diff --git a/StickerCloner/StickerCloner MessagesExtension/Models.swift b/StickerCloner/Shared/Models.swift similarity index 100% rename from StickerCloner/StickerCloner MessagesExtension/Models.swift rename to StickerCloner/Shared/Models.swift diff --git a/StickerCloner/StickerCloner MessagesExtension/StickerClonerAPI.swift b/StickerCloner/Shared/StickerClonerAPI.swift similarity index 100% rename from StickerCloner/StickerCloner MessagesExtension/StickerClonerAPI.swift rename to StickerCloner/Shared/StickerClonerAPI.swift diff --git a/StickerCloner/StickerCloner MessagesExtension/StickerStore.swift b/StickerCloner/Shared/StickerStore.swift similarity index 55% rename from StickerCloner/StickerCloner MessagesExtension/StickerStore.swift rename to StickerCloner/Shared/StickerStore.swift index f91465e..7ff0dc9 100644 --- a/StickerCloner/StickerCloner MessagesExtension/StickerStore.swift +++ b/StickerCloner/Shared/StickerStore.swift @@ -1,14 +1,22 @@ import Foundation -import Messages final class StickerStore { static let shared = StickerStore() private let packsKey = "savedPacks" - private let defaults = UserDefaults.standard + private let suiteName = "group.de.felixfoertsch.StickerCloner" private let fileManager = FileManager.default - private init() {} + private lazy var defaults: UserDefaults = { + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("App Group '\(suiteName)' is not configured") + } + return defaults + }() + + private init() { + migrateIfNeeded() + } // MARK: - Pack persistence @@ -26,8 +34,8 @@ final class StickerStore { // MARK: - File management private var stickersDirectory: URL { - let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! - return caches.appendingPathComponent("stickers", isDirectory: true) + let container = fileManager.containerURL(forSecurityApplicationGroupIdentifier: suiteName)! + return container.appendingPathComponent("stickers", isDirectory: true) } private func packDirectory(name: String) -> URL { @@ -39,7 +47,6 @@ final class StickerStore { func addPack(response: StickerSetResponse) async throws { let pack = SavedPack(name: response.name, title: response.title, stickerCount: response.stickerCount) - // Download all sticker PNGs let packDir = packDirectory(name: response.name) try fileManager.createDirectory(at: packDir, withIntermediateDirectories: true) @@ -49,7 +56,6 @@ final class StickerStore { try await StickerClonerAPI.downloadSticker(from: sticker.pngUrl, to: localURL) } - // Save metadata after successful download var packs = savedPacks packs.removeAll { $0.name == pack.name } packs.append(pack) @@ -65,10 +71,10 @@ final class StickerStore { try? fileManager.removeItem(at: packDir) } - // MARK: - Load stickers for display + // MARK: - Load sticker file URLs - func loadStickers() -> [MSSticker] { - var stickers: [MSSticker] = [] + func stickerFileURLs() -> [URL] { + var urls: [URL] = [] for pack in savedPacks { let packDir = packDirectory(name: pack.name) @@ -76,12 +82,35 @@ final class StickerStore { for file in files.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { guard file.pathExtension == "png" else { continue } - if let sticker = try? MSSticker(contentsOfFileURL: file, localizedDescription: pack.title) { - stickers.append(sticker) - } + urls.append(file) } } - return stickers + return urls + } + + // MARK: - Migration from old sandbox + + private func migrateIfNeeded() { + let migrated = defaults.bool(forKey: "didMigrateToAppGroup") + guard !migrated else { return } + + let oldDefaults = UserDefaults.standard + if let data = oldDefaults.data(forKey: packsKey) { + defaults.set(data, forKey: packsKey) + oldDefaults.removeObject(forKey: packsKey) + } + + let oldCaches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! + let oldStickersDir = oldCaches.appendingPathComponent("stickers", isDirectory: true) + if fileManager.fileExists(atPath: oldStickersDir.path) { + let newStickersDir = stickersDirectory + try? fileManager.createDirectory(at: newStickersDir.deletingLastPathComponent(), withIntermediateDirectories: true) + if !fileManager.fileExists(atPath: newStickersDir.path) { + try? fileManager.moveItem(at: oldStickersDir, to: newStickersDir) + } + } + + defaults.set(true, forKey: "didMigrateToAppGroup") } } diff --git a/StickerCloner/StickerCloner MessagesExtension/MessagesViewController.swift b/StickerCloner/StickerCloner MessagesExtension/MessagesViewController.swift index aa30bd9..efd0056 100644 --- a/StickerCloner/StickerCloner MessagesExtension/MessagesViewController.swift +++ b/StickerCloner/StickerCloner MessagesExtension/MessagesViewController.swift @@ -1,10 +1,8 @@ import Messages -import SwiftUI import UIKit final class MessagesViewController: MSMessagesAppViewController { private let stickerBrowser = StickerBrowserViewController() - private var addPackHostingController: UIHostingController? override func viewDidLoad() { super.viewDidLoad() @@ -14,28 +12,6 @@ final class MessagesViewController: MSMessagesAppViewController { override func willBecomeActive(with conversation: MSConversation) { stickerBrowser.reloadStickers() - - // Prompt user to add a pack on first launch - if StickerStore.shared.savedPacks.isEmpty { - requestPresentationStyle(.expanded) - } - } - - // MARK: - Presentation style transitions - - override func willTransition(to presentationStyle: MSMessagesAppPresentationStyle) { - removeAddPackView() - } - - override func didTransition(to presentationStyle: MSMessagesAppPresentationStyle) { - switch presentationStyle { - case .expanded: - showAddPackView() - case .compact: - stickerBrowser.reloadStickers() - default: - break - } } // MARK: - Child view controllers @@ -52,31 +28,4 @@ final class MessagesViewController: MSMessagesAppViewController { ]) stickerBrowser.didMove(toParent: self) } - - private func showAddPackView() { - let addPackView = AddPackView { [weak self] in - self?.stickerBrowser.reloadStickers() - } - let hostingController = UIHostingController(rootView: addPackView) - addPackHostingController = hostingController - - addChild(hostingController) - hostingController.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(hostingController.view) - NSLayoutConstraint.activate([ - hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), - hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) - hostingController.didMove(toParent: self) - } - - private func removeAddPackView() { - guard let hostingController = addPackHostingController else { return } - hostingController.willMove(toParent: nil) - hostingController.view.removeFromSuperview() - hostingController.removeFromParent() - addPackHostingController = nil - } } diff --git a/StickerCloner/StickerCloner MessagesExtension/StickerBrowserViewController.swift b/StickerCloner/StickerCloner MessagesExtension/StickerBrowserViewController.swift index 0515f06..862a4bf 100644 --- a/StickerCloner/StickerCloner MessagesExtension/StickerBrowserViewController.swift +++ b/StickerCloner/StickerCloner MessagesExtension/StickerBrowserViewController.swift @@ -5,7 +5,13 @@ final class StickerBrowserViewController: MSStickerBrowserViewController { private var stickers: [MSSticker] = [] func reloadStickers() { - stickers = StickerStore.shared.loadStickers() + let urls = StickerStore.shared.stickerFileURLs() + stickers = urls.compactMap { url in + try? MSSticker( + contentsOfFileURL: url, + localizedDescription: url.deletingLastPathComponent().lastPathComponent + ) + } stickerBrowserView.reloadData() } diff --git a/StickerCloner/StickerCloner MessagesExtension/StickerClonerMessagesExtension.entitlements b/StickerCloner/StickerCloner MessagesExtension/StickerClonerMessagesExtension.entitlements new file mode 100644 index 0000000..2869c5d --- /dev/null +++ b/StickerCloner/StickerCloner MessagesExtension/StickerClonerMessagesExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.de.felixfoertsch.StickerCloner + + + diff --git a/StickerCloner/StickerCloner.xcodeproj/project.pbxproj b/StickerCloner/StickerCloner.xcodeproj/project.pbxproj index bc321dd..d1879e8 100644 --- a/StickerCloner/StickerCloner.xcodeproj/project.pbxproj +++ b/StickerCloner/StickerCloner.xcodeproj/project.pbxproj @@ -65,9 +65,21 @@ path = "StickerCloner MessagesExtension"; sourceTree = ""; }; + 3FD2D01C2F57098E00B76B50 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 3FD2D01B2F57098E00B76B50 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3FD2CFFD2F57098D00B76B50 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -83,6 +95,7 @@ isa = PBXGroup; children = ( 3FD2CFF92F57098B00B76B50 /* StickerCloner */, + 3FD2D01C2F57098E00B76B50 /* Shared */, 3FD2D0072F57098D00B76B50 /* StickerCloner MessagesExtension */, 3FD2D0042F57098D00B76B50 /* Frameworks */, 3FD2CFF82F57098B00B76B50 /* Products */, @@ -113,6 +126,8 @@ isa = PBXNativeTarget; buildConfigurationList = 3FD2D0172F57098E00B76B50 /* Build configuration list for PBXNativeTarget "StickerCloner" */; buildPhases = ( + 3FD2D01A2F57098E00B76B50 /* Sources */, + 3FD2D01B2F57098E00B76B50 /* Frameworks */, 3FD2CFF52F57098B00B76B50 /* Resources */, 3FD2D0162F57098E00B76B50 /* Embed Foundation Extensions */, ); @@ -123,13 +138,14 @@ ); fileSystemSynchronizedGroups = ( 3FD2CFF92F57098B00B76B50 /* StickerCloner */, + 3FD2D01C2F57098E00B76B50 /* Shared */, ); name = StickerCloner; packageProductDependencies = ( ); productName = StickerCloner; productReference = 3FD2CFF72F57098B00B76B50 /* StickerCloner.app */; - productType = "com.apple.product-type.application.messages"; + productType = "com.apple.product-type.application"; }; 3FD2CFFF2F57098D00B76B50 /* StickerCloner MessagesExtension */ = { isa = PBXNativeTarget; @@ -145,6 +161,7 @@ ); fileSystemSynchronizedGroups = ( 3FD2D0072F57098D00B76B50 /* StickerCloner MessagesExtension */, + 3FD2D01C2F57098E00B76B50 /* Shared */, ); name = "StickerCloner MessagesExtension"; packageProductDependencies = ( @@ -209,6 +226,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 3FD2D01A2F57098E00B76B50 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3FD2CFFC2F57098D00B76B50 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -231,6 +255,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; + CODE_SIGN_ENTITLEMENTS = "StickerCloner MessagesExtension/StickerClonerMessagesExtension.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = NG5W75WE8U; @@ -260,6 +285,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; + CODE_SIGN_ENTITLEMENTS = "StickerCloner MessagesExtension/StickerClonerMessagesExtension.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = NG5W75WE8U; @@ -410,6 +436,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = StickerCloner/StickerCloner.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = NG5W75WE8U; @@ -433,6 +460,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = StickerCloner/StickerCloner.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = NG5W75WE8U; diff --git a/StickerCloner/StickerCloner.xcodeproj/xcshareddata/xcschemes/StickerCloner.xcscheme b/StickerCloner/StickerCloner.xcodeproj/xcshareddata/xcschemes/StickerCloner.xcscheme index f3ec3db..f9f4309 100644 --- a/StickerCloner/StickerCloner.xcodeproj/xcshareddata/xcschemes/StickerCloner.xcscheme +++ b/StickerCloner/StickerCloner.xcodeproj/xcshareddata/xcschemes/StickerCloner.xcscheme @@ -35,27 +35,21 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" - askForAppToLaunch = "Yes" - launchAutomaticallySubstyle = "2" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> - - - + - + + + + + diff --git a/StickerCloner/StickerCloner MessagesExtension/AddPackView.swift b/StickerCloner/StickerCloner/AddPackView.swift similarity index 86% rename from StickerCloner/StickerCloner MessagesExtension/AddPackView.swift rename to StickerCloner/StickerCloner/AddPackView.swift index 5f1a931..65c8575 100644 --- a/StickerCloner/StickerCloner MessagesExtension/AddPackView.swift +++ b/StickerCloner/StickerCloner/AddPackView.swift @@ -4,17 +4,10 @@ struct AddPackView: View { @State private var urlText = "" @State private var isLoading = false @State private var errorMessage: String? - @State private var packs: [SavedPack] - - var onPacksChanged: () -> Void - - init(onPacksChanged: @escaping () -> Void) { - self.onPacksChanged = onPacksChanged - _packs = State(initialValue: StickerStore.shared.savedPacks) - } + @State private var packs: [SavedPack] = StickerStore.shared.savedPacks var body: some View { - NavigationView { + NavigationStack { List { Section { HStack { @@ -86,7 +79,6 @@ struct AddPackView: View { try await StickerStore.shared.addPack(response: response) packs = StickerStore.shared.savedPacks urlText = "" - onPacksChanged() } catch { errorMessage = error.localizedDescription } @@ -99,13 +91,11 @@ struct AddPackView: View { StickerStore.shared.removePack(name: packs[index].name) } packs = StickerStore.shared.savedPacks - onPacksChanged() } private func extractPackName(from input: String) -> String { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - // Handle full URL: https://t.me/addstickers/PackName if let url = URL(string: trimmed), let host = url.host, host.hasSuffix("t.me") { @@ -117,12 +107,10 @@ struct AddPackView: View { return url.lastPathComponent } - // Handle bare URL without scheme if trimmed.contains("t.me/addstickers/") { return trimmed.components(separatedBy: "t.me/addstickers/").last ?? "" } - // Treat as bare pack name return trimmed } } diff --git a/StickerCloner/StickerCloner/StickerCloner.entitlements b/StickerCloner/StickerCloner/StickerCloner.entitlements new file mode 100644 index 0000000..2869c5d --- /dev/null +++ b/StickerCloner/StickerCloner/StickerCloner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.de.felixfoertsch.StickerCloner + + + diff --git a/StickerCloner/StickerCloner/StickerClonerApp.swift b/StickerCloner/StickerCloner/StickerClonerApp.swift new file mode 100644 index 0000000..aec8c77 --- /dev/null +++ b/StickerCloner/StickerCloner/StickerClonerApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct StickerClonerApp: App { + var body: some Scene { + WindowGroup { + AddPackView() + } + } +}