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 +}