Skip to content

Commit e1bbba0

Browse files
authored
Merge pull request #595 from TableProApp/feat/ios-spotlight-shortcuts
feat: Spotlight search and Siri Shortcuts for iOS
2 parents c873885 + c5503fd commit e1bbba0

File tree

7 files changed

+154
-0
lines changed

7 files changed

+154
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3434
- iOS: persistent query history with timestamps
3535
- iOS: export to clipboard (JSON, CSV, SQL INSERT)
3636
- iOS: sort columns with native Picker menu
37+
- iOS: Spotlight search and Siri Shortcuts for connections
3738

3839
## [0.27.4] - 2026-04-05
3940

TableProMobile/TableProMobile/AppState.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// TableProMobile
44
//
55

6+
import CoreSpotlight
67
import Foundation
78
import Observation
89
import TableProDatabase
@@ -40,12 +41,14 @@ final class AppState {
4041
tags = tagStorage.load()
4142
secureStore.cleanOrphanedCredentials(validConnectionIds: Set(connections.map(\.id)))
4243
updateWidgetData()
44+
updateSpotlightIndex()
4345

4446
syncCoordinator.onConnectionsChanged = { [weak self] merged in
4547
guard let self else { return }
4648
self.connections = merged
4749
self.storage.save(merged)
4850
self.updateWidgetData()
51+
self.updateSpotlightIndex()
4952
}
5053

5154
syncCoordinator.onGroupsChanged = { [weak self] merged in
@@ -72,6 +75,7 @@ final class AppState {
7275
connections.append(connection)
7376
storage.save(connections)
7477
updateWidgetData()
78+
updateSpotlightIndex()
7579
syncCoordinator.markDirty(connection.id)
7680
syncCoordinator.scheduleSyncAfterChange()
7781
}
@@ -81,6 +85,7 @@ final class AppState {
8185
connections[index] = connection
8286
storage.save(connections)
8387
updateWidgetData()
88+
updateSpotlightIndex()
8489
syncCoordinator.markDirty(connection.id)
8590
syncCoordinator.scheduleSyncAfterChange()
8691
}
@@ -98,6 +103,7 @@ final class AppState {
98103
try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)")
99104
storage.save(connections)
100105
updateWidgetData()
106+
updateSpotlightIndex()
101107
syncCoordinator.markDeleted(connection.id)
102108
syncCoordinator.scheduleSyncAfterChange()
103109
}
@@ -179,6 +185,26 @@ final class AppState {
179185
syncCoordinator.scheduleSyncAfterChange()
180186
}
181187

188+
// MARK: - Spotlight
189+
190+
private func updateSpotlightIndex() {
191+
let items = connections.map { conn in
192+
let attributes = CSSearchableItemAttributeSet(contentType: .item)
193+
attributes.title = conn.name.isEmpty ? conn.host : conn.name
194+
attributes.contentDescription = "\(conn.type.rawValue) · \(conn.host):\(conn.port)"
195+
return CSSearchableItem(
196+
uniqueIdentifier: conn.id.uuidString,
197+
domainIdentifier: "com.TablePro.connections",
198+
attributeSet: attributes
199+
)
200+
}
201+
if items.isEmpty {
202+
CSSearchableIndex.default().deleteAllSearchableItems()
203+
} else {
204+
CSSearchableIndex.default().indexSearchableItems(items)
205+
}
206+
}
207+
182208
// MARK: - Widget
183209

184210
private func updateWidgetData() {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// ConnectionEntity.swift
3+
// TableProMobile
4+
//
5+
6+
import AppIntents
7+
import Foundation
8+
9+
struct ConnectionEntity: AppEntity {
10+
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Connection")
11+
static var defaultQuery = ConnectionEntityQuery()
12+
13+
var id: UUID
14+
var name: String
15+
var host: String
16+
var databaseType: String
17+
18+
var displayRepresentation: DisplayRepresentation {
19+
DisplayRepresentation(
20+
title: "\(name)",
21+
subtitle: "\(databaseType) · \(host)"
22+
)
23+
}
24+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//
2+
// ConnectionEntityQuery.swift
3+
// TableProMobile
4+
//
5+
6+
import AppIntents
7+
import Foundation
8+
9+
struct ConnectionEntityQuery: EntityQuery {
10+
func entities(for identifiers: [UUID]) async throws -> [ConnectionEntity] {
11+
let all = loadConnections()
12+
return all.filter { identifiers.contains($0.id) }
13+
}
14+
15+
func suggestedEntities() async throws -> [ConnectionEntity] {
16+
loadConnections()
17+
}
18+
19+
private func loadConnections() -> [ConnectionEntity] {
20+
guard let dir = FileManager.default.urls(
21+
for: .applicationSupportDirectory,
22+
in: .userDomainMask
23+
).first else {
24+
return []
25+
}
26+
let fileURL = dir
27+
.appendingPathComponent("TableProMobile", isDirectory: true)
28+
.appendingPathComponent("connections.json")
29+
guard let data = try? Data(contentsOf: fileURL) else { return [] }
30+
31+
struct StoredConnection: Decodable {
32+
let id: UUID
33+
let name: String
34+
let host: String
35+
let type: String
36+
}
37+
38+
guard let connections = try? JSONDecoder().decode([StoredConnection].self, from: data) else {
39+
return []
40+
}
41+
42+
return connections.map { conn in
43+
ConnectionEntity(
44+
id: conn.id,
45+
name: conn.name.isEmpty ? conn.host : conn.name,
46+
host: conn.host,
47+
databaseType: conn.type
48+
)
49+
}
50+
}
51+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//
2+
// OpenConnectionIntent.swift
3+
// TableProMobile
4+
//
5+
6+
import AppIntents
7+
import Foundation
8+
import UIKit
9+
10+
struct OpenConnectionIntent: AppIntent {
11+
static var title: LocalizedStringResource = "Open Connection"
12+
static var description = IntentDescription("Opens a database connection in TablePro")
13+
static var openAppWhenRun = true
14+
15+
@Parameter(title: "Connection")
16+
var connection: ConnectionEntity
17+
18+
@MainActor
19+
func perform() async throws -> some IntentResult {
20+
guard let url = URL(string: "tablepro://connect/\(connection.id.uuidString)") else {
21+
return .result()
22+
}
23+
await UIApplication.shared.open(url)
24+
return .result()
25+
}
26+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// TableProShortcuts.swift
3+
// TableProMobile
4+
//
5+
6+
import AppIntents
7+
8+
struct TableProShortcuts: AppShortcutsProvider {
9+
static var appShortcuts: [AppShortcut] {
10+
AppShortcut(
11+
intent: OpenConnectionIntent(),
12+
phrases: [
13+
"Open \(\.$connection) in \(.applicationName)",
14+
"Connect to \(\.$connection) in \(.applicationName)"
15+
],
16+
shortTitle: "Open Connection",
17+
systemImageName: "server.rack"
18+
)
19+
}
20+
}

TableProMobile/TableProMobile/TableProMobileApp.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// TableProMobile
44
//
55

6+
import CoreSpotlight
67
import SwiftUI
78
import TableProDatabase
89
import TableProModels
@@ -31,6 +32,11 @@ struct TableProMobileApp: App {
3132
let uuid = UUID(uuidString: uuidString) else { return }
3233
appState.pendingConnectionId = uuid
3334
}
35+
.onContinueUserActivity(CSSearchableItemActionType) { activity in
36+
guard let identifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String,
37+
let uuid = UUID(uuidString: identifier) else { return }
38+
appState.pendingConnectionId = uuid
39+
}
3440
}
3541
.onChange(of: scenePhase) { _, phase in
3642
switch phase {

0 commit comments

Comments
 (0)