updated to tabView

This commit is contained in:
wt
2026-08-30 04:07:04 +07:00
parent efe3986ba5
commit 97e8f4bab6
6 changed files with 556 additions and 73 deletions
+5 -2
View File
@@ -13,8 +13,11 @@ and the Martin / MartinOMEMO libraries. The Xcode project is generated from
(`ChatMessage`, `Conversation`).
- `UI/` — SwiftUI views; chat/contact lists read SwiftData through
`@Query` (`MainTabView`, `ChatView`, `ForwardMessageView`); the main
screen is a Telegram-style bottom tab bar (Контакты/Звонки/Чаты/
Настройки).
screen is Telegram-style: an iOS native `TabView` tab bar
(Контакты/Звонки/Чаты/Настройки) with inline search fields above
the lists, and a
macOS Telegram Desktop-style split view (`MainSplitView`: sidebar
with menu/search/list plus a chat detail pane).
- `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store,
`SASLprep` (RFC 4013), SCRAM-SHA-512 mechanism, SASL failure
observer/messages.
+4
View File
@@ -160,6 +160,7 @@
9AB2A519206C5016B0D8C6DE /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; };
9B53D50C5EDE4AD507C3F5B6 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; };
9CA60ADF5AEFB8CE70B25EE8 /* EncryptionDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */; };
9E36AF2F40D4EC3AF5CE1680 /* LayoutCheckUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */; };
9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */; };
9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */; };
9F288ACC92CD62A2E0CCE157 /* SASLprep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */; };
@@ -355,6 +356,7 @@
61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicyTests.swift; sourceTree = "<group>"; };
6317FAA29F22A3EA8450208F /* LumaTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySync.swift; sourceTree = "<group>"; };
6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutCheckUITests.swift; sourceTree = "<group>"; };
6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupConversationTests.swift; sourceTree = "<group>"; };
69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessage.swift; sourceTree = "<group>"; };
6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerDismissGestureTests.swift; sourceTree = "<group>"; };
@@ -574,6 +576,7 @@
307BDB7CACD11611A6CCDC4E /* UITests */ = {
isa = PBXGroup;
children = (
6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */,
0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */,
);
path = UITests;
@@ -1191,6 +1194,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9E36AF2F40D4EC3AF5CE1680 /* LayoutCheckUITests.swift in Sources */,
48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
+16
View File
@@ -286,6 +286,22 @@ grep -q '@Query' Sources/Shared/UI/MainTabView.swift || {
echo "Chat list must be SwiftData @Query-driven"
exit 1
}
grep -q 'NavigationSplitView' Sources/Shared/UI/MainTabView.swift || {
echo "macOS must keep the Telegram Desktop-style split view"
exit 1
}
grep -q 'SearchField' Sources/Shared/UI/MainTabView.swift || {
echo "Search fields must stay inline above the lists"
exit 1
}
grep -q 'TabView(selection:' Sources/Shared/UI/MainTabView.swift || {
echo "The iOS main screen must use the native TabView tab bar"
exit 1
}
grep -q 'toolbar(.hidden, for: .tabBar)' Sources/Shared/UI/ChatView.swift || {
echo "The tab bar must be hidden inside a pushed chat"
exit 1
}
grep -q '@Query' Sources/Shared/UI/ChatView.swift || {
echo "Chat timeline must be SwiftData @Query-driven"
exit 1
+3
View File
@@ -226,6 +226,9 @@ struct ChatView: View {
replyThreadSelection == nil ? .visible : .hidden,
for: .navigationBar
)
// A pushed conversation takes the whole screen; the tab bar
// comes back when the user pops back to the list.
.toolbar(.hidden, for: .tabBar)
#endif
.fileImporter(
isPresented: $showingFileImporter,
+439 -71
View File
@@ -2,10 +2,67 @@ import Foundation
import SwiftData
import SwiftUI
/// Telegram-style main screen: a custom bottom tab bar (Контакты / Звонки /
/// Чаты / Настройки) shared by iOS and macOS, with a search field above the
/// chat and contact lists.
/// Telegram-style main screen. On iOS the root is a bottom tab bar
/// (Контакты / Звонки / Чаты / Настройки) with an inline search field at
/// the top of the chat and contact lists. On macOS the root is a Telegram
/// Desktop-style split view: a sidebar (menu, search, list) plus a detail
/// pane with the selected chat.
struct MainTabView: View {
@ObservedObject var model: AppModel
var body: some View {
#if os(macOS)
MainSplitView(model: model)
#else
MainTabBarView(model: model)
#endif
}
}
// MARK: - Shared search field
/// Inline rounded search field, placed explicitly above the list it
/// filters. Used on both platforms so the position never depends on
/// navigation-bar search placement.
private struct SearchField: View {
@Binding var text: String
let prompt: String
var body: some View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.system(size: 13))
.foregroundStyle(.secondary)
TextField(prompt, text: $text)
.textFieldStyle(.plain)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
if !text.isEmpty {
Button {
text = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Очистить поиск")
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(Color.secondary.opacity(0.14), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
}
// MARK: - iOS: bottom tab bar
#if os(iOS)
private struct MainTabBarView: View {
enum Tab: String, CaseIterable, Identifiable {
case contacts
case calls
@@ -46,56 +103,39 @@ struct MainTabView: View {
@State private var selectedTab: Tab = .chats
var body: some View {
VStack(spacing: 0) {
Group {
switch selectedTab {
case .contacts:
ContactsTab(model: model)
case .calls:
CallsTab(model: model)
case .chats:
ChatsTab(model: model)
case .settings:
NavigationStack {
SettingsView(model: model, presentedAsTab: true)
// Native TabView: on iOS 26 it renders the system floating
// translucent tab bar (Liquid Glass).
TabView(selection: $selectedTab) {
ForEach(Tab.allCases) { tab in
tabContent(for: tab)
.tabItem {
Label(
tab.title,
systemImage: selectedTab == tab ? tab.selectedIcon : tab.icon
)
}
}
.tag(tab)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
tabBar
}
.ignoresSafeArea(.keyboard, edges: .bottom)
}
private var tabBar: some View {
HStack(spacing: 0) {
ForEach(Tab.allCases) { tab in
Button {
selectedTab = tab
} label: {
VStack(spacing: 3) {
Image(systemName: selectedTab == tab ? tab.selectedIcon : tab.icon)
.font(.system(size: 21, weight: .medium))
Text(tab.title)
.font(.system(size: 10, weight: selectedTab == tab ? .semibold : .regular))
}
.foregroundStyle(selectedTab == tab ? Color.accentColor : Color.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(tab.title)
.help(tab.title)
@ViewBuilder
private func tabContent(for tab: Tab) -> some View {
switch tab {
case .contacts:
ContactsTab(model: model)
case .calls:
CallsTab(model: model)
case .chats:
ChatsTab(model: model)
case .settings:
NavigationStack {
SettingsView(model: model, presentedAsTab: true)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 2)
.background(.bar)
}
}
#endif
// MARK: - Chats
@@ -107,9 +147,9 @@ private struct ChatsTab: View {
@State private var pendingGroupDeletion: Conversation?
/// SwiftData-backed chat list. The query orders by last activity;
/// `filteredConversations` applies the pinned-first ordering that matches
/// `AppModel.sortConversations` (`Bool` is not `Comparable`, so pinning
/// cannot be a `SortDescriptor`).
/// `filteredConversations` applies the pinned-first ordering that
/// matches `AppModel.sortConversations` (`Bool` is not `Comparable`,
/// so pinning cannot be a `SortDescriptor`).
@Query(
sort: [
SortDescriptor(\Conversation.lastActivity, order: .reverse),
@@ -120,16 +160,11 @@ private struct ChatsTab: View {
var body: some View {
NavigationStack {
VStack(spacing: 0) {
SearchField(text: $searchText, prompt: "Поиск чатов")
ConnectionBanner(model: model)
#if os(macOS)
searchField
#endif
chatList
}
.navigationTitle("Чаты")
#if os(iOS)
.searchable(text: $searchText, prompt: "Поиск чатов")
#endif
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button { showingNewGroup = true } label: {
@@ -217,15 +252,6 @@ private struct ChatsTab: View {
}
}
#if os(macOS)
private var searchField: some View {
TextField("Поиск чатов", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
#endif
private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool {
if lhs.isPinned != rhs.isPinned { return lhs.isPinned }
if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity }
@@ -255,19 +281,11 @@ private struct ContactsTab: View {
var body: some View {
NavigationStack {
VStack(spacing: 0) {
SearchField(text: $searchText, prompt: "Поиск контактов")
ConnectionBanner(model: model)
#if os(macOS)
TextField("Поиск контактов", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 12)
.padding(.vertical, 8)
#endif
contactsList
}
.navigationTitle("Контакты")
#if os(iOS)
.searchable(text: $searchText, prompt: "Поиск контактов")
#endif
}
}
@@ -482,3 +500,353 @@ private struct ContactRow: View {
}
}
// MARK: - macOS: Telegram Desktop-style split view
#if os(macOS)
private struct MainSplitView: View {
private enum Mode: String, CaseIterable, Identifiable {
case chats
case contacts
case calls
case settings
var id: String { rawValue }
var title: String {
switch self {
case .chats: return "Чаты"
case .contacts: return "Контакты"
case .calls: return "Звонки"
case .settings: return "Настройки"
}
}
var icon: String {
switch self {
case .chats: return "bubble.left.and.bubble.right"
case .contacts: return "person.2"
case .calls: return "phone"
case .settings: return "gearshape"
}
}
var searchPrompt: String {
switch self {
case .chats: return "Поиск чатов"
case .contacts: return "Поиск контактов"
case .calls: return "Поиск звонков"
case .settings: return "Поиск"
}
}
}
@ObservedObject var model: AppModel
@State private var mode: Mode = .chats
@State private var selectedJID: String?
@State private var searchText = ""
@State private var showingNewChat = false
@State private var showingNewGroup = false
@State private var pendingGroupDeletion: Conversation?
/// SwiftData-backed chat list; pinned-first ordering is applied in
/// `sortedChats` because `Bool` is not `Comparable` and cannot be a
/// `SortDescriptor`.
@Query(
sort: [
SortDescriptor(\Conversation.lastActivity, order: .reverse),
]
)
private var conversations: [Conversation]
var body: some View {
NavigationSplitView {
sidebar
.navigationSplitViewColumnWidth(min: 240, ideal: 290, max: 380)
} detail: {
detailPane
}
.frame(minWidth: 840, minHeight: 560)
.toolbar {
if mode != .settings {
ToolbarItemGroup(placement: .primaryAction) {
Button { showingNewGroup = true } label: {
Image(systemName: "person.3.fill")
}
.accessibilityLabel("Новый групповой чат")
.help("Новый групповой чат")
Button { showingNewChat = true } label: {
Image(systemName: "square.and.pencil")
}
.accessibilityLabel("Новый личный чат")
.help("Новый личный чат")
}
}
}
.sheet(isPresented: $showingNewChat) {
NewChatView(model: model)
}
.sheet(isPresented: $showingNewGroup) {
NewGroupView(model: model)
}
.alert(
"Удалить групповой чат?",
isPresented: Binding(
get: { pendingGroupDeletion != nil },
set: { if !$0 { pendingGroupDeletion = nil } }
)
) {
Button("Удалить", role: .destructive) {
if let pendingGroupDeletion {
model.deleteGroupChat(jid: pendingGroupDeletion.jid)
}
pendingGroupDeletion = nil
}
Button("Отмена", role: .cancel) { pendingGroupDeletion = nil }
} message: {
Text("Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней.")
}
.onAppear {
ensureSelection()
}
}
// MARK: Sidebar
private var sidebar: some View {
VStack(spacing: 0) {
HStack(spacing: 8) {
Menu {
ForEach(Mode.allCases) { item in
Button {
switchMode(item)
} label: {
Label(item.title, systemImage: item.icon)
}
}
} label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 13, weight: .semibold))
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.menuStyle(.borderlessButton)
.fixedSize()
.help("Меню")
.accessibilityLabel("Меню")
if mode != .settings {
SearchField(text: $searchText, prompt: mode.searchPrompt)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
if mode != .settings {
ConnectionBanner(model: model)
sidebarList
}
}
}
private var sidebarList: some View {
List(selection: $selectedJID) {
switch mode {
case .chats:
chatRows
case .contacts:
contactRows
case .calls:
callRows
case .settings:
EmptyView()
}
}
.listStyle(.sidebar)
.overlay {
if mode == .chats && filteredChats.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет чатов" : "Ничего не найдено",
systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass",
description: Text(searchText.isEmpty
? "Создайте личный или групповой чат."
: "Попробуйте другой JID или имя.")
)
}
if mode == .contacts && filteredRoster.isEmpty && filteredGroups.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет контактов" : "Ничего не найдено",
systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass",
description: Text(searchText.isEmpty
? "Подключитесь к серверу, чтобы загрузить roster, или создайте групповой чат."
: "Попробуйте другой JID или имя.")
)
}
}
}
@ViewBuilder
private var chatRows: some View {
ForEach(filteredChats) { conversation in
ConversationRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid),
isEncrypted: model.encryptionEnabled(for: conversation.jid)
)
.tag(conversation.jid)
.contextMenu {
if conversation.isGroup {
Button("Удалить", role: .destructive) {
pendingGroupDeletion = conversation
}
}
}
}
}
@ViewBuilder
private var contactRows: some View {
if !filteredRoster.isEmpty {
Section("Люди из roster") {
ForEach(filteredRoster) { conversation in
ContactRow(conversation: conversation, imageData: model.avatarData(for: conversation.jid))
.tag(conversation.jid)
}
}
}
if !filteredGroups.isEmpty {
Section("Групповые чаты") {
ForEach(filteredGroups) { conversation in
ContactRow(conversation: conversation, imageData: model.avatarData(for: conversation.jid))
.tag(conversation.jid)
}
}
}
}
@ViewBuilder
private var callRows: some View {
ForEach(filteredCalls) { message in
if let conversation = conversations.first(where: { $0.jid == message.conversationID }) {
CallHistoryRow(model: model, message: message, conversation: conversation)
.tag(message.clientID)
} else {
CallHistoryRow(model: model, message: message, conversation: nil)
.tag(message.clientID)
}
}
}
// MARK: Detail pane
@ViewBuilder
private var detailPane: some View {
if mode == .settings {
NavigationStack {
SettingsView(model: model, presentedAsTab: true)
}
} else if let conversation = selectedConversation ?? defaultConversation {
ChatView(model: model, conversation: conversation)
.id(conversation.jid)
.onAppear {
model.selectConversation(id: conversation.jid)
}
} else {
ContentUnavailableView(
emptyTitle,
systemImage: "bubble.left.and.bubble.right",
description: Text("Выберите пункт из списка слева.")
)
}
}
private var emptyTitle: String {
switch mode {
case .chats: return "Выберите чат"
case .contacts: return "Выберите контакт"
case .calls: return "Выберите звонок"
case .settings: return "Настройки"
}
}
private var selectedConversation: Conversation? {
guard let selectedJID else { return nil }
if mode == .calls {
guard let message = model.callHistoryMessages.first(where: { $0.clientID == selectedJID }) else {
return nil
}
return conversations.first(where: { $0.jid == message.conversationID })
}
return conversations.first(where: { $0.jid == selectedJID })
}
private var defaultConversation: Conversation? {
mode == .chats ? sortedChats.first : nil
}
// MARK: Filtering and sorting
private var sortedChats: [Conversation] {
conversations.sorted(by: conversationSort)
}
private var filteredChats: [Conversation] {
searchText.isEmpty ? sortedChats : sortedChats.filter(matchesChat)
}
private func matchesChat(_ conversation: Conversation) -> Bool {
conversation.displayName.localizedCaseInsensitiveContains(searchText)
|| conversation.jid.localizedCaseInsensitiveContains(searchText)
}
private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool {
if lhs.isPinned != rhs.isPinned { return lhs.isPinned }
if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity }
return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
== .orderedAscending
}
private var filteredRoster: [Conversation] {
conversations
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) }
.filter { searchText.isEmpty || matchesChat($0) }
.sorted(by: contactSort)
}
private var filteredGroups: [Conversation] {
conversations
.filter { $0.isGroup }
.filter { searchText.isEmpty || matchesChat($0) }
.sorted(by: contactSort)
}
private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool {
lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
}
private var filteredCalls: [ChatMessage] {
guard !searchText.isEmpty else { return model.callHistoryMessages }
return model.callHistoryMessages.filter { message in
message.conversationID.localizedCaseInsensitiveContains(searchText)
|| (conversations.first(where: { $0.jid == message.conversationID })?.displayName
.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
// MARK: Selection
private func switchMode(_ newMode: Mode) {
mode = newMode
selectedJID = nil
searchText = ""
if newMode == .chats {
selectedJID = sortedChats.first?.jid
}
}
private func ensureSelection() {
if mode == .chats, selectedJID == nil {
selectedJID = sortedChats.first?.jid
}
}
}
#endif
+89
View File
@@ -0,0 +1,89 @@
import XCTest
/// Layout regression checks: the inline search fields must live at the top
/// of the Chats and Contacts tabs, above the lists (they used to be pushed
/// to the bottom when implemented via `.searchable`).
final class LayoutCheckUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
private func launchApp() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = ["-luma-ui-test-chat"]
app.launch()
return app
}
func testChatsAndContactsSearchFieldsAreAtTop() throws {
let app = launchApp()
dismissAnyAlert(app)
// Keep the clean screen on display long enough for external
// `simctl io screenshot` captures of the tab bar.
Thread.sleep(forTimeInterval: 12)
let chatSearch = app.textFields["Поиск чатов"]
XCTAssertTrue(chatSearch.waitForExistence(timeout: 30))
addScreenshot(named: "chats-search-top", app: app)
let chatRow = app.staticTexts["uitest-peer"]
XCTAssertTrue(chatRow.waitForExistence(timeout: 15))
XCTAssertLessThan(
chatSearch.frame.midY,
chatRow.frame.midY,
"The chats search field must sit above the chat list"
)
app.buttons["Контакты"].tap()
let contactSearch = app.textFields["Поиск контактов"]
XCTAssertTrue(contactSearch.waitForExistence(timeout: 15))
XCTAssertLessThan(
contactSearch.frame.minY,
app.frame.height * 0.4,
"The contacts search field must sit near the top of the screen"
)
addScreenshot(named: "contacts-search-top", app: app)
}
func testTabBarHidesInsideChat() throws {
let app = launchApp()
dismissAnyAlert(app)
let chatRow = app.staticTexts["uitest-peer"]
XCTAssertTrue(chatRow.waitForExistence(timeout: 30))
chatRow.tap()
let timeline = app.descendants(matching: .any).matching(identifier: "chat-timeline").firstMatch
XCTAssertTrue(timeline.waitForExistence(timeout: 30))
// A pushed conversation takes the whole screen: no tab bar.
let tabBar = app.tabBars.firstMatch
XCTAssertFalse(tabBar.exists, "The tab bar must be hidden inside a chat")
// Popping back to the list restores the tab bar.
app.navigationBars.buttons.firstMatch.tap()
XCTAssertTrue(tabBar.waitForExistence(timeout: 15))
}
private func addScreenshot(named name: String, app: XCUIApplication) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
/// The unsigned debug build can present a system "missing rights" alert
/// on the simulator; dismiss it so it does not cover the UI.
private func dismissAnyAlert(_ app: XCUIApplication) {
let hosts = [app, XCUIApplication(bundleIdentifier: "com.apple.springboard")]
for host in hosts {
let ok = host.alerts.buttons["OK"]
if ok.waitForExistence(timeout: 8) {
ok.tap()
return
}
}
}
}