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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- iOS: safe mode (Off, Confirm Writes, Read-Only) per connection

## [0.27.6] - 2026-04-07

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable {
public struct DatabaseConnection: Identifiable, Hashable, Sendable {
public var id: UUID
public var name: String
public var type: DatabaseType
Expand All @@ -10,6 +10,7 @@ public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable {
public var database: String
public var colorTag: String?
public var isReadOnly: Bool
public var safeModeLevel: SafeModeLevel
public var queryTimeoutSeconds: Int?
public var additionalFields: [String: String]

Expand All @@ -33,6 +34,7 @@ public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable {
database: String = "",
colorTag: String? = nil,
isReadOnly: Bool = false,
safeModeLevel: SafeModeLevel = .off,
queryTimeoutSeconds: Int? = nil,
additionalFields: [String: String] = [:],
sshEnabled: Bool = false,
Expand All @@ -52,6 +54,7 @@ public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable {
self.database = database
self.colorTag = colorTag
self.isReadOnly = isReadOnly
self.safeModeLevel = safeModeLevel
self.queryTimeoutSeconds = queryTimeoutSeconds
self.additionalFields = additionalFields
self.sshEnabled = sshEnabled
Expand All @@ -62,4 +65,63 @@ public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable {
self.tagId = tagId
self.sortOrder = sortOrder
}

private enum CodingKeys: String, CodingKey {
case id, name, type, host, port, username, database, colorTag
case isReadOnly, safeModeLevel, queryTimeoutSeconds, additionalFields
case sshEnabled, sshConfiguration, sslEnabled, sslConfiguration
case groupId, tagId, sortOrder
}
}

extension DatabaseConnection: Codable {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
type = try container.decode(DatabaseType.self, forKey: .type)
host = try container.decode(String.self, forKey: .host)
port = try container.decode(Int.self, forKey: .port)
username = try container.decode(String.self, forKey: .username)
database = try container.decode(String.self, forKey: .database)
colorTag = try container.decodeIfPresent(String.self, forKey: .colorTag)
isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false
if let level = try container.decodeIfPresent(SafeModeLevel.self, forKey: .safeModeLevel) {
safeModeLevel = level
} else {
safeModeLevel = isReadOnly ? .readOnly : .off
}
queryTimeoutSeconds = try container.decodeIfPresent(Int.self, forKey: .queryTimeoutSeconds)
additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) ?? [:]
sshEnabled = try container.decodeIfPresent(Bool.self, forKey: .sshEnabled) ?? false
sshConfiguration = try container.decodeIfPresent(SSHConfiguration.self, forKey: .sshConfiguration)
sslEnabled = try container.decodeIfPresent(Bool.self, forKey: .sslEnabled) ?? false
sslConfiguration = try container.decodeIfPresent(SSLConfiguration.self, forKey: .sslConfiguration)
groupId = try container.decodeIfPresent(UUID.self, forKey: .groupId)
tagId = try container.decodeIfPresent(UUID.self, forKey: .tagId)
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(type, forKey: .type)
try container.encode(host, forKey: .host)
try container.encode(port, forKey: .port)
try container.encode(username, forKey: .username)
try container.encode(database, forKey: .database)
try container.encodeIfPresent(colorTag, forKey: .colorTag)
try container.encode(isReadOnly, forKey: .isReadOnly)
try container.encode(safeModeLevel, forKey: .safeModeLevel)
try container.encodeIfPresent(queryTimeoutSeconds, forKey: .queryTimeoutSeconds)
try container.encode(additionalFields, forKey: .additionalFields)
try container.encode(sshEnabled, forKey: .sshEnabled)
try container.encodeIfPresent(sshConfiguration, forKey: .sshConfiguration)
try container.encode(sslEnabled, forKey: .sslEnabled)
try container.encodeIfPresent(sslConfiguration, forKey: .sslConfiguration)
try container.encodeIfPresent(groupId, forKey: .groupId)
try container.encodeIfPresent(tagId, forKey: .tagId)
try container.encode(sortOrder, forKey: .sortOrder)
}
}
21 changes: 21 additions & 0 deletions Packages/TableProCore/Sources/TableProModels/SafeModeLevel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

public enum SafeModeLevel: String, Codable, Sendable, CaseIterable, Identifiable {
case off = "off"
case confirmWrites = "confirmWrites"
case readOnly = "readOnly"

public var id: String { rawValue }

public var blocksWrites: Bool { self == .readOnly }

public var requiresConfirmation: Bool { self == .confirmWrites }

public var displayName: String {
switch self {
case .off: return "Off"
case .confirmWrites: return "Confirm Writes"
case .readOnly: return "Read-Only"
}
}
}
8 changes: 8 additions & 0 deletions TableProMobile/TableProMobile/Views/ConnectedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ struct ConnectedView: View {
.background(.bar)
}
.toolbar {
if connection.safeModeLevel != .off {
ToolbarItem(placement: .topBarTrailing) {
Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill")
.foregroundStyle(connection.safeModeLevel == .readOnly ? .red : .orange)
.font(.caption)
}
}
if supportsDatabaseSwitching && databases.count > 1 {
ToolbarItem(placement: .topBarLeading) {
Menu {
Expand Down Expand Up @@ -175,6 +182,7 @@ struct ConnectedView: View {
session: session,
tables: tables,
databaseType: connection.type,
safeModeLevel: connection.safeModeLevel,
queryHistory: $queryHistory,
connectionId: connection.id,
historyStorage: historyStorage
Expand Down
9 changes: 9 additions & 0 deletions TableProMobile/TableProMobile/Views/ConnectionFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ struct ConnectionFormView: View {
// Organization
@State private var groupId: UUID?
@State private var tagId: UUID?
@State private var safeModeLevel: SafeModeLevel = .off

// SSH
@State private var sshEnabled = false
Expand Down Expand Up @@ -104,6 +105,7 @@ struct ConnectionFormView: View {
}
_groupId = State(initialValue: connection.groupId)
_tagId = State(initialValue: connection.tagId)
_safeModeLevel = State(initialValue: connection.safeModeLevel)
if connection.type == .sqlite {
_selectedFileURL = State(initialValue: URL(fileURLWithPath: connection.database))
}
Expand Down Expand Up @@ -157,6 +159,12 @@ struct ConnectionFormView: View {
}
}
.pickerStyle(.menu)

Picker("Safe Mode", selection: $safeModeLevel) {
ForEach(SafeModeLevel.allCases) { level in
Text(level.displayName).tag(level)
}
}
}

if type == .sqlite {
Expand Down Expand Up @@ -547,6 +555,7 @@ struct ConnectionFormView: View {
groupId: groupId,
tagId: tagId
)
conn.safeModeLevel = safeModeLevel
if sshEnabled {
conn.sshConfiguration = SSHConfiguration(
host: sshHost,
Expand Down
7 changes: 4 additions & 3 deletions TableProMobile/TableProMobile/Views/DataBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ struct DataBrowserView: View {
} description: {
Text("This table is empty.")
} actions: {
if !isView {
if !isView && !connection.safeModeLevel.blocksWrites {
Button("Insert Row") { showInsertSheet = true }
.buttonStyle(.borderedProminent)
}
Expand All @@ -194,6 +194,7 @@ struct DataBrowserView: View {
session: session,
columnDetails: columnDetails,
databaseType: connection.type,
safeModeLevel: connection.safeModeLevel,
onSaved: { Task { await loadData() } }
)
} label: {
Expand All @@ -217,7 +218,7 @@ struct DataBrowserView: View {
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if !isView && hasPrimaryKeys {
if !isView && hasPrimaryKeys && !connection.safeModeLevel.blocksWrites {
Button(role: .destructive) {
deleteTarget = primaryKeyValues(for: rows[index])
showDeleteConfirmation = true
Expand Down Expand Up @@ -298,7 +299,7 @@ struct DataBrowserView: View {
Image(systemName: "info.circle")
}
}
if !isView {
if !isView && !connection.safeModeLevel.blocksWrites {
ToolbarItem(placement: .primaryAction) {
Button { showInsertSheet = true } label: {
Image(systemName: "plus")
Expand Down
41 changes: 40 additions & 1 deletion TableProMobile/TableProMobile/Views/QueryEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct QueryEditorView: View {
var tables: [TableInfo] = []
var initialQuery: String = ""
var databaseType: DatabaseType = .sqlite
var safeModeLevel: SafeModeLevel = .off

private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorView")

Expand All @@ -27,6 +28,9 @@ struct QueryEditorView: View {
let historyStorage: QueryHistoryStorage
@State private var showHistory = false
@State private var showClearHistoryConfirmation = false
@State private var showWriteConfirmation = false
@State private var showWriteBlockedAlert = false
@State private var pendingWriteQuery = ""
var body: some View {
VStack(spacing: 0) {
editorSection
Expand All @@ -37,6 +41,18 @@ struct QueryEditorView: View {
.onAppear {
if !initialQuery.isEmpty { query = initialQuery }
}
.alert("Write Query Blocked", isPresented: $showWriteBlockedAlert) {
Button("OK", role: .cancel) {}
} message: {
Text("This connection is in read-only mode. Write queries are not allowed.")
}
.confirmationDialog("Execute Write Query?", isPresented: $showWriteConfirmation, titleVisibility: .visible) {
Button("Execute", role: .destructive) {
executeTask = Task { await executeQueryDirect(pendingWriteQuery) }
}
} message: {
Text("This query will modify data. Are you sure you want to continue?")
}
.sheet(isPresented: $showHistory) { historySheet }
}

Expand Down Expand Up @@ -329,11 +345,34 @@ struct QueryEditorView: View {

// MARK: - Execution

private func isWriteQuery(_ sql: String) -> Bool {
let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let writeKeywords = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "REPLACE"]
return writeKeywords.contains(where: { trimmed.hasPrefix($0) })
}

private func executeQuery() async {
guard let session else { return }
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }

if isWriteQuery(trimmed) {
if safeModeLevel.blocksWrites {
showWriteBlockedAlert = true
return
}
if safeModeLevel.requiresConfirmation {
pendingWriteQuery = trimmed
showWriteConfirmation = true
return
}
}

await executeQueryDirect(trimmed)
}

private func executeQueryDirect(_ trimmed: String) async {
guard let session else { return }

UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
isExecuting = true
defer { isExecuting = false }
Expand Down
4 changes: 4 additions & 0 deletions TableProMobile/TableProMobile/Views/RowDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct RowDetailView: View {
let session: ConnectionSession?
let columnDetails: [ColumnInfo]
let databaseType: DatabaseType
let safeModeLevel: SafeModeLevel
var onSaved: (() -> Void)?

@State private var currentIndex: Int
Expand All @@ -33,6 +34,7 @@ struct RowDetailView: View {
session: ConnectionSession? = nil,
columnDetails: [ColumnInfo] = [],
databaseType: DatabaseType = .sqlite,
safeModeLevel: SafeModeLevel = .off,
onSaved: (() -> Void)? = nil
) {
self.columns = columns
Expand All @@ -41,6 +43,7 @@ struct RowDetailView: View {
self.session = session
self.columnDetails = columnDetails
self.databaseType = databaseType
self.safeModeLevel = safeModeLevel
self.onSaved = onSaved
_currentIndex = State(initialValue: initialIndex)
}
Expand All @@ -57,6 +60,7 @@ struct RowDetailView: View {

private var canEdit: Bool {
table != nil && session != nil && !columnDetails.isEmpty && !isView
&& !safeModeLevel.blocksWrites
&& columnDetails.contains(where: { $0.isPrimaryKey })
}

Expand Down
Loading