Add Quick Action

This commit is contained in:
Kaede Akino committed 2026-09-13 23:08:42 +08:00
1 parent 9d73280833
commit 3dbcbcccdf
15 files changed
+436 -42

No files matched your search

+24
View File
@@ -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" : {
+5 -1
View File
@@ -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
+27 -2
View File
@@ -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)
+18
View File
@@ -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.
+15 -2
View File
@@ -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)
+11
View File
@@ -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>
+29
View File
@@ -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
}
}
+11 -6
View File
@@ -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)
}
}
+29
View File
@@ -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")
+4 -4
View File
@@ -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;