restructure as standalone iOS app + iMessage extension

convert container from Messages-only app to regular iOS app,
move shared code (models, api client, store) to Shared/ group,
add app group entitlements for cross-process data sharing,
rewrite StickerStore for shared UserDefaults + container,
create SwiftUI app entry point, simplify extension to read-only browser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 15:40:52 +01:00
parent 027def42f9
commit 31040c3ca5
11 changed files with 127 additions and 93 deletions

View File

@@ -0,0 +1,116 @@
import Foundation
final class StickerStore {
static let shared = StickerStore()
private let packsKey = "savedPacks"
private let suiteName = "group.de.felixfoertsch.StickerCloner"
private let fileManager = FileManager.default
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
var savedPacks: [SavedPack] {
get {
guard let data = defaults.data(forKey: packsKey) else { return [] }
return (try? JSONDecoder().decode([SavedPack].self, from: data)) ?? []
}
set {
let data = try? JSONEncoder().encode(newValue)
defaults.set(data, forKey: packsKey)
}
}
// MARK: - File management
private var stickersDirectory: URL {
let container = fileManager.containerURL(forSecurityApplicationGroupIdentifier: suiteName)!
return container.appendingPathComponent("stickers", isDirectory: true)
}
private func packDirectory(name: String) -> URL {
stickersDirectory.appendingPathComponent(name, isDirectory: true)
}
// MARK: - Add / remove packs
func addPack(response: StickerSetResponse) async throws {
let pack = SavedPack(name: response.name, title: response.title, stickerCount: response.stickerCount)
let packDir = packDirectory(name: response.name)
try fileManager.createDirectory(at: packDir, withIntermediateDirectories: true)
for sticker in response.stickers {
let localURL = packDir.appendingPathComponent("\(sticker.id).png")
if fileManager.fileExists(atPath: localURL.path) { continue }
try await StickerClonerAPI.downloadSticker(from: sticker.pngUrl, to: localURL)
}
var packs = savedPacks
packs.removeAll { $0.name == pack.name }
packs.append(pack)
savedPacks = packs
}
func removePack(name: String) {
var packs = savedPacks
packs.removeAll { $0.name == name }
savedPacks = packs
let packDir = packDirectory(name: name)
try? fileManager.removeItem(at: packDir)
}
// MARK: - Load sticker file URLs
func stickerFileURLs() -> [URL] {
var urls: [URL] = []
for pack in savedPacks {
let packDir = packDirectory(name: pack.name)
guard let files = try? fileManager.contentsOfDirectory(at: packDir, includingPropertiesForKeys: nil) else { continue }
for file in files.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
guard file.pathExtension == "png" else { continue }
urls.append(file)
}
}
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")
}
}