From b24e30372520401c0f3d9e282d76cf96755ae3cf Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 16 Oct 2026 23:45:08 +0800 Subject: [PATCH] refactor(ui): simplify discovery and custom theme cards --- .../HostConnectionFields.swift | 19 ++- .../HostWakeOnLANSection.swift | 4 +- .../LocalDiscoveryView.swift | 12 +- .../Terminal/TerminalCustomThemeSummary.swift | 13 +- .../HostEditor/MobileHostEditor.swift | 13 +- .../HostEditor/MobileLocalDiscoveryView.swift | 9 +- .../Settings/MobileCustomThemesView.swift | 13 +- TFI/Features/Vault/MobileVaultView.swift | 17 +- TFITests/MobileTerminalThemeTests.swift | 21 +++ .../MobileLocalDiscoveryRenderingTests.swift | 153 ++++++++++++++++++ TFM/App/TermindCommands.swift | 2 +- TFM/Features/HostEditor/HostEditorView.swift | 11 +- .../HostEditor/HostLocalDiscoveryView.swift | 2 +- .../HostEditor/HostMACAddressView.swift | 2 +- TFM/Features/Hosts/HostsPageView.swift | 6 +- .../Settings/TerminalCustomThemesPage.swift | 14 +- .../LocalDiscoveryRenderingTests.swift | 79 +++++++++ .../TerminalThemePageRenderingTests.swift | 43 ++++- 18 files changed, 334 insertions(+), 99 deletions(-) create mode 100644 TFITests/Screenshots/MobileLocalDiscoveryRenderingTests.swift create mode 100644 TFMTests/Screenshots/LocalDiscoveryRenderingTests.swift diff --git a/Frameworks/TermindPresentation/HostConnectionFields.swift b/Frameworks/TermindPresentation/HostConnectionFields.swift index d9180f4d..54c15222 100644 --- a/Frameworks/TermindPresentation/HostConnectionFields.swift +++ b/Frameworks/TermindPresentation/HostConnectionFields.swift @@ -57,15 +57,15 @@ public struct HostProtocolProfileControls: View { public struct HostConnectionFields: View { @Binding private var host: Host private let connectionProtocol: HostTerminalProtocol + private let discover: (() -> Void)? - public init(host: Binding, protocol connectionProtocol: HostTerminalProtocol) { + public init( + host: Binding, protocol connectionProtocol: HostTerminalProtocol = .ssh, + discover: (() -> Void)? = nil + ) { _host = host self.connectionProtocol = connectionProtocol - } - - public init(host: Binding) { - _host = host - connectionProtocol = .ssh + self.discover = discover } public var body: some View { @@ -94,6 +94,13 @@ public struct HostConnectionFields: View { #endif } } + if let discover, !host.isTailscaleManaged, + LocalDiscoveryProtocol(terminalProtocol: connectionProtocol) != nil + { + Button( + AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "dot.radiowaves.left.and.right", + action: discover) + } } } diff --git a/Frameworks/TermindPresentation/HostWakeOnLANSection.swift b/Frameworks/TermindPresentation/HostWakeOnLANSection.swift index a10a6025..730d6ee9 100644 --- a/Frameworks/TermindPresentation/HostWakeOnLANSection.swift +++ b/Frameworks/TermindPresentation/HostWakeOnLANSection.swift @@ -27,7 +27,9 @@ public struct HostWakeOnLANSection: View { .textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.asciiCapable) #endif if let fetchMAC { - Button(AppStrings.LocalNetwork.fetchMAC.swiftUIKey, action: fetchMAC) + Button( + AppStrings.LocalNetwork.fetchMAC.swiftUIKey, systemImage: "rectangle.connected.to.line.below", + action: fetchMAC) } Toggle( AppStrings.LocalNetwork.autoWake.swiftUIKey, diff --git a/Frameworks/TermindPresentation/LocalDiscoveryView.swift b/Frameworks/TermindPresentation/LocalDiscoveryView.swift index df90f27a..ace97faa 100644 --- a/Frameworks/TermindPresentation/LocalDiscoveryView.swift +++ b/Frameworks/TermindPresentation/LocalDiscoveryView.swift @@ -21,7 +21,7 @@ public struct LocalDiscoveryView: View { }.frame(maxWidth: .infinity, maxHeight: .infinity) } else if model.hosts.isEmpty { ContentUnavailableView { - Label(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "network") + Label(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "dot.radiowaves.left.and.right") } description: { Text(status) } @@ -33,16 +33,6 @@ public struct LocalDiscoveryView: View { VStack(alignment: .leading, spacing: 6) { Text(host.name).font(.headline) Text("\(host.address):\(host.port)").font(.callout.monospaced()) - Text( - host.connectionProtocol == .telnet - ? AppStrings.LocalNetwork.telnetPortOpen - : host.source == .bonjour - ? AppStrings.LocalNetwork.bonjour : AppStrings.LocalNetwork.verifiedSSH - ).font(.caption).foregroundStyle(.secondary) - if host.source == .bonjour { - Text(AppStrings.LocalNetwork.advertisedSSH).font(.caption).foregroundStyle( - .secondary) - } }.padding(.vertical, 4) }.buttonStyle(.plain) } diff --git a/Frameworks/TermindPresentation/Terminal/TerminalCustomThemeSummary.swift b/Frameworks/TermindPresentation/Terminal/TerminalCustomThemeSummary.swift index e04035b9..f390a65d 100644 --- a/Frameworks/TermindPresentation/Terminal/TerminalCustomThemeSummary.swift +++ b/Frameworks/TermindPresentation/Terminal/TerminalCustomThemeSummary.swift @@ -3,11 +3,9 @@ import TermindDomain public struct TerminalCustomThemeSummary: View { private let theme: TerminalCustomTheme - private let library: TerminalThemeLibrary - public init(theme: TerminalCustomTheme, library: TerminalThemeLibrary) { + public init(theme: TerminalCustomTheme) { self.theme = theme - self.library = library } public var body: some View { @@ -17,15 +15,6 @@ public struct TerminalCustomThemeSummary: View { VStack(alignment: .leading, spacing: 6) { Text(verbatim: theme.name).font(.headline) Text(theme.appearance.title).font(.caption).foregroundStyle(.secondary) - ForEach([false, true], id: \.self) { isDark in - if library.selectedTheme(isDark: isDark)?.id == theme.id { - Label( - (isDark ? AppStrings.Settings.darkTheme : AppStrings.Settings.lightTheme).swiftUIKey, - systemImage: "checkmark" - ) - .font(.caption).foregroundStyle(.tint) - } - } } Spacer(minLength: 0) } diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 6971d955..34e5d0fe 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -31,9 +31,8 @@ struct MobileHostEditor: View { @State private var didLoadCustom = false @State private var selectedProtocol: HostTerminalProtocol - init(app: MobileAppModel, host: Host, discover: Bool = false) { + init(app: MobileAppModel, host: Host) { self.app = app - _showsDiscovery = State(initialValue: discover) original = host _draft = State(initialValue: host) _selectedProtocol = State(initialValue: host.sshEnabled ? .ssh : .telnet) @@ -92,14 +91,8 @@ struct MobileHostEditor: View { MobileTagField(tags: $draft.tags, suggestions: library.allTags) } } - HostConnectionFields(host: $draft, protocol: selectedProtocol).disabled(draft.isTailscaleManaged) - if !draft.isTailscaleManaged, LocalDiscoveryProtocol(terminalProtocol: selectedProtocol) != nil { - Section { - Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "network") { - showsDiscovery = true - } - } - } + HostConnectionFields(host: $draft, protocol: selectedProtocol, discover: { showsDiscovery = true }) + .disabled(draft.isTailscaleManaged) if selectedProtocol == .ssh && draft.sshEnabled { if draft.isTailscaleManaged { Section(AppStrings.Vault.authentication.swiftUIKey) { diff --git a/TFI/Features/HostEditor/MobileLocalDiscoveryView.swift b/TFI/Features/HostEditor/MobileLocalDiscoveryView.swift index 1d8f9112..04b7d6bc 100644 --- a/TFI/Features/HostEditor/MobileLocalDiscoveryView.swift +++ b/TFI/Features/HostEditor/MobileLocalDiscoveryView.swift @@ -17,11 +17,14 @@ struct MobileLocalDiscoveryView: View { .navigationTitle(AppStrings.LocalNetwork.discovery.swiftUIKey) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(AppStrings.Common.cancel.swiftUIKey) { dismiss() } + Button(AppStrings.Common.cancel.swiftUIKey, systemImage: "xmark") { dismiss() } + .labelStyle(.iconOnly) } - ToolbarItem(placement: .primaryAction) { - Button(AppStrings.LocalNetwork.rescan.swiftUIKey) { model.scan() } + ToolbarItem(placement: .confirmationAction) { + Button(AppStrings.LocalNetwork.rescan.swiftUIKey, systemImage: "arrow.clockwise") { model.scan() } + .labelStyle(.iconOnly) } + .mobileProminentAction() } } } diff --git a/TFI/Features/Settings/MobileCustomThemesView.swift b/TFI/Features/Settings/MobileCustomThemesView.swift index ffe83fc7..67462831 100644 --- a/TFI/Features/Settings/MobileCustomThemesView.swift +++ b/TFI/Features/Settings/MobileCustomThemesView.swift @@ -28,21 +28,10 @@ struct MobileCustomThemesView: View { MobileActionCard(alignment: .leading) { draft = .init(theme, isNew: false) } content: { - TerminalCustomThemeSummary(theme: theme, library: model.themeLibrary) - .padding(.trailing, 32) + TerminalCustomThemeSummary(theme: theme) } actions: { actions(theme) } - .overlay(alignment: .topTrailing) { - Menu { - actions(theme) - } label: { - Label(AppStrings.Common.edit.swiftUIKey, systemImage: "ellipsis") - .labelStyle(.iconOnly) - .frame(width: 44, height: 44).contentShape(.rect) - } - .padding(4) - } .operationConfirmation( .init(AppStrings.Settings.deleteCustomThemeConfirmation), isPresented: $deleting.isPresenting { $0.id == theme.id }, diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 978f6a20..a14d6779 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -300,7 +300,6 @@ struct MobileVaultView: View { let executeSnippet: (Snippet, [Host.ID]) -> Void private let openMetrics: (() -> Void)? @Binding var editingHost: Host? - @State private var discoverNewHost = false @State private var wakeNotice: String? @State private var query = "" @State private var tailscaleHost: Host? @@ -505,10 +504,6 @@ struct MobileVaultView: View { Button(AppStrings.Vault.newHost.swiftUIKey, systemImage: "server.rack") { editingHost = Host(name: "", host: "") } - Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "network") { - discoverNewHost = true - editingHost = Host(name: "", host: "") - } Button(AppStrings.Vault.newGroup.swiftUIKey, systemImage: "folder.badge.plus") { groupEditorRequest = GroupEditorRequest(groupID: nil, parentID: nil) } @@ -520,8 +515,8 @@ struct MobileVaultView: View { .sheet(item: $tailscaleHost) { host in TailscalePeerView(model: app.tailscale, host: host) { UIPasteboard.general.string = $0 } } - .sheet(item: $editingHost, onDismiss: { discoverNewHost = false }) { - MobileHostEditor(app: app, host: $0, discover: discoverNewHost) + .sheet(item: $editingHost) { + MobileHostEditor(app: app, host: $0) } .alert( AppStrings.LocalNetwork.wake, @@ -700,10 +695,6 @@ struct MobileVaultView: View { Button(AppStrings.Vault.newHost.swiftUIKey, systemImage: "server.rack") { editingHost = Host(name: "", host: "", groupID: group.id) } - Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "network") { - discoverNewHost = true - editingHost = Host(name: "", host: "", groupID: group.id) - } Button(AppStrings.Vault.newSubgroup.swiftUIKey, systemImage: "folder.badge.plus") { groupEditorRequest = GroupEditorRequest(groupID: nil, parentID: group.id) } @@ -736,10 +727,6 @@ struct MobileVaultView: View { Button(AppStrings.Vault.newHost.swiftUIKey, systemImage: "server.rack") { editingHost = Host(name: "", host: "") } - Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "network") { - discoverNewHost = true - editingHost = Host(name: "", host: "") - } Button(AppStrings.Vault.newGroup.swiftUIKey, systemImage: "folder.badge.plus") { groupEditorRequest = GroupEditorRequest(groupID: nil, parentID: nil) } diff --git a/TFITests/MobileTerminalThemeTests.swift b/TFITests/MobileTerminalThemeTests.swift index 737ea181..e5e5738f 100644 --- a/TFITests/MobileTerminalThemeTests.swift +++ b/TFITests/MobileTerminalThemeTests.swift @@ -78,6 +78,7 @@ struct MobileTerminalThemeTests { model.saveCustomTheme( .init(name: "Daylight", appearance: .light, baseThemeName: "Termind Light"), isNew: true) model.selectCustomTheme(id: theme.id, isDark: true) + model.selectCustomTheme(id: theme.id, isDark: false) let initial = model.themeLibrary let draft = TerminalCustomThemeDraft(theme, isNew: false) let content = Group { @@ -113,6 +114,12 @@ struct MobileTerminalThemeTests { search.searchBar.placeholder == AppLocalization.resolve(AppStrings.Settings.searchThemes, locale: Locale(identifier: language))) #expect(navigation.topViewController?.navigationItem.hidesSearchBarWhenScrolling == false) + let labels = accessibilityLabels(in: window) + #expect(labels.contains { $0.contains(theme.name) }) + for resource in [AppStrings.Settings.lightTheme, AppStrings.Settings.darkTheme, AppStrings.Common.edit] { + let unwanted = AppLocalization.resolve(resource, locale: Locale(identifier: language)) + #expect(!labels.contains { $0.contains(unwanted) }) + } } let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) @@ -176,6 +183,20 @@ struct MobileTerminalThemeTests { return controller.children.lazy.compactMap { navigationController(in: $0) }.first } + private func accessibilityLabels(in root: NSObject) -> [String] { + var seen = Set() + func visit(_ element: NSObject) -> [String] { + guard seen.insert(ObjectIdentifier(element)).inserted else { return [] } + var children: [NSObject] = (element as? UIView)?.subviews ?? [] + let count = element.accessibilityElementCount() + if count != NSNotFound, count > 0 { + children += (0.. [UIScrollView] { let scroll = (view as? UIScrollView).map { [$0] } ?? [] return scroll + view.subviews.flatMap { scrollViews(in: $0) } diff --git a/TFITests/Screenshots/MobileLocalDiscoveryRenderingTests.swift b/TFITests/Screenshots/MobileLocalDiscoveryRenderingTests.swift new file mode 100644 index 00000000..5082d3c9 --- /dev/null +++ b/TFITests/Screenshots/MobileLocalDiscoveryRenderingTests.swift @@ -0,0 +1,153 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing +import UIKit + +@testable import TermindIOS + +@Suite(.requiresKeyWindow, .serialized) @MainActor +struct MobileLocalDiscoveryRenderingTests { + @Test(arguments: [HostTerminalProtocol.ssh, .telnet, .mosh, .eternalTerminal], [false, true]) + func connectionActionsRespectProtocolAndManagedHosts( + connectionProtocol: HostTerminalProtocol, managed: Bool + ) async throws { + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.keyWindow + var host = TermindDomain.Host(name: "Example", host: "192.168.1.24") + if managed { host.tailscale = .init(groupID: UUID(), stableNodeID: "preview") } + let window = UIWindow(windowScene: scene) + window.overrideUserInterfaceStyle = .light + let controller = UIHostingController( + rootView: NavigationStack { + Form { + HostConnectionFields(host: .constant(host), protocol: connectionProtocol, discover: {}) + HostWakeOnLANSection( + preferences: .constant(.init()), requestAccess: { true }, fetchMAC: {}) + } + .navigationTitle(AppStrings.Vault.addHost.swiftUIKey) + } + .environment(\.locale, Locale(identifier: "en"))) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + } + try await Task.sleep(for: .milliseconds(500)) + let labels = accessibilityLabels(in: window) + let discovery = AppLocalization.resolve(AppStrings.LocalNetwork.discovery, locale: Locale(identifier: "en")) + let fetchMAC = AppLocalization.resolve(AppStrings.LocalNetwork.fetchMAC, locale: Locale(identifier: "en")) + let supportsDiscovery = !managed && [.ssh, .telnet].contains(connectionProtocol) + #expect(labels.contains { $0.contains(discovery) } == supportsDiscovery) + #expect(labels.contains { $0.contains(fetchMAC) }) + if !managed, connectionProtocol == .ssh { + let data = UIGraphicsImageRenderer(bounds: window.bounds).pngData { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + Attachment.record(data, named: "iphone-connection-actions.png") + } + } + + @Test(arguments: ["en", "zh-Hans"]) + func discoveredHostsUseTheNativeSheet(language: String) async throws { + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.keyWindow + let service = PreviewDiscovery() + let model = LocalDiscoveryModel(service: service) + let window = UIWindow(windowScene: scene) + window.overrideUserInterfaceStyle = .light + let controller = UIHostingController( + rootView: DiscoverySheetPreview(model: model) + .environment(\.locale, Locale(identifier: language))) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + model.cancel() + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + } + for _ in 0..<100 { + if model.phase == .finished { break } + try await Task.sleep(for: .milliseconds(20)) + } + try #require(model.phase == .finished) + #expect(model.hosts.contains { $0.source == .bonjour }) + #expect(model.hosts.contains { $0.source == .probe }) + #expect(service.scanCount == 1) + try #require(controller.presentedViewController != nil) + model.scan() + for _ in 0..<100 { + if model.phase == .finished { break } + try await Task.sleep(for: .milliseconds(20)) + } + try #require(model.phase == .finished) + #expect(service.scanCount == 2) + try await Task.sleep(for: .milliseconds(700)) + window.layoutIfNeeded() + let labels = accessibilityLabels(in: window) + #expect(labels.contains { $0.contains("192.168.1.24:22") }) + for resource in [ + AppStrings.LocalNetwork.bonjour, AppStrings.LocalNetwork.advertisedSSH, + AppStrings.LocalNetwork.verifiedSSH, AppStrings.LocalNetwork.telnetPortOpen, + ] { + let unwanted = AppLocalization.resolve(resource, locale: Locale(identifier: language)) + #expect(!labels.contains { $0.contains(unwanted) }) + } + let data = UIGraphicsImageRenderer(bounds: window.bounds).pngData { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let name = "iphone-\(language)-discovery.png" + Attachment.record(data, named: name) + let directory = FileManager.default.temporaryDirectory.appending(path: "local-discovery-screenshots") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let output = directory.appending(path: name) + try data.write(to: output) + print("Local discovery screenshot: \(output.path)") + } + + private struct DiscoverySheetPreview: View { + let model: LocalDiscoveryModel + @State private var presented = false + + var body: some View { + Color(uiColor: .systemGroupedBackground) + .sheet(isPresented: $presented) { + MobileLocalDiscoveryView(model: model, select: { _ in }) + } + .onAppear { presented = true } + } + } + + private func accessibilityLabels(in root: NSObject) -> [String] { + var seen = Set() + func visit(_ element: NSObject) -> [String] { + guard seen.insert(ObjectIdentifier(element)).inserted else { return [] } + var children: [NSObject] = (element as? UIView)?.subviews ?? [] + let count = element.accessibilityElementCount() + if count != NSNotFound, count > 0 { + children += (0.. [DiscoveredLocalHost] { + scanCount += 1 + return [ + .init(name: "Mac mini", address: "mac-mini.local", port: 22, source: .bonjour), + .init(name: "192.168.1.24", address: "192.168.1.24", port: 22, source: .probe), + .init(name: "Raspberry Pi", address: "raspberrypi.local", port: 22, source: .bonjour), + ] + } +} diff --git a/TFM/App/TermindCommands.swift b/TFM/App/TermindCommands.swift index 5bfc639f..ebda92d1 100644 --- a/TFM/App/TermindCommands.swift +++ b/TFM/App/TermindCommands.swift @@ -84,7 +84,7 @@ struct TermindCommands: Commands { window?.openHostEditor(host: nil) } .termindShortcut("n", modifiers: [.command, .shift]) - Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "network") { + Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "dot.radiowaves.left.and.right") { window?.openVaultWork(.hostEditor(nil, discover: true), on: .hosts) } .disabled(window == nil) diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 0fb69c16..b4685af5 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -159,15 +159,8 @@ struct HostEditorView: View { } } - HostConnectionFields(host: $draft, protocol: selectedProtocol).disabled(draft.isTailscaleManaged) - - if !draft.isTailscaleManaged, LocalDiscoveryProtocol(terminalProtocol: selectedProtocol) != nil { - Section { - Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "network") { - openDiscovery() - } - } - } + HostConnectionFields(host: $draft, protocol: selectedProtocol, discover: openDiscovery) + .disabled(draft.isTailscaleManaged) if selectedProtocol == .ssh && draft.sshEnabled { if draft.isTailscaleManaged { diff --git a/TFM/Features/HostEditor/HostLocalDiscoveryView.swift b/TFM/Features/HostEditor/HostLocalDiscoveryView.swift index e82005d9..f68fef74 100644 --- a/TFM/Features/HostEditor/HostLocalDiscoveryView.swift +++ b/TFM/Features/HostEditor/HostLocalDiscoveryView.swift @@ -10,7 +10,7 @@ struct HostLocalDiscoveryView: View { var body: some View { VaultDetailScaffold( - title: AppStrings.LocalNetwork.discovery, symbol: "network", close: back, + title: AppStrings.LocalNetwork.discovery, symbol: "dot.radiowaves.left.and.right", close: back, closeLabel: AppStrings.Common.back, closeSymbol: "chevron.backward" ) { LocalDiscoveryView(model: model, select: select) diff --git a/TFM/Features/HostEditor/HostMACAddressView.swift b/TFM/Features/HostEditor/HostMACAddressView.swift index c8b2fd92..b0f38244 100644 --- a/TFM/Features/HostEditor/HostMACAddressView.swift +++ b/TFM/Features/HostEditor/HostMACAddressView.swift @@ -10,7 +10,7 @@ struct HostMACAddressView: View { var body: some View { VaultDetailScaffold( - title: AppStrings.LocalNetwork.fetchMAC, symbol: "network", close: back, + title: AppStrings.LocalNetwork.fetchMAC, symbol: "rectangle.connected.to.line.below", close: back, closeLabel: AppStrings.Common.back, closeSymbol: "chevron.backward" ) { VStack(spacing: 12) { diff --git a/TFM/Features/Hosts/HostsPageView.swift b/TFM/Features/Hosts/HostsPageView.swift index 07742464..9ef2b2bf 100644 --- a/TFM/Features/Hosts/HostsPageView.swift +++ b/TFM/Features/Hosts/HostsPageView.swift @@ -162,7 +162,7 @@ struct HostsPageView: ManagedCardPage { Button(AppStrings.Vault.newHost.swiftUIKey, systemImage: MenuSymbol.newHost) { openHostEditor(nil, groupID: group.id) } - Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "network") { + Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "dot.radiowaves.left.and.right") { openWork(.hostEditor(nil, groupID: group.id, discover: true)) } Button(AppStrings.Vault.newSubgroup.swiftUIKey, systemImage: MenuSymbol.newGroup) { @@ -186,7 +186,7 @@ struct HostsPageView: ManagedCardPage { Button(AppStrings.Vault.newHost.swiftUIKey, systemImage: MenuSymbol.newHost) { openHostEditor(nil) } - Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "network") { + Button(AppStrings.LocalNetwork.discovery.swiftUIKey, systemImage: "dot.radiowaves.left.and.right") { openWork(.hostEditor(nil, discover: true)) } Button(AppStrings.Vault.newGroup.swiftUIKey, systemImage: MenuSymbol.newGroup) { @@ -230,7 +230,7 @@ struct HostsPageView: ManagedCardPage { NativeMenuEntry(title: AppStrings.Vault.newHost, symbol: MenuSymbol.newHost) { openHostEditor(nil, groupID: groupID) }, - NativeMenuEntry(title: AppStrings.LocalNetwork.discovery, symbol: "network") { + NativeMenuEntry(title: AppStrings.LocalNetwork.discovery, symbol: "dot.radiowaves.left.and.right") { openWork(.hostEditor(nil, groupID: groupID, discover: true)) }, NativeMenuEntry(title: AppStrings.Vault.newGroup, symbol: MenuSymbol.newGroup) { diff --git a/TFM/Features/Settings/TerminalCustomThemesPage.swift b/TFM/Features/Settings/TerminalCustomThemesPage.swift index d47c22da..0dc4f5be 100644 --- a/TFM/Features/Settings/TerminalCustomThemesPage.swift +++ b/TFM/Features/Settings/TerminalCustomThemesPage.swift @@ -26,21 +26,9 @@ struct TerminalCustomThemesPage: View { CardGrid { ForEach(app.terminalSettings.themeLibrary.themes(matching: window.customThemeQuery)) { theme in CardTile(activate: { edit(theme) }, activatesOnReturn: true, activatesOnSingleClick: true) { - TerminalCustomThemeSummary(theme: theme, library: app.terminalSettings.themeLibrary) - .padding(.trailing, 24) + TerminalCustomThemeSummary(theme: theme) } .contextMenu { actions(theme) } - .overlay(alignment: .topTrailing) { - Menu { - actions(theme) - } label: { - Label(AppStrings.Common.edit.swiftUIKey, systemImage: "ellipsis").labelStyle( - .iconOnly) - } - .menuStyle(.borderlessButton) - .fixedSize() - .padding(10) - } } } } diff --git a/TFMTests/Screenshots/LocalDiscoveryRenderingTests.swift b/TFMTests/Screenshots/LocalDiscoveryRenderingTests.swift new file mode 100644 index 00000000..883edd5e --- /dev/null +++ b/TFMTests/Screenshots/LocalDiscoveryRenderingTests.swift @@ -0,0 +1,79 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindDomain +import Testing + +@testable import Termind + +@Suite(.requiresVisibleWindow, .serialized) @MainActor +struct LocalDiscoveryRenderingTests { + @Test(arguments: ["en", "zh-Hans"]) + func discoveredHostsUseTheNativeDetail(language: String) async throws { + let service = PreviewDiscovery() + let model = LocalDiscoveryModel(service: service) + let controller = NSHostingController( + rootView: HostLocalDiscoveryView(model: model, back: {}, select: { _ in }) + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, .light) + .frame(width: 720, height: 520)) + controller.sizingOptions = [] + let window = NSWindow( + contentRect: CGRect(x: 100, y: 100, width: 720, height: 520), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.title = "Termind" + window.contentViewController = controller + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + defer { + model.cancel() + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + for _ in 0..<100 { + if model.phase == .finished { break } + try await Task.sleep(for: .milliseconds(20)) + } + try #require(model.phase == .finished) + #expect(model.hosts.contains { $0.source == .bonjour }) + #expect(model.hosts.contains { $0.source == .probe }) + #expect(service.scanCount == 1) + model.scan() + for _ in 0..<100 { + if model.phase == .finished { break } + try await Task.sleep(for: .milliseconds(20)) + } + try #require(model.phase == .finished) + #expect(service.scanCount == 2) + controller.view.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(700)) + let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds)) + controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) + Attachment.record( + try #require(bitmap.representation(using: .png, properties: [:])), + named: "macos-\(language)-discovery.png") + // An external runner can capture compositor output, including native glass, during this interval. + FileHandle.standardOutput.write( + Data("TERMIND_DISCOVERY_WINDOW|\(window.windowNumber)|macos-\(language)-discovery\n".utf8)) + try await Task.sleep(for: .seconds(3)) + } +} + +@MainActor +private final class PreviewDiscovery: LocalHostDiscovering { + let connectionProtocol = LocalDiscoveryProtocol.ssh + private(set) var scanCount = 0 + + func discover() async throws -> [DiscoveredLocalHost] { + scanCount += 1 + return [ + .init(name: "Mac mini", address: "mac-mini.local", port: 22, source: .bonjour), + .init(name: "192.168.1.24", address: "192.168.1.24", port: 22, source: .probe), + .init(name: "Raspberry Pi", address: "raspberrypi.local", port: 22, source: .bonjour), + ] + } +} diff --git a/TFMTests/Screenshots/TerminalThemePageRenderingTests.swift b/TFMTests/Screenshots/TerminalThemePageRenderingTests.swift index e58ccafe..a7697549 100644 --- a/TFMTests/Screenshots/TerminalThemePageRenderingTests.swift +++ b/TFMTests/Screenshots/TerminalThemePageRenderingTests.swift @@ -7,9 +7,50 @@ import Testing @testable import Termind -/// Offscreen hosting leaves the user's windows and keyboard focus untouched. +/// Native rendering; tests that show windows opt in separately. @Suite(.serialized) @MainActor struct TerminalThemePageRenderingTests { + @Test(.requiresVisibleWindow, arguments: ["en", "zh-Hans"]) + func selectedCustomThemeCardsRenderWithoutMenuButtons(language: String) async throws { + let model = TerminalSettingsModel() + let theme = TerminalCustomTheme(name: "Ocean", appearance: .both, baseThemeName: "Termind Light") + model.saveCustomTheme(theme, isNew: true) + model.selectCustomTheme(id: theme.id, isDark: false) + model.selectCustomTheme(id: theme.id, isDark: true) + let initial = model.themeLibrary + let app = AppModel(hosts: HostLibraryModel(), terminalSettings: model) + defer { app.shutdownForTermination() } + let navigation = WindowModel(workspaces: []) + navigation.openSettings(route: .terminalCustomThemes) + let controller = NSHostingController( + rootView: TerminalCustomThemesPage().environment(app).environment(navigation) + .environment(\.locale, Locale(identifier: language)) + .frame(width: 720, height: 520)) + controller.sizingOptions = [] + let window = NSWindow( + contentRect: CGRect(x: 100, y: 100, width: 720, height: 520), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentViewController = controller + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + try await Task.sleep(for: .milliseconds(700)) + controller.view.layoutSubtreeIfNeeded() + #expect(descendants(in: controller.view, ofType: NSPopUpButton.self).isEmpty) + let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds)) + controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) + Attachment.record( + try #require(bitmap.representation(using: .png, properties: [:])), + named: "macos-custom-library-\(language).png") + #expect(model.themeLibrary == initial) + } + @Test( .enabled(if: ProcessInfo.processInfo.environment["TERMIND_THEME_SCREENSHOTS"] == "1"), arguments: ["en", "zh-Hans"], [CGFloat(650), CGFloat(1000)])