diff --git a/CHANGELOG.md b/CHANGELOG.md
index 287e056d3..39cf078f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,13 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- iOS: Live Activity for running queries shows query preview, elapsed time, and row count on the lock screen and Dynamic Island
+- iOS: multi-window support on iPad - drag a tab off to open a second window, each window remembers its own selected connection across launches
- iOS: VoiceOver "Delete row" / "Delete group" / "Delete tag" custom actions on rows whose only deletion path was a swipe gesture
- iOS: empty Groups and Tags screens show a Create button so the action is reachable without opening the toolbar
- iOS: "No Results" empty state in Query Editor explains the query returned no rows
- iOS: iCloud sync runs every 30 minutes in the background via `BGAppRefreshTask` while the app is closed (gated by the iCloud Sync setting); iOS schedules the actual cadence based on usage and battery
- iOS: Cmd+F focuses the search field in Tables and Data Browser (iPad keyboard canonical)
- iOS: search text in Tables and Data Browser persists across process kill via `@SceneStorage` (per-window on iPad)
-- iOS Settings: iCloud Sync toggle (off keeps connections, groups, and tags on this device only and disables the sync toolbar button), Rows per Page picker (50/100/200/500, applied to new data browser sessions), Default Safe Mode picker (applied when adding a new connection)
+- iOS Settings: iCloud Sync toggle (off keeps connections, groups, and tags on this device only and disables the sync toolbar button), Rows per Page picker (50/100/200/500, applied to new data browser sessions), Default Safe Mode picker (applied when adding a new connection), "Hide query in Live Activities" toggle that swaps the SQL preview for a generic "Running query" label on the lock screen and Dynamic Island
- iOS: alert when the active connection is deleted mid-session (for example via iCloud sync from another device), so a stale screen no longer fails silently on the next action
- iOS: Face ID, Touch ID, or Optic ID lock with cold-launch protection and idle timeout (1, 5, 15, or 60 minutes), opt-in from Settings
- iOS: Connection Info tab replaces the per-connection Settings tab, showing host, SSL, SSH tunnel, active database, and live connection status
diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj
index b43df3502..ad26d856b 100644
--- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj
+++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj
@@ -541,6 +541,7 @@
membershipExceptions = (
Shared/SharedConnectionStore.swift,
Shared/WidgetConnectionItem.swift,
+ Shared/QueryActivityAttributes.swift,
);
target = 5AB9F3D82F7C1C12001F3337 /* TableProMobile */;
};
diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift
index 4d6cb4472..2995d9ae5 100644
--- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift
+++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift
@@ -132,25 +132,28 @@ final class ConnectionCoordinator {
func reconnectIfNeeded() async {
guard let session, !isSwitching, !isReconnecting else { return }
+ do {
+ _ = try await session.driver.ping()
+ return
+ } catch {
+ // Ping failed; fall through to actual reconnect path below.
+ }
+
isReconnecting = true
defer { isReconnecting = false }
do {
- _ = try await session.driver.ping()
+ await appState.sshProvider.setPendingConnectionId(connection.id)
+ let newSession = try await appState.connectionManager.connect(connection)
+ self.session = newSession
} catch {
- do {
- await appState.sshProvider.setPendingConnectionId(connection.id)
- let newSession = try await appState.connectionManager.connect(connection)
- self.session = newSession
- } catch {
- let context = ErrorContext(
- operation: "reconnect",
- databaseType: connection.type,
- host: connection.host,
- sshEnabled: connection.sshEnabled
- )
- phase = .error(ErrorClassifier.classify(error, context: context))
- self.session = nil
- }
+ let context = ErrorContext(
+ operation: "reconnect",
+ databaseType: connection.type,
+ host: connection.host,
+ sshEnabled: connection.sshEnabled
+ )
+ phase = .error(ErrorClassifier.classify(error, context: context))
+ self.session = nil
}
}
diff --git a/TableProMobile/TableProMobile/Info.plist b/TableProMobile/TableProMobile/Info.plist
index 78b125521..460574592 100644
--- a/TableProMobile/TableProMobile/Info.plist
+++ b/TableProMobile/TableProMobile/Info.plist
@@ -15,11 +15,18 @@
_postgresql._tcp
_redis._tcp
+ NSSupportsLiveActivities
+
NSUserActivityTypes
com.TablePro.viewConnection
com.TablePro.viewTable
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
UIBackgroundModes
fetch
diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings
index dd1549a00..4fc4b8d04 100644
--- a/TableProMobile/TableProMobile/Localizable.xcstrings
+++ b/TableProMobile/TableProMobile/Localizable.xcstrings
@@ -1262,6 +1262,16 @@
}
}
},
+ "Create Group" : {
+ "localizations" : {
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tạo nhóm"
+ }
+ }
+ }
+ },
"Create New Database" : {
"localizations" : {
"vi" : {
@@ -1278,6 +1288,16 @@
}
}
},
+ "Create Tag" : {
+ "localizations" : {
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tạo thẻ"
+ }
+ }
+ }
+ },
"Database" : {
"localizations" : {
"vi" : {
@@ -1462,6 +1482,16 @@
}
}
},
+ "Delete group" : {
+ "localizations" : {
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Xóa nhóm"
+ }
+ }
+ }
+ },
"Delete Group" : {
"localizations" : {
"vi" : {
@@ -1478,6 +1508,16 @@
}
}
},
+ "Delete row" : {
+ "localizations" : {
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Xóa hàng"
+ }
+ }
+ }
+ },
"Delete Row" : {
"localizations" : {
"vi" : {
@@ -1494,6 +1534,16 @@
}
}
},
+ "Delete tag" : {
+ "localizations" : {
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Xóa thẻ"
+ }
+ }
+ }
+ },
"Descending" : {
"localizations" : {
"vi" : {
@@ -4210,6 +4260,16 @@
}
}
},
+ "The query returned no rows." : {
+ "localizations" : {
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Truy vấn không trả về hàng nào."
+ }
+ }
+ }
+ },
"The server is not responding. Check the host and port." : {
"localizations" : {
"vi" : {
@@ -4669,6 +4729,39 @@
}
}
}
+ },
+ "Hide query in Live Activities" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ẩn truy vấn trong Live Activity"
+ }
+ }
+ }
+ },
+ "When on, the lock screen and Dynamic Island show \"Running query\" instead of the SQL preview." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Khi bật, màn hình khóa và Dynamic Island hiển thị \"Đang chạy truy vấn\" thay cho nội dung SQL."
+ }
+ }
+ }
+ },
+ "Running query" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Đang chạy truy vấn"
+ }
+ }
+ }
}
},
"version" : "1.0"
diff --git a/TableProMobile/TableProMobile/Platform/AppPreferences.swift b/TableProMobile/TableProMobile/Platform/AppPreferences.swift
index aa23b0931..d5716afca 100644
--- a/TableProMobile/TableProMobile/Platform/AppPreferences.swift
+++ b/TableProMobile/TableProMobile/Platform/AppPreferences.swift
@@ -5,6 +5,7 @@ enum AppPreferences {
static let cloudSyncEnabledKey = "com.TablePro.settings.cloudSyncEnabled"
static let defaultPageSizeKey = "com.TablePro.settings.defaultPageSize"
static let defaultSafeModeKey = "com.TablePro.settings.defaultSafeMode"
+ static let hideQueryPreviewInActivityKey = "com.TablePro.settings.hideQueryPreviewInActivity"
static let pageSizeOptions: [Int] = [50, 100, 200, 500]
@@ -23,4 +24,8 @@ enum AppPreferences {
let level = SafeModeLevel(rawValue: raw) else { return .off }
return level
}
+
+ static var hidesQueryPreviewInActivity: Bool {
+ UserDefaults.standard.bool(forKey: hideQueryPreviewInActivityKey)
+ }
}
diff --git a/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift b/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift
index edef4ca4a..e5cf639d6 100644
--- a/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift
+++ b/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift
@@ -87,9 +87,9 @@ struct SQLHighlightTextView: UIViewRepresentable {
// MARK: - Keyboard Accessory Toolbar
func makeAccessoryToolbar() -> UIView {
- let toolbar = UIInputView(frame: CGRect(x: 0, y: 0, width: 0, height: 44), inputViewStyle: .keyboard)
+ let toolbar = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 44))
toolbar.autoresizingMask = .flexibleWidth
- toolbar.allowsSelfSizing = true
+ toolbar.backgroundColor = .secondarySystemBackground
let separator = UIView()
separator.backgroundColor = .separator
diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift
index 7549779e0..ac0b1faa4 100644
--- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift
+++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift
@@ -7,7 +7,7 @@ struct ConnectionListView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var showingAddConnection = false
@State private var editingConnection: DatabaseConnection?
- @AppStorage("lastConnectionId") private var selectedConnectionIdString: String?
+ @SceneStorage("lastConnectionId") private var selectedConnectionIdString: String?
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
@State private var showingGroupManagement = false
@State private var showingTagManagement = false
diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift
index 384ce4672..690d2d128 100644
--- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift
+++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift
@@ -1,3 +1,4 @@
+import ActivityKit
import os
import SwiftUI
import TableProDatabase
@@ -394,10 +395,15 @@ struct QueryEditorView: View {
editorFocused = false
isExecuting = true
- executionStartTime = Date()
+ let startedAt = Date()
+ executionStartTime = startedAt
+ let activity = startQueryActivity(trimmed: trimmed, startedAt: startedAt)
+ let progressUpdater = startActivityProgressUpdater(activity: activity, startedAt: startedAt)
defer {
+ progressUpdater.cancel()
isExecuting = false
executionStartTime = nil
+ endQueryActivity(activity, startedAt: startedAt)
}
appError = nil
@@ -417,4 +423,72 @@ struct QueryEditorView: View {
let item = QueryHistoryItem(query: trimmed, connectionId: connectionId)
coordinator.addHistoryItem(item)
}
+
+ // MARK: - Live Activity
+
+ private func startQueryActivity(trimmed: String, startedAt: Date) -> Activity? {
+ guard ActivityAuthorizationInfo().areActivitiesEnabled else { return nil }
+ let preview: String = AppPreferences.hidesQueryPreviewInActivity
+ ? String(localized: "Running query")
+ : String(trimmed.prefix(60))
+ let attributes = QueryActivityAttributes(
+ connectionId: coordinator.connection.id,
+ connectionName: coordinator.displayName,
+ queryPreview: preview
+ )
+ let initialState = QueryActivityAttributes.ContentState(
+ startedAt: startedAt,
+ endedAt: nil,
+ rowsStreamed: 0
+ )
+ // 5-minute stale window: if the app crashes mid-query, iOS marks the
+ // activity stale instead of showing a forever-ticking timer.
+ return try? Activity.request(
+ attributes: attributes,
+ content: .init(state: initialState, staleDate: startedAt.addingTimeInterval(5 * 60))
+ )
+ }
+
+ /// Polls the streaming row count once per second while the query runs and pushes
+ /// `activity.update(state:)` only when the count changes. The system rate-limits
+ /// activity updates anyway, and the lock screen card just needs a fresh number
+ /// when the user wakes the device mid-query - it does not need real-time ticks
+ /// for the count (the elapsed time ticks itself via `Text(timerInterval:)`).
+ private func startActivityProgressUpdater(
+ activity: Activity?,
+ startedAt: Date
+ ) -> Task {
+ Task { [weak viewModel] in
+ guard let activity else { return }
+ var lastReportedCount = 0
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(1))
+ if Task.isCancelled { return }
+ let count = viewModel?.legacyRows.count ?? 0
+ guard count != lastReportedCount else { continue }
+ lastReportedCount = count
+ let state = QueryActivityAttributes.ContentState(
+ startedAt: startedAt,
+ endedAt: nil,
+ rowsStreamed: count
+ )
+ await activity.update(.init(
+ state: state,
+ staleDate: startedAt.addingTimeInterval(5 * 60)
+ ))
+ }
+ }
+ }
+
+ private func endQueryActivity(_ activity: Activity?, startedAt: Date) {
+ guard let activity else { return }
+ let final = QueryActivityAttributes.ContentState(
+ startedAt: startedAt,
+ endedAt: Date(),
+ rowsStreamed: viewModel.legacyRows.count
+ )
+ Task {
+ await activity.end(.init(state: final, staleDate: nil), dismissalPolicy: .immediate)
+ }
+ }
}
diff --git a/TableProMobile/TableProMobile/Views/SettingsView.swift b/TableProMobile/TableProMobile/Views/SettingsView.swift
index 24506cb0b..1efd620d0 100644
--- a/TableProMobile/TableProMobile/Views/SettingsView.swift
+++ b/TableProMobile/TableProMobile/Views/SettingsView.swift
@@ -8,6 +8,7 @@ struct SettingsView: View {
@AppStorage(AppPreferences.cloudSyncEnabledKey) private var cloudSyncEnabled = true
@AppStorage(AppPreferences.defaultPageSizeKey) private var defaultPageSize = 100
@AppStorage(AppPreferences.defaultSafeModeKey) private var defaultSafeModeRaw = SafeModeLevel.off.rawValue
+ @AppStorage(AppPreferences.hideQueryPreviewInActivityKey) private var hideQueryPreviewInActivity = false
private let auth = BiometricAuthService()
@@ -17,12 +18,18 @@ struct SettingsView: View {
syncSection
defaultsSection
- Section("Privacy") {
+ Section {
Toggle(String(localized: "Share anonymous usage data"), isOn: $shareAnalytics)
Text("Help improve TablePro by sharing anonymous usage statistics (no personal data or queries).")
.font(.caption)
.foregroundStyle(.secondary)
+
+ Toggle(String(localized: "Hide query in Live Activities"), isOn: $hideQueryPreviewInActivity)
+ } header: {
+ Text("Privacy")
+ } footer: {
+ Text("When on, the lock screen and Dynamic Island show \"Running query\" instead of the SQL preview.")
}
Section("About") {
diff --git a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift
new file mode 100644
index 000000000..78d65527e
--- /dev/null
+++ b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift
@@ -0,0 +1,131 @@
+import ActivityKit
+import SwiftUI
+import WidgetKit
+
+struct QueryLiveActivityWidget: Widget {
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: QueryActivityAttributes.self) { context in
+ lockScreenView(context: context)
+ .widgetURL(deepLink(connectionId: context.attributes.connectionId))
+ } dynamicIsland: { context in
+ DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ Image(systemName: "terminal.fill")
+ .font(.title3)
+ .foregroundStyle(.tint)
+ .frame(width: 32, height: 32)
+ .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 7))
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ elapsedText(context.state)
+ .font(.title3.monospacedDigit())
+ .foregroundStyle(context.state.endedAt == nil ? .primary : .secondary)
+ }
+ DynamicIslandExpandedRegion(.center) {
+ Text(context.attributes.connectionName)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+ }
+ DynamicIslandExpandedRegion(.bottom) {
+ HStack {
+ Text(context.attributes.queryPreview)
+ .font(.system(.footnote, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Spacer()
+ if context.state.rowsStreamed > 0 {
+ Label("^[\(context.state.rowsStreamed) row](inflect: true)", systemImage: "list.bullet")
+ .font(.caption)
+ .labelStyle(.titleAndIcon)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ } compactLeading: {
+ Image(systemName: "terminal.fill")
+ .foregroundStyle(.tint)
+ } compactTrailing: {
+ compactStatus(state: context.state)
+ } minimal: {
+ compactStatus(state: context.state)
+ }
+ .widgetURL(deepLink(connectionId: context.attributes.connectionId))
+ }
+ }
+
+ // MARK: - Lock Screen
+
+ @ViewBuilder
+ private func lockScreenView(context: ActivityViewContext) -> some View {
+ HStack(spacing: 12) {
+ Image(systemName: "terminal.fill")
+ .font(.title2)
+ .foregroundStyle(.tint)
+ .frame(width: 36, height: 36)
+ .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(context.attributes.connectionName)
+ .font(.subheadline.weight(.medium))
+ Text(context.attributes.queryPreview)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 2) {
+ elapsedText(context.state)
+ .font(.body.monospacedDigit())
+ if context.state.rowsStreamed > 0 {
+ Text("^[\(context.state.rowsStreamed) row](inflect: true)")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ }
+
+ // MARK: - Compact / Minimal Status
+
+ @ViewBuilder
+ private func compactStatus(state: QueryActivityAttributes.ContentState) -> some View {
+ if state.endedAt != nil {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ } else {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .controlSize(.mini)
+ }
+ }
+
+ // MARK: - Helpers
+
+ @ViewBuilder
+ private func elapsedText(_ state: QueryActivityAttributes.ContentState) -> some View {
+ if let ended = state.endedAt {
+ Text(formatElapsed(ended.timeIntervalSince(state.startedAt)))
+ } else {
+ Text(timerInterval: state.startedAt...Date.distantFuture, countsDown: false, showsHours: false)
+ }
+ }
+
+ private func deepLink(connectionId: UUID) -> URL? {
+ URL(string: "tablepro://connect/\(connectionId.uuidString)")
+ }
+
+ private func formatElapsed(_ seconds: TimeInterval) -> String {
+ if seconds < 60 {
+ return String(format: "%.1fs", seconds)
+ }
+ let minutes = Int(seconds) / 60
+ let secs = Int(seconds) % 60
+ return String(format: "%d:%02d", minutes, secs)
+ }
+}
diff --git a/TableProMobile/TableProWidget/QuickConnectWidget.swift b/TableProMobile/TableProWidget/QuickConnectWidget.swift
index 2f9636c5c..5a665831f 100644
--- a/TableProMobile/TableProWidget/QuickConnectWidget.swift
+++ b/TableProMobile/TableProWidget/QuickConnectWidget.swift
@@ -1,7 +1,6 @@
import SwiftUI
import WidgetKit
-@main
struct QuickConnectWidget: Widget {
let kind = "com.TablePro.QuickConnect"
@@ -15,3 +14,11 @@ struct QuickConnectWidget: Widget {
.supportedFamilies([.systemSmall, .systemMedium])
}
}
+
+@main
+struct TableProWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ QuickConnectWidget()
+ QueryLiveActivityWidget()
+ }
+}
diff --git a/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift
new file mode 100644
index 000000000..da897d0c8
--- /dev/null
+++ b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift
@@ -0,0 +1,14 @@
+import ActivityKit
+import Foundation
+
+struct QueryActivityAttributes: ActivityAttributes {
+ public struct ContentState: Codable, Hashable {
+ var startedAt: Date
+ var endedAt: Date?
+ var rowsStreamed: Int
+ }
+
+ let connectionId: UUID
+ let connectionName: String
+ let queryPreview: String
+}