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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
10
StickerCloner/StickerCloner/StickerCloner.entitlements
Normal file
10
StickerCloner/StickerCloner/StickerCloner.entitlements
Normal 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>
|
||||
10
StickerCloner/StickerCloner/StickerClonerApp.swift
Normal file
10
StickerCloner/StickerCloner/StickerClonerApp.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct StickerClonerApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
AddPackView()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user