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 167be33f..3844fd45 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,26 @@ 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 + ) + } + if items.isEmpty { + CSSearchableIndex.default().deleteAllSearchableItems() + } else { + 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..814f596f --- /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 {