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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- In-app feedback form for bug reports and feature requests via Help > Report an Issue
- Per-connection "Local only" option to exclude individual connections from iCloud sync
- Filter operator picker shows SQL symbols alongside names for quick visual recognition
- SQL autocomplete now suggests column names before a FROM clause is written, using all cached schema columns as fallback
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Services/Infrastructure/AppNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,8 @@ extension Notification.Name {
// MARK: - Settings Window

static let openSettingsWindow = Notification.Name("com.TablePro.openSettingsWindow")

// MARK: - Feedback

static let showFeedbackWindow = Notification.Name("com.TablePro.showFeedbackWindow")
}
157 changes: 157 additions & 0 deletions TablePro/Core/Services/Infrastructure/FeedbackAPIClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//
// FeedbackAPIClient.swift
// TablePro
//

import Foundation
import os

enum FeedbackType: String, Codable, CaseIterable {
case bugReport = "bug_report"
case featureRequest = "feature_request"
case general

var displayName: String {
switch self {
case .bugReport: String(localized: "Bug Report")
case .featureRequest: String(localized: "Feature Request")
case .general: String(localized: "General Feedback")
}
}

var iconName: String {
switch self {
case .bugReport: "ladybug"
case .featureRequest: "lightbulb"
case .general: "bubble.left"
}
}
}

struct FeedbackSubmissionRequest: Encodable {
let feedbackType: String
let title: String
let description: String
let stepsToReproduce: String?
let expectedBehavior: String?
let appVersion: String
let osVersion: String
let architecture: String
let databaseType: String?
let installedPlugins: [String]
let machineId: String
let screenshots: [String]
}

struct FeedbackSubmissionResponse: Decodable {
let issueUrl: String
let issueNumber: Int
}

enum FeedbackError: LocalizedError {
case networkError(Error)
case serverError(Int, String)
case rateLimited
case submissionTooLarge
case decodingError(Error)

var errorDescription: String? {
switch self {
case .networkError:
String(localized: "Network error. Check your connection and try again.")
case .serverError(let code, let msg):
String(format: String(localized: "Server error (%d): %@"), code, msg)
case .rateLimited:
String(localized: "Too many submissions. Please try again later.")
case .submissionTooLarge:
String(localized: "Submission too large. Try removing the screenshot.")
case .decodingError:
String(localized: "Unexpected server response.")
}
}
}

final class FeedbackAPIClient {
static let shared = FeedbackAPIClient()

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

// swiftlint:disable:next force_unwrapping
private let baseURL = URL(string: "https://api.tablepro.app/v1/feedback")!

private let session: URLSession

private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}()

private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}()

private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
self.session = URLSession(configuration: config)
}

func submitFeedback(request: FeedbackSubmissionRequest) async throws -> FeedbackSubmissionResponse {
try await post(url: baseURL, body: request)
}

// MARK: - Private

private func post<T: Encodable, R: Decodable>(url: URL, body: T) async throws -> R {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try encoder.encode(body)

let data: Data
let response: URLResponse

do {
(data, response) = try await session.data(for: request)
} catch {
Self.logger.error("Network request failed: \(error.localizedDescription)")
throw FeedbackError.networkError(error)
}

guard let httpResponse = response as? HTTPURLResponse else {
throw FeedbackError.networkError(URLError(.badServerResponse))
}

switch httpResponse.statusCode {
case 200...299:
do {
return try decoder.decode(R.self, from: data)
} catch {
Self.logger.error("Failed to decode response: \(error.localizedDescription)")
throw FeedbackError.decodingError(error)
}

case 413:
throw FeedbackError.submissionTooLarge

case 429:
throw FeedbackError.rateLimited

default:
let message: String
if let errorBody = try? JSONDecoder().decode([String: String].self, from: data),
let msg = errorBody["message"] {
message = msg
} else {
message = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)
}
Self.logger.error("Server error \(httpResponse.statusCode): \(message)")
throw FeedbackError.serverError(httpResponse.statusCode, message)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// FeedbackDiagnosticsCollector.swift
// TablePro
//

import Foundation

struct FeedbackDiagnostics {
let appVersion: String
let osVersion: String
let architecture: String
let activeDatabaseType: String?
let installedPlugins: [String]
let machineId: String

var formattedSummary: String {
var parts = ["TablePro \(appVersion)", "\(osVersion) · \(architecture)"]
if let db = activeDatabaseType {
parts.append("Database: \(db)")
}
return parts.joined(separator: "\n")
}

var pluginsSummary: String {
let count = installedPlugins.count
return "\(count) plugin\(count == 1 ? "" : "s") installed"
}
}

@MainActor
enum FeedbackDiagnosticsCollector {
static func collect() -> FeedbackDiagnostics {
let version = ProcessInfo.processInfo.operatingSystemVersion
let osVersion = "macOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"

let architecture: String = {
#if arch(arm64)
return "Apple Silicon"
#else
return "Intel"
#endif
}()

let databaseType = DatabaseManager.shared.activeSessions.values
.first
.map { $0.connection.type.rawValue }

let plugins = PluginManager.shared.plugins.map { "\($0.name) v\($0.version)" }

return FeedbackDiagnostics(
appVersion: "\(Bundle.main.appVersion) (Build \(Bundle.main.buildNumber))",
osVersion: osVersion,
architecture: architecture,
activeDatabaseType: databaseType,
installedPlugins: plugins,
machineId: LicenseStorage.shared.machineId
)
}
}
15 changes: 15 additions & 0 deletions TablePro/Models/UI/FeedbackDraft.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// FeedbackDraft.swift
// TablePro
//

import Foundation

struct FeedbackDraft: Codable {
var feedbackType: String
var title: String
var description: String
var stepsToReproduce: String
var expectedBehavior: String
var includeDiagnostics: Bool
}
Loading
Loading