Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions TableProMobile/TableProMobile/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// TableProMobile
//

import CoreSpotlight
import Foundation
import Observation
import TableProDatabase
Expand Down Expand Up @@ -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
Expand All @@ -72,6 +75,7 @@ final class AppState {
connections.append(connection)
storage.save(connections)
updateWidgetData()
updateSpotlightIndex()
syncCoordinator.markDirty(connection.id)
syncCoordinator.scheduleSyncAfterChange()
}
Expand All @@ -81,6 +85,7 @@ final class AppState {
connections[index] = connection
storage.save(connections)
updateWidgetData()
updateSpotlightIndex()
syncCoordinator.markDirty(connection.id)
syncCoordinator.scheduleSyncAfterChange()
}
Expand All @@ -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()
}
Expand Down Expand Up @@ -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() {
Expand Down
24 changes: 24 additions & 0 deletions TableProMobile/TableProMobile/Intents/ConnectionEntity.swift
Original file line number Diff line number Diff line change
@@ -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)"
)
}
}
51 changes: 51 additions & 0 deletions TableProMobile/TableProMobile/Intents/ConnectionEntityQuery.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
26 changes: 26 additions & 0 deletions TableProMobile/TableProMobile/Intents/OpenConnectionIntent.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
20 changes: 20 additions & 0 deletions TableProMobile/TableProMobile/Intents/TableProShortcuts.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
6 changes: 6 additions & 0 deletions TableProMobile/TableProMobile/TableProMobileApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// TableProMobile
//

import CoreSpotlight
import SwiftUI
import TableProDatabase
import TableProModels
Expand Down Expand Up @@ -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 {
Expand Down
Loading