From c7d4db3dd083eeecb251e3b5ca9cc945d6bdeb26 Mon Sep 17 00:00:00 2001 From: superdooper86 Date: Wed, 15 Jul 2026 15:14:57 +0200 Subject: [PATCH] feat: prepare stable signed updates --- Meetingnotes.xcodeproj/project.pbxproj | 10 +-- .../xcshareddata/swiftpm/Package.resolved | 4 +- meetingnotes/Info.plist | 8 ++- .../Managers/LocalStorageManager.swift | 64 ++++++++++++++++++- meetingnotes/Views/SettingsView.swift | 38 +++++++++++ meetingnotes/meetingnotes.entitlements | 4 -- 6 files changed, 112 insertions(+), 16 deletions(-) diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj index c8ac7eb..0286b81 100644 --- a/Meetingnotes.xcodeproj/project.pbxproj +++ b/Meetingnotes.xcodeproj/project.pbxproj @@ -276,7 +276,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_TEAM = ML6HYR5LUR; ENABLE_HARDENED_RUNTIME = YES; @@ -290,7 +290,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.1.2; + MARKETING_VERSION = 1.1.3; ONLY_ACTIVE_ARCH = NO; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes; @@ -312,7 +312,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_TEAM = ML6HYR5LUR; ENABLE_HARDENED_RUNTIME = YES; @@ -326,7 +326,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.1.2; + MARKETING_VERSION = 1.1.3; ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes; @@ -366,7 +366,7 @@ repositoryURL = "https://github.com/sparkle-project/Sparkle.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.7.1; + minimumVersion = 2.9.4; }; }; CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = { diff --git a/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8606731..743f3ed 100644 --- a/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle.git", "state" : { - "revision" : "df074165274afaa39539c05d57b0832620775b11", - "version" : "2.7.1" + "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", + "version" : "2.9.4" } }, { diff --git a/meetingnotes/Info.plist b/meetingnotes/Info.plist index c4bff09..4094c80 100644 --- a/meetingnotes/Info.plist +++ b/meetingnotes/Info.plist @@ -12,9 +12,13 @@ NSMicrophoneUsageDescription Meetingnotes needs access to your microphone for transcription. SUFeedURL - https://raw.githubusercontent.com/superdooper86/meetingnotes/main/appcast.xml + https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml SUPublicEDKey - BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg= + 9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0= + SUEnableAutomaticChecks + + SUAutomaticallyUpdate + SUEnableInstallerLauncherService diff --git a/meetingnotes/Managers/LocalStorageManager.swift b/meetingnotes/Managers/LocalStorageManager.swift index 4e5990d..d19c6eb 100644 --- a/meetingnotes/Managers/LocalStorageManager.swift +++ b/meetingnotes/Managers/LocalStorageManager.swift @@ -6,6 +6,11 @@ import Foundation /// Manages local file storage for meetings and app data class LocalStorageManager { static let shared = LocalStorageManager() + + struct MeetingImportResult { + let importedCount: Int + let skippedCount: Int + } private let documentsDirectory: URL private let meetingsDirectory: URL @@ -47,7 +52,11 @@ class LocalStorageManager { // Write atomically using a temp file then replace let tmpURL = fileURL.appendingPathExtension("tmp") try data.write(to: tmpURL, options: .atomic) - try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil) + if FileManager.default.fileExists(atPath: fileURL.path) { + try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL) + } else { + try FileManager.default.moveItem(at: tmpURL, to: fileURL) + } print("✅ Saved meeting: \(meeting.id)") return true @@ -128,6 +137,51 @@ class LocalStorageManager { return false } } + + /// Imports meeting JSON files from a folder selected by the user. + func importMeetings(from directory: URL) throws -> MeetingImportResult { + let didStartAccess = directory.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + directory.stopAccessingSecurityScopedResource() + } + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let existingIDs = Set(loadMeetings().map(\.id)) + var importedIDs = Set() + var skippedCount = 0 + + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { + throw CocoaError(.fileReadUnknown) + } + + for case let fileURL as URL in enumerator where fileURL.pathExtension.lowercased() == "json" { + do { + let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) + guard values.isRegularFile == true else { continue } + let data = try Data(contentsOf: fileURL) + let meeting = try decoder.decode(Meeting.self, from: data) + guard meeting.dataVersion <= Meeting.currentDataVersion, + !existingIDs.contains(meeting.id), + !importedIDs.contains(meeting.id), + saveMeeting(meeting) else { + skippedCount += 1 + continue + } + importedIDs.insert(meeting.id) + } catch { + skippedCount += 1 + } + } + + return MeetingImportResult(importedCount: importedIDs.count, skippedCount: skippedCount) + } // MARK: - Template Management @@ -146,7 +200,11 @@ class LocalStorageManager { // Write atomically using a temp file then replace let tmpURL = fileURL.appendingPathExtension("tmp") try data.write(to: tmpURL, options: .atomic) - try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil) + if FileManager.default.fileExists(atPath: fileURL.path) { + try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL) + } else { + try FileManager.default.moveItem(at: tmpURL, to: fileURL) + } print("✅ Saved template: \(template.id)") return true @@ -241,4 +299,4 @@ class LocalStorageManager { var meetingsDirectoryURL: URL { meetingsDirectory } -} \ No newline at end of file +} diff --git a/meetingnotes/Views/SettingsView.swift b/meetingnotes/Views/SettingsView.swift index 25a9cc8..97a44b5 100644 --- a/meetingnotes/Views/SettingsView.swift +++ b/meetingnotes/Views/SettingsView.swift @@ -1,10 +1,14 @@ import SwiftUI +import UniformTypeIdentifiers struct SettingsView: View { @ObservedObject var viewModel: SettingsViewModel @StateObject private var localAPIServer = LocalAPIServer.shared @State private var showingTemplateManager = false @State private var confirmingTokenRegeneration = false + @State private var showingMeetingImporter = false + @State private var meetingImportMessage = "" + @State private var showingMeetingImportResult = false @Binding var navigationPath: NavigationPath init(viewModel: SettingsViewModel, navigationPath: Binding = .constant(NavigationPath())) { @@ -119,6 +123,17 @@ struct SettingsView: View { } } } + + VStack(alignment: .leading, spacing: 8) { + Text("Meeting Storage") + .font(.headline) + + Button { + showingMeetingImporter = true + } label: { + Label("Import Meetings...", systemImage: "square.and.arrow.down") + } + } // Note Templates Section: only the Manage Templates button VStack(alignment: .leading, spacing: 8) { @@ -276,6 +291,29 @@ struct SettingsView: View { } message: { Text(viewModel.saveMessage) } + .fileImporter( + isPresented: $showingMeetingImporter, + allowedContentTypes: [.folder], + allowsMultipleSelection: false + ) { result in + do { + guard let directory = try result.get().first else { return } + let importResult = try LocalStorageManager.shared.importMeetings(from: directory) + meetingImportMessage = "Imported \(importResult.importedCount) meeting\(importResult.importedCount == 1 ? "" : "s")." + if importResult.skippedCount > 0 { + meetingImportMessage += " Skipped \(importResult.skippedCount) existing or invalid file\(importResult.skippedCount == 1 ? "" : "s")." + } + NotificationCenter.default.post(name: .meetingSaved, object: nil) + } catch { + meetingImportMessage = "Meeting import failed: \(error.localizedDescription)" + } + showingMeetingImportResult = true + } + .alert("Meeting Import", isPresented: $showingMeetingImportResult) { + Button("OK") { } + } message: { + Text(meetingImportMessage) + } .confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) { Button("Regenerate", role: .destructive) { viewModel.regenerateMuteDeckAPIToken() diff --git a/meetingnotes/meetingnotes.entitlements b/meetingnotes/meetingnotes.entitlements index 42da2af..e1c5df7 100644 --- a/meetingnotes/meetingnotes.entitlements +++ b/meetingnotes/meetingnotes.entitlements @@ -4,10 +4,6 @@ com.apple.security.app-sandbox - com.apple.security.application-groups - - group.owen.meetingnotes - com.apple.security.device.audio-input com.apple.security.device.screen-capture