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

@@ -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")
}
}

View File

@@ -1,10 +1,8 @@
import Messages
import SwiftUI
import UIKit
final class MessagesViewController: MSMessagesAppViewController {
private let stickerBrowser = StickerBrowserViewController()
private var addPackHostingController: UIHostingController<AddPackView>?
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
}
}

View File

@@ -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()
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.de.felixfoertsch.StickerCloner</string>
</array>
</dict>
</plist>

View File

@@ -65,9 +65,21 @@
path = "StickerCloner MessagesExtension";
sourceTree = "<group>";
};
3FD2D01C2F57098E00B76B50 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
/* 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;

View File

@@ -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">
<RemoteRunnable
runnableDebuggingMode = "2"
BundleIdentifier = "com.apple.MobileSMS"
RemotePath = "/Applications/MobileSMS.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3FD2CFFF2F57098D00B76B50"
BuildableName = "StickerCloner MessagesExtension.appex"
BlueprintName = "StickerCloner MessagesExtension"
BlueprintIdentifier = "3FD2CFF62F57098B00B76B50"
BuildableName = "StickerCloner.app"
BlueprintName = "StickerCloner"
ReferencedContainer = "container:StickerCloner.xcodeproj">
</BuildableReference>
</MacroExpansion>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -63,6 +57,16 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3FD2CFF62F57098B00B76B50"
BuildableName = "StickerCloner.app"
BlueprintName = "StickerCloner"
ReferencedContainer = "container:StickerCloner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

@@ -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
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.de.felixfoertsch.StickerCloner</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct StickerClonerApp: App {
var body: some Scene {
WindowGroup {
AddPackView()
}
}
}