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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions TableProMobile/TableProMobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@
membershipExceptions = (
Shared/SharedConnectionStore.swift,
Shared/WidgetConnectionItem.swift,
Shared/QueryActivityAttributes.swift,
);
target = 5AB9F3D82F7C1C12001F3337 /* TableProMobile */;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
7 changes: 7 additions & 0 deletions TableProMobile/TableProMobile/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@
<string>_postgresql._tcp</string>
<string>_redis._tcp</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSUserActivityTypes</key>
<array>
<string>com.TablePro.viewConnection</string>
<string>com.TablePro.viewTable</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
Expand Down
93 changes: 93 additions & 0 deletions TableProMobile/TableProMobile/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,16 @@
}
}
},
"Create Group" : {
"localizations" : {
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tạo nhóm"
}
}
}
},
"Create New Database" : {
"localizations" : {
"vi" : {
Expand All @@ -1278,6 +1288,16 @@
}
}
},
"Create Tag" : {
"localizations" : {
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tạo thẻ"
}
}
}
},
"Database" : {
"localizations" : {
"vi" : {
Expand Down Expand Up @@ -1462,6 +1482,16 @@
}
}
},
"Delete group" : {
"localizations" : {
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Xóa nhóm"
}
}
}
},
"Delete Group" : {
"localizations" : {
"vi" : {
Expand All @@ -1478,6 +1508,16 @@
}
}
},
"Delete row" : {
"localizations" : {
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Xóa hàng"
}
}
}
},
"Delete Row" : {
"localizations" : {
"vi" : {
Expand All @@ -1494,6 +1534,16 @@
}
}
},
"Delete tag" : {
"localizations" : {
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Xóa thẻ"
}
}
}
},
"Descending" : {
"localizations" : {
"vi" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions TableProMobile/TableProMobile/Platform/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 75 additions & 1 deletion TableProMobile/TableProMobile/Views/QueryEditorView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ActivityKit
import os
import SwiftUI
import TableProDatabase
Expand Down Expand Up @@ -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

Expand All @@ -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<QueryActivityAttributes>? {
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<QueryActivityAttributes>?,
startedAt: Date
) -> Task<Void, Never> {
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<QueryActivityAttributes>?, 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)
}
}
}
9 changes: 8 additions & 1 deletion TableProMobile/TableProMobile/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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") {
Expand Down
Loading
Loading