Add Quick Action
This commit is contained in:
1 parent
9d73280833
commit
3dbcbcccdf
15 files changed
+436
-42
No files matched your search
@@ -136,6 +136,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"QUICK_ACTION_CONNECT_TITLE" : {
|
||||
"comment" : "Title of the Home Screen quick action that opens the connection picker.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Connect"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "连接"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "連線"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSCameraUsageDescription" : {
|
||||
"comment" : "Privacy - Camera Usage Description",
|
||||
"localizations" : {
|
||||
|
||||
@@ -341,7 +341,11 @@ Each mobile scene owns one navigation/command model and one Assistant library mo
|
||||
including its selected conversation and New Chat draft. The app-wide Assistant runtime
|
||||
and conversation store remain shared so active runs are never duplicated. The scene
|
||||
model is published as a focused value, so hardware-keyboard commands never operate
|
||||
another iPad window. Mobile
|
||||
another iPad window. Home Screen quick actions enter through a per-window scene
|
||||
delegate, are consumed once by that scene's navigation model, and never target another
|
||||
iPad window. Connect is static; up to three dynamic history actions derive from the
|
||||
shared event log. Workspace History and those actions keep the newest display row per
|
||||
Host and connection kind without deleting duplicate persisted events. Mobile
|
||||
uses the macOS shortcuts for capabilities that exist on both platforms: navigation,
|
||||
connection/creation, terminal Find and Command Generator, Workspace close/switch,
|
||||
and Assistant send/stop. Workspace switching never reconnects the session or claims
|
||||
|
||||
@@ -7,9 +7,18 @@ struct MobileRootView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.mobileInterfaceIdiom) private var interfaceIdiom
|
||||
@State private var scene: MobileSceneModel
|
||||
private let pendingQuickAction: MobileHomeScreenQuickAction?
|
||||
private let consumePendingQuickAction: () -> MobileHomeScreenQuickAction?
|
||||
|
||||
init(app: MobileAppModel, scene: MobileSceneModel? = nil) {
|
||||
init(
|
||||
app: MobileAppModel,
|
||||
scene: MobileSceneModel? = nil,
|
||||
pendingQuickAction: MobileHomeScreenQuickAction? = nil,
|
||||
consumePendingQuickAction: @escaping () -> MobileHomeScreenQuickAction? = { nil }
|
||||
) {
|
||||
self.app = app
|
||||
self.pendingQuickAction = pendingQuickAction
|
||||
self.consumePendingQuickAction = consumePendingQuickAction
|
||||
_scene = State(initialValue: scene ?? MobileSceneModel(app: app))
|
||||
}
|
||||
|
||||
@@ -50,7 +59,23 @@ struct MobileRootView: View {
|
||||
app.renameWorkspace(workspace.id, to: title)
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase, initial: true) { _, phase in app.sceneChanged(scene.id, phase: phase) }
|
||||
.onChange(of: scenePhase, initial: true) { _, phase in
|
||||
app.sceneChanged(scene.id, phase: phase)
|
||||
MobileHomeScreenQuickActions.refresh(app: app)
|
||||
}
|
||||
.onChange(of: app.connectionHistory.revision, initial: true) { _, _ in
|
||||
MobileHomeScreenQuickActions.refresh(app: app)
|
||||
}
|
||||
.onChange(of: app.library.hosts) { _, _ in
|
||||
MobileHomeScreenQuickActions.refresh(app: app)
|
||||
}
|
||||
.onChange(of: app.library.identities) { _, _ in
|
||||
MobileHomeScreenQuickActions.refresh(app: app)
|
||||
}
|
||||
.onChange(of: pendingQuickAction, initial: true) { _, action in
|
||||
guard action != nil, let action = consumePendingQuickAction() else { return }
|
||||
_ = scene.perform(action)
|
||||
}
|
||||
.onChange(of: app.pro.hasProAccess) { _, _ in app.reconcileSync() }
|
||||
.onChange(of: app.workspaces.map(\.id)) { _, workspaceIDs in
|
||||
scene.reconcileWorkspaceIDs(workspaceIDs)
|
||||
|
||||
@@ -91,6 +91,24 @@ final class MobileSceneModel {
|
||||
selectWorkspace(id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func perform(_ quickAction: MobileHomeScreenQuickAction) -> Bool {
|
||||
switch quickAction {
|
||||
case .connect:
|
||||
openConnectionPicker()
|
||||
return true
|
||||
case .history(let hostID, let kind):
|
||||
guard app.library.host(hostID) != nil else {
|
||||
selectedDestination = .workspaces
|
||||
workspaceID = nil
|
||||
isWorkspaceUnavailable = true
|
||||
return false
|
||||
}
|
||||
open(hostID: hostID, kind: kind)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Reuses this scene's most recently selected SSH workspace for the Host.
|
||||
/// Explicit connection actions continue to call `open(hostID:kind:)` so
|
||||
/// users can still create independent SSH and SFTP workspaces.
|
||||
|
||||
@@ -4,7 +4,7 @@ import TermindApplication
|
||||
|
||||
@main
|
||||
struct TermindIOSApp: App {
|
||||
@UIApplicationDelegateAdaptor(MobileCloudNotifications.self) private var notifications
|
||||
@UIApplicationDelegateAdaptor(MobileAppDelegate.self) private var appDelegate
|
||||
@AppStorage("mobile.appearance") private var appearance = "system"
|
||||
private let launchLanguage = AppLanguage.stored()
|
||||
private let startup: Result<MobileAppModel, any Error>
|
||||
@@ -20,7 +20,7 @@ struct TermindIOSApp: App {
|
||||
WindowGroup {
|
||||
switch startup {
|
||||
case .success(let app):
|
||||
MobileRootView(app: app)
|
||||
MobileQuickActionSceneRoot(app: app)
|
||||
.modelContainer(app.container)
|
||||
.environment(\.mobileInterfaceIdiom, MobileInterfaceIdiom.current)
|
||||
.environment(\.locale, launchLanguage.locale)
|
||||
@@ -34,3 +34,16 @@ struct TermindIOSApp: App {
|
||||
.commands { MobileCommands() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct MobileQuickActionSceneRoot: View {
|
||||
let app: MobileAppModel
|
||||
@EnvironmentObject private var sceneDelegate: MobileSceneDelegate
|
||||
|
||||
var body: some View {
|
||||
MobileRootView(
|
||||
app: app,
|
||||
pendingQuickAction: sceneDelegate.pendingQuickAction,
|
||||
consumePendingQuickAction: sceneDelegate.consumePendingQuickAction
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
struct MobileConnectionHistoryRow: Identifiable {
|
||||
let entry: ConnectionHistoryEntry
|
||||
let host: Host
|
||||
let loginAddress: String
|
||||
|
||||
var id: ConnectionHistoryEntry.ID { entry.id }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum MobileConnectionHistory {
|
||||
private struct Key: Hashable {
|
||||
let hostID: Host.ID
|
||||
let kind: ConnectionHistoryEntry.Kind
|
||||
}
|
||||
|
||||
static func rows(
|
||||
entries: [ConnectionHistoryEntry],
|
||||
library: HostLibraryModel,
|
||||
limit: Int
|
||||
) -> [MobileConnectionHistoryRow] {
|
||||
guard limit > 0 else { return [] }
|
||||
|
||||
var seen: Set<Key> = []
|
||||
var rows: [MobileConnectionHistoryRow] = []
|
||||
for entry in entries {
|
||||
guard let host = library.host(entry.hostID) else { continue }
|
||||
guard seen.insert(Key(hostID: entry.hostID, kind: entry.kind)).inserted else { continue }
|
||||
rows.append(
|
||||
MobileConnectionHistoryRow(
|
||||
entry: entry,
|
||||
host: host,
|
||||
loginAddress: library.loginAddress(for: host)
|
||||
))
|
||||
if rows.count == limit { break }
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,6 @@ import SwiftUI
|
||||
import TermindDomain
|
||||
import UIKit
|
||||
|
||||
struct MobileConnectionHistoryRow: Identifiable {
|
||||
let entry: ConnectionHistoryEntry
|
||||
let host: Host
|
||||
|
||||
var id: ConnectionHistoryEntry.ID { entry.id }
|
||||
}
|
||||
|
||||
/// The registered Workspaces destination.
|
||||
struct MobileWorkspaceListView: View {
|
||||
let app: MobileAppModel
|
||||
@@ -143,10 +136,10 @@ struct MobileWorkspaceListView: View {
|
||||
|
||||
var historyRows: [MobileConnectionHistoryRow] {
|
||||
_ = app.connectionHistory.revision
|
||||
return Array(
|
||||
app.connectionHistory.entries().lazy.compactMap { entry in
|
||||
app.library.host(entry.hostID).map { MobileConnectionHistoryRow(entry: entry, host: $0) }
|
||||
}.prefix(Self.historyLimit)
|
||||
return MobileConnectionHistory.rows(
|
||||
entries: app.connectionHistory.entries(),
|
||||
library: app.library,
|
||||
limit: Self.historyLimit
|
||||
)
|
||||
}
|
||||
|
||||
@@ -172,7 +165,7 @@ struct MobileWorkspaceListView: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Text(app.library.loginAddress(for: row.host))
|
||||
Text(row.loginAddress)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -21,6 +21,17 @@
|
||||
<string>$(ICLOUD_CONTAINER_ENVIRONMENT)</string>
|
||||
<key>TermindKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UIApplicationShortcutItemIconSymbolName</key>
|
||||
<string>network</string>
|
||||
<key>UIApplicationShortcutItemTitle</key>
|
||||
<string>QUICK_ACTION_CONNECT_TITLE</string>
|
||||
<key>UIApplicationShortcutItemType</key>
|
||||
<string>com.akinokaede.termind.quickAction.connect</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class MobileAppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil {
|
||||
application.registerForRemoteNotifications()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions
|
||||
) -> UISceneConfiguration {
|
||||
let configuration = UISceneConfiguration(
|
||||
name: nil,
|
||||
sessionRole: connectingSceneSession.role
|
||||
)
|
||||
if connectingSceneSession.role == .windowApplication {
|
||||
configuration.delegateClass = MobileSceneDelegate.self
|
||||
}
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
/// CKSyncEngine owns subscription delivery; registering with APNs enables silent cloud notifications.
|
||||
@MainActor
|
||||
final class MobileCloudNotifications: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil {
|
||||
application.registerForRemoteNotifications()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
import UIKit
|
||||
|
||||
nonisolated enum MobileHomeScreenQuickAction: Equatable, Sendable {
|
||||
case connect
|
||||
case history(hostID: Host.ID, kind: ConnectionHistoryEntry.Kind)
|
||||
}
|
||||
|
||||
struct MobileHomeScreenQuickActionDescriptor: Equatable {
|
||||
let action: MobileHomeScreenQuickAction
|
||||
let title: String
|
||||
let symbol: String
|
||||
|
||||
var shortcutItem: UIApplicationShortcutItem {
|
||||
let userInfo: [String: any NSSecureCoding]?
|
||||
switch action {
|
||||
case .connect:
|
||||
userInfo = nil
|
||||
case .history(let hostID, let kind):
|
||||
userInfo = [
|
||||
MobileHomeScreenQuickActions.hostIDKey: hostID.uuidString as NSString,
|
||||
MobileHomeScreenQuickActions.kindKey: kind.rawValue as NSString,
|
||||
]
|
||||
}
|
||||
return UIApplicationShortcutItem(
|
||||
type: MobileHomeScreenQuickActions.type(for: action),
|
||||
localizedTitle: title,
|
||||
localizedSubtitle: nil,
|
||||
icon: UIApplicationShortcutIcon(systemImageName: symbol),
|
||||
userInfo: userInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum MobileHomeScreenQuickActions {
|
||||
static let historyLimit = 3
|
||||
static let connectType = "com.akinokaede.termind.quickAction.connect"
|
||||
static let historyType = "com.akinokaede.termind.quickAction.history"
|
||||
static let hostIDKey = "hostID"
|
||||
static let kindKey = "kind"
|
||||
|
||||
static func type(for action: MobileHomeScreenQuickAction) -> String {
|
||||
switch action {
|
||||
case .connect: connectType
|
||||
case .history: historyType
|
||||
}
|
||||
}
|
||||
|
||||
static func action(from shortcutItem: UIApplicationShortcutItem) -> MobileHomeScreenQuickAction? {
|
||||
switch shortcutItem.type {
|
||||
case connectType:
|
||||
return .connect
|
||||
case historyType:
|
||||
guard
|
||||
let hostIDValue = shortcutItem.userInfo?[hostIDKey] as? NSString,
|
||||
let hostID = UUID(uuidString: hostIDValue as String),
|
||||
let kindValue = shortcutItem.userInfo?[kindKey] as? NSString,
|
||||
let kind = ConnectionHistoryEntry.Kind(rawValue: kindValue as String)
|
||||
else { return nil }
|
||||
return .history(hostID: hostID, kind: kind)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func descriptors(rows: [MobileConnectionHistoryRow]) -> [MobileHomeScreenQuickActionDescriptor] {
|
||||
rows.prefix(historyLimit).map { row in
|
||||
return MobileHomeScreenQuickActionDescriptor(
|
||||
action: .history(hostID: row.host.id, kind: row.entry.kind),
|
||||
title: row.host.name,
|
||||
symbol: row.entry.kind.symbol
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func refresh(app: MobileAppModel) {
|
||||
let rows = MobileConnectionHistory.rows(
|
||||
entries: app.connectionHistory.entries(),
|
||||
library: app.library,
|
||||
limit: historyLimit
|
||||
)
|
||||
UIApplication.shared.shortcutItems = descriptors(rows: rows).map(\.shortcutItem)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MobileSceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
|
||||
@Published private(set) var pendingQuickAction: MobileHomeScreenQuickAction?
|
||||
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
guard let shortcutItem = connectionOptions.shortcutItem else { return }
|
||||
_ = receive(shortcutItem)
|
||||
}
|
||||
|
||||
func windowScene(
|
||||
_ windowScene: UIWindowScene,
|
||||
performActionFor shortcutItem: UIApplicationShortcutItem
|
||||
) async -> Bool {
|
||||
receive(shortcutItem)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func receive(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
|
||||
guard let action = MobileHomeScreenQuickActions.action(from: shortcutItem) else { return false }
|
||||
pendingQuickAction = action
|
||||
return true
|
||||
}
|
||||
|
||||
func consumePendingQuickAction() -> MobileHomeScreenQuickAction? {
|
||||
defer { pendingQuickAction = nil }
|
||||
return pendingQuickAction
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ struct MobileConnectionPickerTests {
|
||||
#expect(selections.last?.1 == .sftp)
|
||||
}
|
||||
|
||||
@Test func emptyWorkspaceHistoryKeepsTheLatestFiveValidConnectionEventsWithoutDeduplication() throws {
|
||||
@Test func emptyWorkspaceHistoryKeepsTheLatestFiveUniqueHostAndProtocolPairs() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let scene = MobileSceneModel(app: app)
|
||||
let repeatedHost = Host(name: "Repeated", host: "repeated.example.invalid")
|
||||
@@ -52,11 +52,15 @@ struct MobileConnectionPickerTests {
|
||||
app.library.upsert(repeatedHost)
|
||||
for host in otherHosts { app.library.upsert(host) }
|
||||
|
||||
let newestSSH = try #require(
|
||||
app.connectionHistory.record(kind: .ssh, for: repeatedHost.id, at: Date(timeIntervalSince1970: 70)))
|
||||
_ = app.connectionHistory.record(
|
||||
kind: .ssh, for: repeatedHost.id, at: Date(timeIntervalSince1970: 60))
|
||||
let newestSFTP = try #require(
|
||||
app.connectionHistory.record(kind: .sftp, for: repeatedHost.id, at: Date(timeIntervalSince1970: 55)))
|
||||
let expectedEntries = try [
|
||||
#require(
|
||||
app.connectionHistory.record(kind: .ssh, for: repeatedHost.id, at: Date(timeIntervalSince1970: 70))),
|
||||
#require(
|
||||
app.connectionHistory.record(kind: .ssh, for: repeatedHost.id, at: Date(timeIntervalSince1970: 60))),
|
||||
newestSSH,
|
||||
newestSFTP,
|
||||
#require(
|
||||
app.connectionHistory.record(kind: .sftp, for: otherHosts[0].id, at: Date(timeIntervalSince1970: 50))),
|
||||
#require(
|
||||
@@ -74,7 +78,8 @@ struct MobileConnectionPickerTests {
|
||||
#expect(rows.count == MobileWorkspaceListView.historyLimit)
|
||||
#expect(rows.map(\.entry.id) == expectedEntries.map(\.id))
|
||||
#expect(rows[0].entry.hostID == rows[1].entry.hostID)
|
||||
#expect(rows[0].entry.kind == rows[1].entry.kind)
|
||||
#expect(rows[0].entry.kind != rows[1].entry.kind)
|
||||
#expect(Set(rows.map { "\($0.entry.hostID.uuidString):\($0.entry.kind.rawValue)" }).count == rows.count)
|
||||
}
|
||||
|
||||
@Test func sharedHostPickerUsesConfiguredTitleWithoutHostsHeaderOrProtocolControl() async throws {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileHomeScreenQuickActionTests {
|
||||
@Test func descriptorsUseTheNewestThreeUniqueValidHistoryRows() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let repeated = Host(name: "Production", host: "production.example.invalid")
|
||||
let others = (0..<3).map { Host(name: "Host \($0)", host: "host-\($0).example.invalid") }
|
||||
app.library.upsert(repeated)
|
||||
for host in others { app.library.upsert(host) }
|
||||
|
||||
_ = app.connectionHistory.record(
|
||||
kind: .ssh, for: repeated.id, at: Date(timeIntervalSince1970: 60))
|
||||
_ = app.connectionHistory.record(
|
||||
kind: .ssh, for: repeated.id, at: Date(timeIntervalSince1970: 50))
|
||||
_ = app.connectionHistory.record(
|
||||
kind: .sftp, for: repeated.id, at: Date(timeIntervalSince1970: 40))
|
||||
_ = app.connectionHistory.record(
|
||||
kind: .ssh, for: others[0].id, at: Date(timeIntervalSince1970: 30))
|
||||
_ = app.connectionHistory.record(
|
||||
kind: .sftp, for: others[1].id, at: Date(timeIntervalSince1970: 20))
|
||||
_ = app.connectionHistory.record(
|
||||
kind: .ssh, for: others[2].id, at: Date(timeIntervalSince1970: 10))
|
||||
_ = app.connectionHistory.record(
|
||||
kind: .ssh, for: UUID(), at: Date(timeIntervalSince1970: 70))
|
||||
|
||||
let rows = MobileConnectionHistory.rows(
|
||||
entries: app.connectionHistory.entries(),
|
||||
library: app.library,
|
||||
limit: MobileHomeScreenQuickActions.historyLimit
|
||||
)
|
||||
let descriptors = MobileHomeScreenQuickActions.descriptors(rows: rows)
|
||||
|
||||
#expect(descriptors.count == 3)
|
||||
#expect(
|
||||
descriptors.map(\.action)
|
||||
== [
|
||||
.history(hostID: repeated.id, kind: .ssh),
|
||||
.history(hostID: repeated.id, kind: .sftp),
|
||||
.history(hostID: others[0].id, kind: .ssh),
|
||||
])
|
||||
#expect(descriptors.map(\.title) == ["Production", "Production", "Host 0"])
|
||||
#expect(descriptors.map(\.symbol) == ["terminal", "folder", "terminal"])
|
||||
}
|
||||
|
||||
@Test func historyShortcutPayloadRoundTripsAndMalformedItemsAreRejected() throws {
|
||||
let hostID = UUID()
|
||||
let row = MobileConnectionHistoryRow(
|
||||
entry: ConnectionHistoryEntry(hostID: hostID, kind: .sftp),
|
||||
host: Host(id: hostID, name: "Files", host: "files.example.invalid"),
|
||||
loginAddress: "deploy@files.example.invalid"
|
||||
)
|
||||
let descriptor = try #require(
|
||||
MobileHomeScreenQuickActions.descriptors(rows: [row]).first)
|
||||
let shortcutItem = descriptor.shortcutItem
|
||||
|
||||
#expect(shortcutItem.type == MobileHomeScreenQuickActions.historyType)
|
||||
#expect(shortcutItem.localizedTitle == "Files")
|
||||
#expect(shortcutItem.localizedSubtitle == nil)
|
||||
#expect(shortcutItem.icon != nil)
|
||||
#expect(
|
||||
MobileHomeScreenQuickActions.action(from: shortcutItem)
|
||||
== .history(hostID: hostID, kind: .sftp))
|
||||
|
||||
let malformed = UIApplicationShortcutItem(
|
||||
type: MobileHomeScreenQuickActions.historyType,
|
||||
localizedTitle: "Malformed"
|
||||
)
|
||||
let unknown = UIApplicationShortcutItem(type: "unknown", localizedTitle: "Unknown")
|
||||
#expect(MobileHomeScreenQuickActions.action(from: malformed) == nil)
|
||||
#expect(MobileHomeScreenQuickActions.action(from: unknown) == nil)
|
||||
}
|
||||
|
||||
@Test func sceneDelegateAcceptsKnownActionsAndConsumesEachOnce() {
|
||||
let delegate = MobileSceneDelegate()
|
||||
let connect = UIApplicationShortcutItem(
|
||||
type: MobileHomeScreenQuickActions.connectType,
|
||||
localizedTitle: "Connect"
|
||||
)
|
||||
|
||||
#expect(delegate.receive(connect))
|
||||
#expect(delegate.pendingQuickAction == .connect)
|
||||
#expect(delegate.consumePendingQuickAction() == .connect)
|
||||
#expect(delegate.consumePendingQuickAction() == nil)
|
||||
|
||||
let unknown = UIApplicationShortcutItem(type: "unknown", localizedTitle: "Unknown")
|
||||
#expect(!delegate.receive(unknown))
|
||||
#expect(delegate.pendingQuickAction == nil)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,35 @@ struct MobileShortcutTests {
|
||||
#expect(scene.assistant.conversation.id != previousConversationID)
|
||||
}
|
||||
|
||||
@Test func homeScreenConnectActionUsesTheWorkspaceConnectionPicker() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let scene = MobileSceneModel(app: app)
|
||||
|
||||
#expect(scene.perform(.connect))
|
||||
#expect(scene.selectedDestination == .workspaces)
|
||||
#expect(scene.workspaceID == nil)
|
||||
#expect(scene.isConnectionPickerPresented)
|
||||
}
|
||||
|
||||
@Test func homeScreenHistoryActionOpensItsProtocolAndReportsAMissingHost() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let host = Host(name: "Files", host: "files.example.invalid")
|
||||
app.library.upsert(host)
|
||||
let scene = MobileSceneModel(app: app)
|
||||
defer { app.closeAll() }
|
||||
|
||||
#expect(scene.perform(.history(hostID: host.id, kind: .sftp)))
|
||||
scene.activatePendingWorkspace()
|
||||
#expect(scene.activeWorkspace?.descriptor.hostID == host.id)
|
||||
#expect(scene.activeWorkspace?.descriptor.kind == .sftp)
|
||||
#expect(app.connectionHistory.entries(for: host.id).map(\.kind) == [.sftp])
|
||||
|
||||
let historyCount = app.connectionHistory.entries().count
|
||||
#expect(!scene.perform(.history(hostID: UUID(), kind: .ssh)))
|
||||
#expect(scene.isWorkspaceUnavailable)
|
||||
#expect(app.connectionHistory.entries().count == historyCount)
|
||||
}
|
||||
|
||||
@Test func hostCardActivationReusesTheScenesMostRecentSSHWorkspace() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let host = Host(name: "Test", host: "example.invalid")
|
||||
|
||||
@@ -1729,7 +1729,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = F6SUNDBBN9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
ICLOUD_CONTAINER_ENVIRONMENT = Development;
|
||||
@@ -1746,7 +1746,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.7.4;
|
||||
MARKETING_VERSION = 1.7.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.akinokaede.termind;
|
||||
PRODUCT_MODULE_NAME = TermindIOS;
|
||||
PRODUCT_NAME = Termind;
|
||||
@@ -1986,7 +1986,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = F6SUNDBBN9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
ICLOUD_CONTAINER_ENVIRONMENT = Production;
|
||||
@@ -2003,7 +2003,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.7.4;
|
||||
MARKETING_VERSION = 1.7.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.akinokaede.termind;
|
||||
PRODUCT_MODULE_NAME = TermindIOS;
|
||||
PRODUCT_NAME = Termind;
|
||||
|
||||
Reference in new issue
Block a user