Merge pull request 'iCloud Backup, better chatview exp. bugfixes++' (#1) from 2.3.5 into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-02-27 14:09:59 +01:00
11 changed files with 634 additions and 30 deletions

View File

@@ -279,7 +279,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.3.3; MARKETING_VERSION = 2.3.4;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -323,7 +323,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.3.3; MARKETING_VERSION = 2.3.4;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;

View File

@@ -31,6 +31,14 @@ enum MessageRole: String, Codable {
case system case system
} }
/// Detail for a single tool call within a system "🔧 Calling:" message.
/// Not persisted in-memory only.
struct ToolCallDetail {
let name: String
let input: String // raw JSON string of arguments
var result: String? // raw JSON string of result (nil while pending)
}
struct Message: Identifiable, Codable, Equatable { struct Message: Identifiable, Codable, Equatable {
let id: UUID let id: UUID
let role: MessageRole let role: MessageRole
@@ -52,6 +60,9 @@ struct Message: Identifiable, Codable, Equatable {
// Generated images from image-output models (base64-decoded PNG/JPEG data) // Generated images from image-output models (base64-decoded PNG/JPEG data)
var generatedImages: [Data]? = nil var generatedImages: [Data]? = nil
// Tool call details (not persisted in-memory only for expandable display)
var toolCalls: [ToolCallDetail]? = nil
init( init(
id: UUID = UUID(), id: UUID = UUID(),
role: MessageRole, role: MessageRole,

View File

@@ -318,7 +318,41 @@ class AnthropicProvider: AIProvider {
conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""]) conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""])
} }
} else { } else {
conversationMessages.append(["role": role, "content": msg["content"] as? String ?? ""]) // Content may be a String (plain text) or an Array (image/multipart blocks)
if let contentArray = msg["content"] as? [[String: Any]] {
// Convert image_url blocks (OpenAI format) to Anthropic image blocks
let anthropicBlocks: [[String: Any]] = contentArray.compactMap { block in
let type = block["type"] as? String ?? ""
if type == "text" {
return block
} else if type == "image_url",
let imageUrl = block["image_url"] as? [String: Any],
let url = imageUrl["url"] as? String,
url.hasPrefix("data:") {
// data:<mediaType>;base64,<data>
let withoutData = url.dropFirst(5) // strip "data:"
guard let semicolon = withoutData.firstIndex(of: ";"),
let comma = withoutData.firstIndex(of: ",") else { return nil }
let mediaType = String(withoutData[withoutData.startIndex..<semicolon])
let base64Data = String(withoutData[withoutData.index(after: comma)...])
return [
"type": "image",
"source": [
"type": "base64",
"media_type": mediaType,
"data": base64Data
]
]
}
return nil
}
if !anthropicBlocks.isEmpty {
conversationMessages.append(["role": role, "content": anthropicBlocks])
}
} else {
let content = (msg["content"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
conversationMessages.append(["role": role, "content": content.isEmpty ? "[Image]" : content])
}
} }
} }
@@ -518,7 +552,9 @@ class AnthropicProvider: AIProvider {
if hasAttachments, let attachments = msg.attachments { if hasAttachments, let attachments = msg.attachments {
var contentBlocks: [[String: Any]] = [] var contentBlocks: [[String: Any]] = []
contentBlocks.append(["type": "text", "text": msg.content]) if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
contentBlocks.append(["type": "text", "text": msg.content])
}
for attachment in attachments { for attachment in attachments {
guard let data = attachment.data else { continue } guard let data = attachment.data else { continue }
@@ -541,7 +577,8 @@ class AnthropicProvider: AIProvider {
} }
apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks]) apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks])
} else { } else {
apiMessages.append(["role": msg.role.rawValue, "content": msg.content]) let content = msg.content.trimmingCharacters(in: .whitespacesAndNewlines)
apiMessages.append(["role": msg.role.rawValue, "content": content.isEmpty ? "[Image]" : content])
} }
} }

View File

@@ -0,0 +1,205 @@
//
// BackupService.swift
// oAI
//
// iCloud Drive backup of non-encrypted settings (Option C, v1)
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import Foundation
import os
// MARK: - BackupManifest
struct BackupManifest: Codable {
let version: Int
let createdAt: String
let appVersion: String
let credentialsIncluded: Bool
let settings: [String: String]
let credentials: [String: String]?
}
// MARK: - BackupService
@Observable
final class BackupService {
static let shared = BackupService()
private let log = Logger(subsystem: "oAI", category: "backup")
/// Whether iCloud Drive is available on this machine
var iCloudAvailable: Bool = false
/// Date of the last backup file on disk (from file attributes)
var lastBackupDate: Date?
/// URL of the last backup file
var lastBackupURL: URL?
// Keys excluded from backup encrypted_ prefix + internal migration flags
private static let excludedKeys: Set<String> = [
"encrypted_openrouterAPIKey",
"encrypted_anthropicAPIKey",
"encrypted_openaiAPIKey",
"encrypted_googleAPIKey",
"encrypted_googleSearchEngineID",
"encrypted_anytypeMcpAPIKey",
"encrypted_paperlessAPIToken",
"encrypted_syncUsername",
"encrypted_syncPassword",
"encrypted_syncAccessToken",
"encrypted_emailUsername",
"encrypted_emailPassword",
"_migrated",
"_keychain_migrated",
]
private init() {
checkForExistingBackup()
}
// MARK: - iCloud Path Resolution
private func resolveBackupDirectory() -> URL {
let home = FileManager.default.homeDirectoryForCurrentUser
let icloudRoot = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs")
if FileManager.default.fileExists(atPath: icloudRoot.path) {
let icloudOAI = icloudRoot.appendingPathComponent("oAI")
try? FileManager.default.createDirectory(at: icloudOAI, withIntermediateDirectories: true)
return icloudOAI
}
// Fallback: Downloads
return FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
}
func checkForExistingBackup() {
let home = FileManager.default.homeDirectoryForCurrentUser
let icloudRoot = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs")
iCloudAvailable = FileManager.default.fileExists(atPath: icloudRoot.path)
let dir = resolveBackupDirectory()
let fileURL = dir.appendingPathComponent("oai_backup.json")
if FileManager.default.fileExists(atPath: fileURL.path),
let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let modified = attrs[.modificationDate] as? Date {
lastBackupDate = modified
lastBackupURL = fileURL
}
}
// MARK: - Export
/// Export all non-encrypted settings to iCloud Drive (or Downloads).
/// Returns the URL where the file was written.
@discardableResult
func exportSettings() async throws -> URL {
// Load raw settings from DB
guard let allSettings = try? DatabaseService.shared.loadAllSettings() else {
throw BackupError.databaseReadFailed
}
// Filter out excluded keys
let filtered = allSettings.filter { !Self.excludedKeys.contains($0.key) }
// Build manifest
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
let manifest = BackupManifest(
version: 1,
createdAt: formatter.string(from: Date()),
appVersion: appVersion(),
credentialsIncluded: false,
settings: filtered,
credentials: nil
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(manifest)
let dir = resolveBackupDirectory()
let fileURL = dir.appendingPathComponent("oai_backup.json")
try data.write(to: fileURL, options: .atomic)
log.info("Backup written to \(fileURL.path, privacy: .public) (\(filtered.count) settings)")
await MainActor.run {
self.lastBackupDate = Date()
self.lastBackupURL = fileURL
}
return fileURL
}
// MARK: - Import
/// Restore settings from a backup JSON file.
func importSettings(from url: URL) async throws {
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
let manifest: BackupManifest
do {
manifest = try decoder.decode(BackupManifest.self, from: data)
} catch {
throw BackupError.invalidFormat(error.localizedDescription)
}
guard manifest.version == 1 else {
throw BackupError.unsupportedVersion(manifest.version)
}
// Write each setting to the database
for (key, value) in manifest.settings {
DatabaseService.shared.setSetting(key: key, value: value)
}
// Refresh in-memory cache
SettingsService.shared.reloadFromDatabase()
log.info("Restored \(manifest.settings.count) settings from backup (v\(manifest.version))")
}
// MARK: - Helpers
private func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}
}
// MARK: - BackupError
enum BackupError: LocalizedError {
case databaseReadFailed
case invalidFormat(String)
case unsupportedVersion(Int)
var errorDescription: String? {
switch self {
case .databaseReadFailed:
return "Could not read settings from the database."
case .invalidFormat(let detail):
return "The backup file is not valid: \(detail)"
case .unsupportedVersion(let v):
return "Backup version \(v) is not supported by this version of oAI."
}
}
}

View File

@@ -953,6 +953,15 @@ class SettingsService {
return true return true
} }
// MARK: - Cache Reload
/// Replace the in-memory cache with a fresh read from the database.
/// Called by BackupService after restoring a backup.
func reloadFromDatabase() {
cache = (try? DatabaseService.shared.loadAllSettings()) ?? [:]
Log.settings.info("Settings cache reloaded (\(self.cache.count) entries)")
}
// MARK: - UserDefaults Migration // MARK: - UserDefaults Migration
private func migrateFromUserDefaultsIfNeeded() { private func migrateFromUserDefaultsIfNeeded() {

View File

@@ -295,6 +295,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
func sendMessage() { func sendMessage() {
guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return } guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
// If already generating, cancel first new message becomes a followup in context
if isGenerating { cancelGeneration() }
let trimmedInput = inputText.trimmingCharacters(in: .whitespaces) let trimmedInput = inputText.trimmingCharacters(in: .whitespaces)
// Handle slash escape: "//" becomes "/" // Handle slash escape: "//" becomes "/"
@@ -382,6 +385,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
MCPService.shared.resetBashSessionApproval() MCPService.shared.resetBashSessionApproval()
messages = loadedMessages messages = loadedMessages
// Track identity so S can re-save under the same name
currentConversationId = conversation.id
currentConversationName = conversation.name
savedMessageCount = loadedMessages.filter { $0.role != .system }.count
// Rebuild session stats from loaded messages // Rebuild session stats from loaded messages
for msg in loadedMessages { for msg in loadedMessages {
sessionStats.addMessage( sessionStats.addMessage(
@@ -1248,7 +1256,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments { if hasAttachments {
var contentArray: [[String: Any]] = [["type": "text", "text": msg.content]] var contentArray: [[String: Any]] = []
if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
contentArray.append(["type": "text", "text": msg.content])
}
for attachment in msg.attachments ?? [] { for attachment in msg.attachments ?? [] {
guard let data = attachment.data else { continue } guard let data = attachment.data else { continue }
switch attachment.type { switch attachment.type {
@@ -1264,7 +1275,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
} }
return ["role": msg.role.rawValue, "content": contentArray] return ["role": msg.role.rawValue, "content": contentArray]
} }
return ["role": msg.role.rawValue, "content": msg.content] let content = msg.content.trimmingCharacters(in: .whitespacesAndNewlines)
return ["role": msg.role.rawValue, "content": content.isEmpty ? "[Image]" : content]
} }
// If this is a silent auto-continue, inject the prompt into the API call only // If this is a silent auto-continue, inject the prompt into the API call only
@@ -1311,7 +1323,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Show what tools the model is calling // Show what tools the model is calling
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ") let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
showSystemMessage("🔧 Calling: \(toolNames)") let toolMsgId = showSystemMessage("🔧 Calling: \(toolNames)")
// Initialise detail entries with inputs (results fill in below)
var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
}
updateToolCallMessage(id: toolMsgId, details: toolDetails)
let usingTextCalls = !textCalls.isEmpty let usingTextCalls = !textCalls.isEmpty
@@ -1340,7 +1358,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Execute each tool and append results // Execute each tool and append results
var toolResultLines: [String] = [] var toolResultLines: [String] = []
for tc in toolCalls { for (i, tc) in toolCalls.enumerated() {
if Task.isCancelled { if Task.isCancelled {
wasCancelled = true wasCancelled = true
break break
@@ -1362,6 +1380,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
resultJSON = "{\"error\": \"Failed to serialize result\"}" resultJSON = "{\"error\": \"Failed to serialize result\"}"
} }
// Update the detail entry with the result so the UI can show it
toolDetails[i].result = resultJSON
updateToolCallMessage(id: toolMsgId, details: toolDetails)
if usingTextCalls { if usingTextCalls {
// Inject results as a user message for text-call models // Inject results as a user message for text-call models
toolResultLines.append("Tool result for \(tc.functionName):\n\(resultJSON)") toolResultLines.append("Tool result for \(tc.functionName):\n\(resultJSON)")
@@ -1462,7 +1484,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
} }
} }
private func showSystemMessage(_ text: String) { @discardableResult
private func showSystemMessage(_ text: String) -> UUID {
let message = Message( let message = Message(
role: .system, role: .system,
content: text, content: text,
@@ -1472,6 +1495,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
attachments: nil attachments: nil
) )
messages.append(message) messages.append(message)
return message.id
}
private func updateToolCallMessage(id: UUID, details: [ToolCallDetail]) {
if let idx = messages.firstIndex(where: { $0.id == id }) {
messages[idx].toolCalls = details
}
} }
// MARK: - Error Helpers // MARK: - Error Helpers

View File

@@ -87,7 +87,7 @@ struct FooterView: View {
// Shortcuts hint // Shortcuts hint
#if os(macOS) #if os(macOS)
Text("⌘N New • ⌘M Model • ⌘S Save") Text("⌘N New • ⌘M Model • ⌘S Save")
.font(.caption2) .font(.caption2)
.foregroundColor(.oaiSecondary) .foregroundColor(.oaiSecondary)
#endif #endif

View File

@@ -33,6 +33,8 @@ struct MessageRow: View {
let viewModel: ChatViewModel? let viewModel: ChatViewModel?
private let settings = SettingsService.shared private let settings = SettingsService.shared
@State private var isExpanded = false
#if os(macOS) #if os(macOS)
@State private var isHovering = false @State private var isHovering = false
@State private var showCopied = false @State private var showCopied = false
@@ -82,8 +84,8 @@ struct MessageRow: View {
.help("Star this message to always include it in context") .help("Star this message to always include it in context")
} }
// Copy button (assistant messages only, visible on hover) // Copy button (user + assistant messages, visible on hover)
if message.role == .assistant && isHovering && !message.content.isEmpty { if (message.role == .assistant || message.role == .user) && isHovering && !message.content.isEmpty {
Button(action: copyContent) { Button(action: copyContent) {
HStack(spacing: 3) { HStack(spacing: 3) {
Image(systemName: showCopied ? "checkmark" : "doc.on.doc") Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
@@ -188,27 +190,126 @@ struct MessageRow: View {
@ViewBuilder @ViewBuilder
private var compactSystemMessage: some View { private var compactSystemMessage: some View {
HStack(spacing: 8) { let expandable = message.toolCalls != nil
Image(systemName: "wrench.and.screwdriver") VStack(alignment: .leading, spacing: 0) {
.font(.system(size: 11)) Button(action: {
.foregroundColor(.secondary) if expandable {
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
}
}) {
HStack(spacing: 8) {
Image(systemName: "wrench.and.screwdriver")
.font(.system(size: 11))
.foregroundColor(.secondary)
Text(message.content) Text(message.content)
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Spacer() Spacer()
Text(message.timestamp, style: .time) if expandable {
.font(.system(size: 10)) Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.foregroundColor(.secondary.opacity(0.7)) .font(.system(size: 9, weight: .medium))
.foregroundColor(.secondary.opacity(0.5))
}
Text(message.timestamp, style: .time)
.font(.system(size: 10))
.foregroundColor(.secondary.opacity(0.7))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded, let calls = message.toolCalls {
Divider()
.padding(.horizontal, 8)
toolCallsDetailView(calls)
}
} }
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.secondary.opacity(0.08)) .background(Color.secondary.opacity(0.08))
.cornerRadius(6) .cornerRadius(6)
} }
@ViewBuilder
private func toolCallsDetailView(_ calls: [ToolCallDetail]) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(calls.indices, id: \.self) { i in
let call = calls[i]
VStack(alignment: .leading, spacing: 6) {
// Tool name + status
HStack(spacing: 6) {
Image(systemName: "function")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
Text(call.name)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
Spacer()
if call.result == nil {
ProgressView()
.scaleEffect(0.5)
.frame(width: 12, height: 12)
} else {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 10))
.foregroundColor(.green.opacity(0.8))
}
}
// Input
if !call.input.isEmpty && call.input != "{}" {
toolDetailSection(label: "Input", text: prettyJSON(call.input), maxHeight: 100)
}
// Result
if let result = call.result {
toolDetailSection(label: "Result", text: prettyJSON(result), maxHeight: 180)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
if i < calls.count - 1 {
Divider().padding(.horizontal, 12)
}
}
}
}
@ViewBuilder
private func toolDetailSection(label: String, text: String, maxHeight: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased())
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.secondary.opacity(0.6))
ScrollView([.vertical, .horizontal]) {
Text(text)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxHeight: maxHeight)
.background(Color.secondary.opacity(0.06))
.cornerRadius(4)
}
}
private func prettyJSON(_ raw: String) -> String {
guard let data = raw.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
let str = String(data: pretty, encoding: .utf8) else {
return raw
}
return str
}
// MARK: - Message Content // MARK: - Message Content
@ViewBuilder @ViewBuilder

View File

@@ -112,7 +112,8 @@ private let helpCategories: [CommandCategory] = [
command: "/save <name>", command: "/save <name>",
brief: "Save current conversation", brief: "Save current conversation",
detail: "Saves all messages in the current session under the given name. You can load it later to continue the conversation.", detail: "Saves all messages in the current session under the given name. You can load it later to continue the conversation.",
examples: ["/save my-project-chat", "/save debug session"] examples: ["/save my-project-chat", "/save debug session"],
shortcut: "⌘S"
), ),
CommandDetail( CommandDetail(
command: "/load", command: "/load",
@@ -191,7 +192,8 @@ private let helpCategories: [CommandCategory] = [
command: "/stats", command: "/stats",
brief: "Show session statistics", brief: "Show session statistics",
detail: "Displays statistics for the current session: message count, total tokens used, estimated cost, and session duration.", detail: "Displays statistics for the current session: message count, total tokens used, estimated cost, and session duration.",
examples: ["/stats"] examples: ["/stats"],
shortcut: "⌘⇧S"
), ),
]), ]),
] ]

View File

@@ -65,6 +65,14 @@ struct SettingsView: View {
@State private var isTestingPaperless = false @State private var isTestingPaperless = false
@State private var paperlessTestResult: String? @State private var paperlessTestResult: String?
// Backup state
private let backupService = BackupService.shared
@State private var isExporting = false
@State private var isImporting = false
@State private var backupMessage: String?
@State private var backupMessageIsError = false
@State private var showRestoreFilePicker = false
// Email handler state // Email handler state
@State private var showEmailLog = false @State private var showEmailLog = false
@State private var showEmailModelSelector = false @State private var showEmailModelSelector = false
@@ -135,6 +143,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync") tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
tabButton(5, icon: "envelope", label: "Email") tabButton(5, icon: "envelope", label: "Email")
tabButton(8, icon: "doc.text", label: "Paperless") tabButton(8, icon: "doc.text", label: "Paperless")
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.bottom, 12) .padding(.bottom, 12)
@@ -162,6 +171,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
agentSkillsTab agentSkillsTab
case 8: case 8:
paperlessTab paperlessTab
case 9:
backupTab
default: default:
generalTab generalTab
} }
@@ -171,10 +182,24 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
} }
} }
.frame(minWidth: 740, idealWidth: 820, minHeight: 620, idealHeight: 760) .frame(minWidth: 860, idealWidth: 940, minHeight: 620, idealHeight: 760)
.sheet(isPresented: $showEmailLog) { .sheet(isPresented: $showEmailLog) {
EmailLogView() EmailLogView()
} }
.fileImporter(
isPresented: $showRestoreFilePicker,
allowedContentTypes: [.json],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
Task { await performRestore(from: url) }
case .failure(let error):
backupMessage = "Could not open file: \(error.localizedDescription)"
backupMessageIsError = true
}
}
} }
// MARK: - General Tab // MARK: - General Tab
@@ -1823,6 +1848,184 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
} }
} }
// MARK: - Backup Tab
@ViewBuilder
private var backupTab: some View {
VStack(alignment: .leading, spacing: 20) {
// Warning notice
VStack(alignment: .leading, spacing: 6) {
sectionHeader("iCloud Drive Backup")
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.system(size: 14))
Text("API keys and credentials are **not** included in the backup. You will need to re-enter them after restoring on a new machine.")
.font(.system(size: 13))
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 4)
.padding(.top, 2)
}
// Status
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Status")
formSection {
row("iCloud Drive") {
HStack(spacing: 6) {
Circle()
.fill(backupService.iCloudAvailable ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(backupService.iCloudAvailable ? "Available" : "Not available — using Downloads")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
rowDivider()
row("Last Backup") {
if let date = backupService.lastBackupDate {
Text(formatBackupDate(date))
.font(.system(size: 13))
.foregroundStyle(.secondary)
} else {
Text("Never")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
}
}
// Actions
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Actions")
formSection {
HStack(spacing: 12) {
Button(action: { Task { await performBackup() } }) {
HStack(spacing: 6) {
if isExporting {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "icloud.and.arrow.up")
}
Text("Back Up Now")
}
}
.disabled(isExporting || isImporting)
Button(action: { showRestoreFilePicker = true }) {
HStack(spacing: 6) {
if isImporting {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "icloud.and.arrow.down")
}
Text("Restore from File…")
}
}
.disabled(isExporting || isImporting)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
if let msg = backupMessage {
rowDivider()
HStack(spacing: 6) {
Image(systemName: backupMessageIsError ? "xmark.circle.fill" : "checkmark.circle.fill")
.foregroundStyle(backupMessageIsError ? .red : .green)
Text(msg)
.font(.system(size: 13))
.foregroundStyle(backupMessageIsError ? .red : .primary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
// Backup location
if let url = backupService.lastBackupURL {
VStack(alignment: .leading, spacing: 4) {
Text("Backup location:")
.font(.system(size: 13, weight: .medium))
Text(url.path.replacingOccurrences(
of: FileManager.default.homeDirectoryForCurrentUser.path,
with: "~"))
.font(.system(size: 12))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding(.horizontal, 4)
}
}
.onAppear {
backupService.checkForExistingBackup()
}
}
private func performBackup() async {
await MainActor.run {
isExporting = true
backupMessage = nil
}
do {
let url = try await backupService.exportSettings()
let shortPath = url.path.replacingOccurrences(
of: FileManager.default.homeDirectoryForCurrentUser.path,
with: "~")
await MainActor.run {
backupMessage = "Backup saved to \(shortPath)"
backupMessageIsError = false
isExporting = false
}
} catch {
await MainActor.run {
backupMessage = error.localizedDescription
backupMessageIsError = true
isExporting = false
}
}
}
private func performRestore(from url: URL) async {
await MainActor.run {
isImporting = true
backupMessage = nil
}
do {
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
try await backupService.importSettings(from: url)
await MainActor.run {
backupMessage = "Settings restored. Re-enter your API keys to resume using oAI."
backupMessageIsError = false
isImporting = false
}
} catch {
await MainActor.run {
backupMessage = error.localizedDescription
backupMessageIsError = true
isImporting = false
}
}
}
private func formatBackupDate(_ date: Date) -> String {
let cal = Calendar.current
if cal.isDateInToday(date) {
let tf = DateFormatter()
tf.dateFormat = "HH:mm"
return "Today \(tf.string(from: date))"
}
let df = DateFormatter()
df.dateFormat = "dd.MM.yyyy HH:mm"
return df.string(from: date)
}
// MARK: - Tab Navigation // MARK: - Tab Navigation
private func tabButton(_ tag: Int, icon: String, label: String) -> some View { private func tabButton(_ tag: Int, icon: String, label: String) -> some View {
@@ -1856,6 +2059,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
case 6: return "Shortcuts" case 6: return "Shortcuts"
case 7: return "Skills" case 7: return "Skills"
case 8: return "Paperless" case 8: return "Paperless"
case 9: return "Backup"
default: return "Settings" default: return "Settings"
} }
} }

View File

@@ -84,6 +84,9 @@ struct oAIApp: App {
CommandGroup(replacing: .newItem) { CommandGroup(replacing: .newItem) {
Button("New Chat") { chatViewModel.newConversation() } Button("New Chat") { chatViewModel.newConversation() }
.keyboardShortcut("n", modifiers: .command) .keyboardShortcut("n", modifiers: .command)
Button("Clear Chat") { chatViewModel.clearChat() }
.keyboardShortcut("k", modifiers: .command)
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
} }
CommandGroup(after: .newItem) { CommandGroup(after: .newItem) {
@@ -93,8 +96,10 @@ struct oAIApp: App {
CommandGroup(replacing: .saveItem) { CommandGroup(replacing: .saveItem) {
Button("Save Chat") { chatViewModel.saveFromMenu() } Button("Save Chat") { chatViewModel.saveFromMenu() }
.keyboardShortcut("s", modifiers: [.command, .shift]) .keyboardShortcut("s", modifiers: .command)
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Stats") { chatViewModel.showStats = true }
.keyboardShortcut("s", modifiers: [.command, .shift])
} }
CommandGroup(after: .importExport) { CommandGroup(after: .importExport) {