From 36d365927e88e3fa9ca0f922b7fdaa125d85440d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 6 Apr 2026 13:03:53 +0700 Subject: [PATCH 1/3] feat: Spotlight search and Siri Shortcuts for iOS connections --- TableProMobile/TableProMobile/AppState.swift | 24 +++++++++ .../Intents/ConnectionEntity.swift | 24 +++++++++ .../Intents/ConnectionEntityQuery.swift | 51 +++++++++++++++++++ .../Intents/OpenConnectionIntent.swift | 26 ++++++++++ .../Intents/TableProShortcuts.swift | 20 ++++++++ .../TableProMobile/TableProMobileApp.swift | 6 +++ 6 files changed, 151 insertions(+) create mode 100644 TableProMobile/TableProMobile/Intents/ConnectionEntity.swift create mode 100644 TableProMobile/TableProMobile/Intents/ConnectionEntityQuery.swift create mode 100644 TableProMobile/TableProMobile/Intents/OpenConnectionIntent.swift create mode 100644 TableProMobile/TableProMobile/Intents/TableProShortcuts.swift diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 167be33f..9ffe0a40 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -3,6 +3,7 @@ // TableProMobile // +import CoreSpotlight import Foundation import Observation import TableProDatabase @@ -40,12 +41,14 @@ final class AppState { tags = tagStorage.load() secureStore.cleanOrphanedCredentials(validConnectionIds: Set(connections.map(\.id))) updateWidgetData() + updateSpotlightIndex() syncCoordinator.onConnectionsChanged = { [weak self] merged in guard let self else { return } self.connections = merged self.storage.save(merged) self.updateWidgetData() + self.updateSpotlightIndex() } syncCoordinator.onGroupsChanged = { [weak self] merged in @@ -72,6 +75,7 @@ final class AppState { connections.append(connection) storage.save(connections) updateWidgetData() + updateSpotlightIndex() syncCoordinator.markDirty(connection.id) syncCoordinator.scheduleSyncAfterChange() } @@ -81,6 +85,7 @@ final class AppState { connections[index] = connection storage.save(connections) updateWidgetData() + updateSpotlightIndex() syncCoordinator.markDirty(connection.id) syncCoordinator.scheduleSyncAfterChange() } @@ -98,6 +103,7 @@ final class AppState { try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)") storage.save(connections) updateWidgetData() + updateSpotlightIndex() syncCoordinator.markDeleted(connection.id) syncCoordinator.scheduleSyncAfterChange() } @@ -179,6 +185,24 @@ final class AppState { syncCoordinator.scheduleSyncAfterChange() } + // MARK: - Spotlight + + private func updateSpotlightIndex() { + let items = connections.map { conn in + let attributes = CSSearchableItemAttributeSet(contentType: .item) + attributes.title = conn.name.isEmpty ? conn.host : conn.name + attributes.contentDescription = "\(conn.type.rawValue) — \(conn.host):\(conn.port)" + return CSSearchableItem( + uniqueIdentifier: conn.id.uuidString, + domainIdentifier: "com.TablePro.connections", + attributeSet: attributes + ) + } + CSSearchableIndex.default().deleteAllSearchableItems { _ in + CSSearchableIndex.default().indexSearchableItems(items) + } + } + // MARK: - Widget private func updateWidgetData() { diff --git a/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift b/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift new file mode 100644 index 00000000..0b8216cd --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift @@ -0,0 +1,24 @@ +// +// ConnectionEntity.swift +// TableProMobile +// + +import AppIntents +import Foundation + +struct ConnectionEntity: AppEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Connection") + static var defaultQuery = ConnectionEntityQuery() + + var id: UUID + var name: String + var host: String + var databaseType: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", + subtitle: "\(databaseType) — \(host)" + ) + } +} diff --git a/TableProMobile/TableProMobile/Intents/ConnectionEntityQuery.swift b/TableProMobile/TableProMobile/Intents/ConnectionEntityQuery.swift new file mode 100644 index 00000000..f6f0b5b5 --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/ConnectionEntityQuery.swift @@ -0,0 +1,51 @@ +// +// ConnectionEntityQuery.swift +// TableProMobile +// + +import AppIntents +import Foundation + +struct ConnectionEntityQuery: EntityQuery { + func entities(for identifiers: [UUID]) async throws -> [ConnectionEntity] { + let all = loadConnections() + return all.filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [ConnectionEntity] { + loadConnections() + } + + private func loadConnections() -> [ConnectionEntity] { + guard let dir = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + return [] + } + let fileURL = dir + .appendingPathComponent("TableProMobile", isDirectory: true) + .appendingPathComponent("connections.json") + guard let data = try? Data(contentsOf: fileURL) else { return [] } + + struct StoredConnection: Decodable { + let id: UUID + let name: String + let host: String + let type: String + } + + guard let connections = try? JSONDecoder().decode([StoredConnection].self, from: data) else { + return [] + } + + return connections.map { conn in + ConnectionEntity( + id: conn.id, + name: conn.name.isEmpty ? conn.host : conn.name, + host: conn.host, + databaseType: conn.type + ) + } + } +} diff --git a/TableProMobile/TableProMobile/Intents/OpenConnectionIntent.swift b/TableProMobile/TableProMobile/Intents/OpenConnectionIntent.swift new file mode 100644 index 00000000..c47ea098 --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/OpenConnectionIntent.swift @@ -0,0 +1,26 @@ +// +// OpenConnectionIntent.swift +// TableProMobile +// + +import AppIntents +import Foundation +import UIKit + +struct OpenConnectionIntent: AppIntent { + static var title: LocalizedStringResource = "Open Connection" + static var description = IntentDescription("Opens a database connection in TablePro") + static var openAppWhenRun = true + + @Parameter(title: "Connection") + var connection: ConnectionEntity + + @MainActor + func perform() async throws -> some IntentResult { + guard let url = URL(string: "tablepro://connect/\(connection.id.uuidString)") else { + return .result() + } + await UIApplication.shared.open(url) + return .result() + } +} diff --git a/TableProMobile/TableProMobile/Intents/TableProShortcuts.swift b/TableProMobile/TableProMobile/Intents/TableProShortcuts.swift new file mode 100644 index 00000000..6bdf7b12 --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/TableProShortcuts.swift @@ -0,0 +1,20 @@ +// +// TableProShortcuts.swift +// TableProMobile +// + +import AppIntents + +struct TableProShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: OpenConnectionIntent(), + phrases: [ + "Open \(\.$connection) in \(.applicationName)", + "Connect to \(\.$connection) in \(.applicationName)" + ], + shortTitle: "Open Connection", + systemImageName: "server.rack" + ) + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index b6ba4070..55135b55 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -3,6 +3,7 @@ // TableProMobile // +import CoreSpotlight import SwiftUI import TableProDatabase import TableProModels @@ -31,6 +32,11 @@ struct TableProMobileApp: App { let uuid = UUID(uuidString: uuidString) else { return } appState.pendingConnectionId = uuid } + .onContinueUserActivity(CSSearchableItemActionType) { activity in + guard let identifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String, + let uuid = UUID(uuidString: identifier) else { return } + appState.pendingConnectionId = uuid + } } .onChange(of: scenePhase) { _, phase in switch phase { From a6aa03953b5627a56e098cf62bfc2719656f14a9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 6 Apr 2026 13:14:15 +0700 Subject: [PATCH 2/3] fix: safe Spotlight re-indexing without delete-all, add CHANGELOG --- CHANGELOG.md | 1 + TableProMobile/TableProMobile/AppState.swift | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 968bdd7e..8789fcca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS: persistent query history with timestamps - iOS: export to clipboard (JSON, CSV, SQL INSERT) - iOS: sort columns with native Picker menu +- iOS: Spotlight search and Siri Shortcuts for connections ## [0.27.4] - 2026-04-05 diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 9ffe0a40..415913a0 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -198,7 +198,9 @@ final class AppState { attributeSet: attributes ) } - CSSearchableIndex.default().deleteAllSearchableItems { _ in + if items.isEmpty { + CSSearchableIndex.default().deleteAllSearchableItems() + } else { CSSearchableIndex.default().indexSearchableItems(items) } } From c5503fd8bf863215a2c19a20c46a36def9cf6a7c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 6 Apr 2026 14:26:47 +0700 Subject: [PATCH 3/3] fix: replace em dashes with middle dots in user-facing strings --- TableProMobile/TableProMobile/AppState.swift | 2 +- TableProMobile/TableProMobile/Intents/ConnectionEntity.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 415913a0..3844fd45 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -191,7 +191,7 @@ final class AppState { let items = connections.map { conn in let attributes = CSSearchableItemAttributeSet(contentType: .item) attributes.title = conn.name.isEmpty ? conn.host : conn.name - attributes.contentDescription = "\(conn.type.rawValue) — \(conn.host):\(conn.port)" + attributes.contentDescription = "\(conn.type.rawValue) · \(conn.host):\(conn.port)" return CSSearchableItem( uniqueIdentifier: conn.id.uuidString, domainIdentifier: "com.TablePro.connections", diff --git a/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift b/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift index 0b8216cd..814f596f 100644 --- a/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift +++ b/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift @@ -18,7 +18,7 @@ struct ConnectionEntity: AppEntity { var displayRepresentation: DisplayRepresentation { DisplayRepresentation( title: "\(name)", - subtitle: "\(databaseType) — \(host)" + subtitle: "\(databaseType) · \(host)" ) } }