WIP: local handoff implementation
Local work on the social.io handoff before merging the claude worktree branch. Includes the full per-spec Sources/Core/Design module (8 files), watchOS target under WatchApp/, Live Activity + widget extension, entitlements, scheme, and asset catalog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,19 +9,24 @@ final class AppViewModel {
|
||||
var focusedMessageRouteID: String?
|
||||
var searchText = ""
|
||||
var showUnreadOnly = false
|
||||
var laneFilter: Lane?
|
||||
var isComposing = false
|
||||
var composeDraft = ComposeDraft()
|
||||
var isCommandPalettePresented = false
|
||||
var composeDraft = ComposeDraft(from: "phil@social.io")
|
||||
var threads: [MailThread] = []
|
||||
var isLoading = false
|
||||
var isSending = false
|
||||
var errorMessage: String?
|
||||
var mailboxNavigationToken = UUID()
|
||||
var threadNavigationToken = UUID()
|
||||
let currentUser = MailPerson(name: "Phil Kunz", email: "phil@social.io")
|
||||
|
||||
private let service: MailServicing
|
||||
private let controlService: AppControlServicing
|
||||
private var pendingNavigationCommand: AppNavigationCommand?
|
||||
private var isListeningForBackendCommands = false
|
||||
@ObservationIgnored private var composeAutosaveTask: Task<Void, Never>?
|
||||
@ObservationIgnored private let autosaveKey = "sio.compose.autosave"
|
||||
|
||||
init(
|
||||
service: MailServicing = MockMailService(),
|
||||
@@ -29,6 +34,7 @@ final class AppViewModel {
|
||||
) {
|
||||
self.service = service
|
||||
self.controlService = controlService
|
||||
restoreAutosavedDraft()
|
||||
if let command = AppNavigationCommand.from(environment: ProcessInfo.processInfo.environment) {
|
||||
apply(command: command)
|
||||
}
|
||||
@@ -40,9 +46,9 @@ final class AppViewModel {
|
||||
}
|
||||
|
||||
var filteredThreads: [MailThread] {
|
||||
threads
|
||||
baseThreads(for: selectedMailbox)
|
||||
.filter { thread in
|
||||
selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox
|
||||
laneFilter == nil || thread.lane == laneFilter
|
||||
}
|
||||
.filter { thread in
|
||||
!showUnreadOnly || thread.isUnread
|
||||
@@ -56,18 +62,92 @@ final class AppViewModel {
|
||||
}
|
||||
|
||||
func threadCount(in mailbox: Mailbox) -> Int {
|
||||
threads.filter { thread in
|
||||
mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
|
||||
}
|
||||
.count
|
||||
baseThreads(for: mailbox).count
|
||||
}
|
||||
|
||||
func unreadCount(in mailbox: Mailbox) -> Int {
|
||||
threads.filter { thread in
|
||||
let matchesMailbox = mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
|
||||
return matchesMailbox && thread.isUnread
|
||||
baseThreads(for: mailbox)
|
||||
.filter { $0.isUnread }
|
||||
.count
|
||||
}
|
||||
|
||||
func laneCount(_ lane: Lane?, in mailbox: Mailbox? = nil) -> Int {
|
||||
baseThreads(for: mailbox ?? selectedMailbox)
|
||||
.filter { lane == nil || $0.lane == lane }
|
||||
.count
|
||||
}
|
||||
|
||||
func unreadCount(for lane: Lane?) -> Int {
|
||||
baseThreads(for: selectedMailbox)
|
||||
.filter { thread in
|
||||
thread.isUnread && (lane == nil || thread.lane == lane)
|
||||
}
|
||||
.count
|
||||
}
|
||||
|
||||
var screenerThreads: [MailThread] {
|
||||
baseThreads(for: .screener)
|
||||
.sorted { $0.lastUpdated > $1.lastUpdated }
|
||||
}
|
||||
|
||||
var searchResults: [MailThread] {
|
||||
threads
|
||||
.filter(matchesSearch)
|
||||
.sorted { lhs, rhs in
|
||||
let lhsScore = score(for: lhs)
|
||||
let rhsScore = score(for: rhs)
|
||||
if lhsScore == rhsScore {
|
||||
return lhs.lastUpdated > rhs.lastUpdated
|
||||
}
|
||||
|
||||
return lhsScore > rhsScore
|
||||
}
|
||||
}
|
||||
|
||||
var folderNames: [String] {
|
||||
Array(Set(threads.flatMap(\.tags)))
|
||||
.filter { !$0.isEmpty && !["Draft", "Launch", "Search", "System", "External", "Sent"].contains($0) }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
var topSearchResult: MailThread? {
|
||||
searchResults.first
|
||||
}
|
||||
|
||||
var remainingSearchResults: [MailThread] {
|
||||
Array(searchResults.dropFirst())
|
||||
}
|
||||
|
||||
var isSearchActive: Bool {
|
||||
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
func setLaneFilter(_ lane: Lane?) {
|
||||
laneFilter = lane
|
||||
reconcileSelectionForCurrentFilters()
|
||||
}
|
||||
|
||||
func cycleToNextLane() {
|
||||
let allFilters: [Lane?] = [nil] + Lane.allCases
|
||||
guard let currentIndex = allFilters.firstIndex(where: { $0 == laneFilter }) else {
|
||||
laneFilter = nil
|
||||
return
|
||||
}
|
||||
|
||||
laneFilter = allFilters[(currentIndex + 1) % allFilters.count]
|
||||
reconcileSelectionForCurrentFilters()
|
||||
}
|
||||
|
||||
func queueDraftAutosave() {
|
||||
let snapshot = composeDraft
|
||||
composeAutosaveTask?.cancel()
|
||||
composeAutosaveTask = Task { [autosaveKey] in
|
||||
try? await Task.sleep(for: .milliseconds(800))
|
||||
guard !Task.isCancelled else { return }
|
||||
if let data = try? JSONEncoder().encode(snapshot) {
|
||||
UserDefaults.standard.set(data, forKey: autosaveKey)
|
||||
}
|
||||
}
|
||||
.count
|
||||
}
|
||||
|
||||
func load() async {
|
||||
@@ -85,6 +165,8 @@ final class AppViewModel {
|
||||
} else {
|
||||
reconcileSelectionForCurrentFilters()
|
||||
}
|
||||
|
||||
await refreshLiveActivity()
|
||||
} catch {
|
||||
errorMessage = "Unable to load mail."
|
||||
}
|
||||
@@ -132,11 +214,120 @@ final class AppViewModel {
|
||||
}
|
||||
|
||||
func startCompose() {
|
||||
composeDraft = ComposeDraft()
|
||||
if composeDraft.isEmpty {
|
||||
restoreAutosavedDraft()
|
||||
}
|
||||
|
||||
if composeDraft.isEmpty {
|
||||
composeDraft = ComposeDraft(from: currentUser.email)
|
||||
} else if composeDraft.from.isEmpty {
|
||||
composeDraft.from = currentUser.email
|
||||
}
|
||||
|
||||
focusedMessageRouteID = nil
|
||||
isComposing = true
|
||||
}
|
||||
|
||||
func dismissCompose() {
|
||||
isComposing = false
|
||||
}
|
||||
|
||||
func discardCompose() {
|
||||
composeAutosaveTask?.cancel()
|
||||
UserDefaults.standard.removeObject(forKey: autosaveKey)
|
||||
composeDraft = ComposeDraft(from: currentUser.email)
|
||||
isComposing = false
|
||||
}
|
||||
|
||||
func startReply(to threadID: MailThread.ID, replyAll: Bool = false, forward: Bool = false) {
|
||||
guard let thread = thread(withID: threadID) else { return }
|
||||
|
||||
let latestMessage = thread.latestMessage
|
||||
let recipients: [MailPerson]
|
||||
if forward {
|
||||
recipients = []
|
||||
} else if replyAll {
|
||||
recipients = thread.participants.filter { $0.email != currentUser.email }
|
||||
} else if let latestMessage {
|
||||
recipients = [latestMessage.sender]
|
||||
} else {
|
||||
recipients = thread.participants.filter { $0.email != currentUser.email }
|
||||
}
|
||||
|
||||
let replyPrefix = forward ? "Fwd:" : "Re:"
|
||||
let quotedBody = latestMessage?.body
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.prefix(4)
|
||||
.map { "> \($0)" }
|
||||
.joined(separator: "\n") ?? ""
|
||||
|
||||
composeDraft = ComposeDraft(
|
||||
to: recipients.map(\.email).joined(separator: ", "),
|
||||
cc: "",
|
||||
from: currentUser.email,
|
||||
subject: thread.subject.hasPrefix(replyPrefix) ? thread.subject : "\(replyPrefix) \(thread.subject)",
|
||||
body: forward || quotedBody.isEmpty ? "" : "\n\n\(quotedBody)"
|
||||
)
|
||||
focusedMessageRouteID = nil
|
||||
isComposing = true
|
||||
}
|
||||
|
||||
func moveThread(withID threadID: MailThread.ID, to mailbox: Mailbox) {
|
||||
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
|
||||
threads[index].mailbox = mailbox
|
||||
if mailbox == .trash {
|
||||
threads[index].isUnread = false
|
||||
}
|
||||
reconcileSelectionForCurrentFilters()
|
||||
}
|
||||
|
||||
func sendInlineReply(_ body: String, in threadID: MailThread.ID) {
|
||||
let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedBody.isEmpty,
|
||||
let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
|
||||
|
||||
let recipients = threads[index].participants.filter { $0.email != currentUser.email }
|
||||
threads[index].messages.append(
|
||||
MailMessage(
|
||||
routeID: "\(threads[index].routeID)-reply-\(threads[index].messages.count + 1)",
|
||||
sender: currentUser,
|
||||
recipients: recipients,
|
||||
sentAt: .now,
|
||||
body: trimmedBody
|
||||
)
|
||||
)
|
||||
threads[index].isUnread = false
|
||||
openThread(withID: threadID)
|
||||
}
|
||||
|
||||
func snoozeThread(withID threadID: MailThread.ID) {
|
||||
moveThread(withID: threadID, to: .snoozed)
|
||||
}
|
||||
|
||||
func applyScreenerDecision(_ decision: ScreenDecision, to threadID: MailThread.ID) {
|
||||
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
|
||||
|
||||
switch decision {
|
||||
case .approve:
|
||||
threads[index].mailbox = .inbox
|
||||
threads[index].isScreeningCandidate = false
|
||||
case .block:
|
||||
threads[index].mailbox = .trash
|
||||
threads[index].isUnread = false
|
||||
threads[index].isScreeningCandidate = false
|
||||
case .sendToPaper:
|
||||
threads[index].mailbox = .inbox
|
||||
threads[index].lane = .paper
|
||||
threads[index].isScreeningCandidate = false
|
||||
}
|
||||
|
||||
reconcileSelectionForCurrentFilters()
|
||||
|
||||
Task {
|
||||
try? await service.screen(threadID: threadID, as: decision)
|
||||
}
|
||||
}
|
||||
|
||||
func openThread(withID threadID: MailThread.ID, focusedMessageRouteID: String? = nil) {
|
||||
guard let thread = thread(withID: threadID) else { return }
|
||||
|
||||
@@ -203,7 +394,7 @@ final class AppViewModel {
|
||||
|
||||
case let .compose(draft):
|
||||
focusedMessageRouteID = nil
|
||||
composeDraft = draft
|
||||
composeDraft = normalizedDraft(draft)
|
||||
isComposing = true
|
||||
}
|
||||
}
|
||||
@@ -221,6 +412,8 @@ final class AppViewModel {
|
||||
selectedMailbox = .sent
|
||||
openThread(withID: sentThread.id)
|
||||
isComposing = false
|
||||
UserDefaults.standard.removeObject(forKey: autosaveKey)
|
||||
composeDraft = ComposeDraft(from: currentUser.email)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = "Unable to send message."
|
||||
@@ -236,7 +429,11 @@ final class AppViewModel {
|
||||
thread.subject,
|
||||
thread.previewText,
|
||||
thread.participants.map(\.name).joined(separator: " "),
|
||||
thread.tags.joined(separator: " ")
|
||||
thread.tags.joined(separator: " "),
|
||||
thread.lane.label,
|
||||
thread.summary?.joined(separator: " ") ?? "",
|
||||
thread.messages.map(\.body).joined(separator: " "),
|
||||
thread.messages.flatMap(\.attachments).map(\.name).joined(separator: " ")
|
||||
]
|
||||
.joined(separator: " ")
|
||||
.localizedLowercase
|
||||
@@ -253,6 +450,57 @@ final class AppViewModel {
|
||||
focusedMessageRouteID = nil
|
||||
}
|
||||
|
||||
private func baseThreads(for mailbox: Mailbox) -> [MailThread] {
|
||||
switch mailbox {
|
||||
case .starred:
|
||||
return threads.filter(\.isStarred)
|
||||
case .screener:
|
||||
return threads.filter(\.isScreeningCandidate)
|
||||
default:
|
||||
return threads.filter { $0.mailbox == mailbox }
|
||||
}
|
||||
}
|
||||
|
||||
private func score(for thread: MailThread) -> Int {
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).localizedLowercase
|
||||
guard !query.isEmpty else { return 0 }
|
||||
|
||||
var score = 0
|
||||
if thread.subject.localizedLowercase.contains(query) { score += 4 }
|
||||
if thread.participants.map(\.name).joined(separator: " ").localizedLowercase.contains(query) { score += 3 }
|
||||
if thread.previewText.localizedLowercase.contains(query) { score += 2 }
|
||||
if thread.messages.flatMap(\.attachments).map(\.name).joined(separator: " ").localizedLowercase.contains(query) { score += 1 }
|
||||
return score
|
||||
}
|
||||
|
||||
private func normalizedDraft(_ draft: ComposeDraft) -> ComposeDraft {
|
||||
var normalized = draft
|
||||
if normalized.from.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
normalized.from = currentUser.email
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func restoreAutosavedDraft() {
|
||||
guard let data = UserDefaults.standard.data(forKey: autosaveKey),
|
||||
let draft = try? JSONDecoder().decode(ComposeDraft.self, from: data) else {
|
||||
if composeDraft.from.isEmpty {
|
||||
composeDraft = ComposeDraft(from: currentUser.email)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
composeDraft = normalizedDraft(draft)
|
||||
}
|
||||
|
||||
private func refreshLiveActivity() async {
|
||||
#if os(iOS)
|
||||
if let thread = threads.first(where: { $0.mailbox == .inbox && $0.isUnread }) {
|
||||
await MailNotificationActivityController.startIfNeeded(with: thread)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func reconcileSelectionForCurrentFilters() {
|
||||
if let selectedThreadID,
|
||||
filteredThreads.contains(where: { $0.id == selectedThreadID }) {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#if os(iOS) && canImport(ActivityKit) && canImport(AppIntents) && canImport(WidgetKit)
|
||||
import ActivityKit
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct MailNotificationActivityAttributes: ActivityAttributes {
|
||||
struct ContentState: Codable, Hashable {
|
||||
var sender: String
|
||||
var initials: String
|
||||
var subject: String
|
||||
var preview: String
|
||||
var route: String
|
||||
}
|
||||
|
||||
var threadRouteID: String
|
||||
}
|
||||
|
||||
struct OpenMailLiveActivityIntent: LiveActivityIntent {
|
||||
static var title: LocalizedStringResource = "Open"
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Route")
|
||||
var route: String
|
||||
|
||||
init() {
|
||||
route = "socialio://mailbox/inbox"
|
||||
}
|
||||
|
||||
init(route: String) {
|
||||
self.route = route
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
.result()
|
||||
}
|
||||
}
|
||||
|
||||
struct SnoozeMailLiveActivityIntent: LiveActivityIntent {
|
||||
static var title: LocalizedStringResource = "Snooze"
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
.result()
|
||||
}
|
||||
}
|
||||
|
||||
struct MailNotificationLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: MailNotificationActivityAttributes.self) { context in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
header(context: context)
|
||||
|
||||
Text(context.state.preview)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button(intent: OpenMailLiveActivityIntent(route: context.state.route)) {
|
||||
Text("Open")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button(intent: SnoozeMailLiveActivityIntent()) {
|
||||
Text("Snooze")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Image(systemName: "envelope.fill")
|
||||
.foregroundStyle(SIO.tint)
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(context.state.initials)
|
||||
.font(.headline.weight(.semibold))
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
header(context: context)
|
||||
Text(context.state.preview)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
HStack(spacing: 10) {
|
||||
Button(intent: OpenMailLiveActivityIntent(route: context.state.route)) {
|
||||
Text("Open")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button(intent: SnoozeMailLiveActivityIntent()) {
|
||||
Text("Snooze")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "envelope.fill")
|
||||
} compactTrailing: {
|
||||
Text(context.state.initials)
|
||||
.font(.caption2.weight(.bold))
|
||||
} minimal: {
|
||||
Image(systemName: "envelope.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func header(context: ActivityViewContext<MailNotificationActivityAttributes>) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(context.state.initials)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(SIO.tint)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(SIO.tint.opacity(0.12), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(context.state.sender)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(context.state.subject)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !WIDGET_EXTENSION
|
||||
enum MailNotificationActivityController {
|
||||
static func startIfNeeded(with thread: MailThread) async {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
|
||||
let attributes = MailNotificationActivityAttributes(threadRouteID: thread.routeID)
|
||||
let state = MailNotificationActivityAttributes.ContentState(
|
||||
sender: thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "social.io",
|
||||
initials: initials(from: thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "SI"),
|
||||
subject: thread.subject,
|
||||
preview: thread.previewText,
|
||||
route: "socialio://open?thread=\(thread.routeID)"
|
||||
)
|
||||
|
||||
if let existing = Activity<MailNotificationActivityAttributes>.activities.first {
|
||||
await existing.update(ActivityContent(state: state, staleDate: nil))
|
||||
return
|
||||
}
|
||||
|
||||
_ = try? Activity<MailNotificationActivityAttributes>.request(
|
||||
attributes: attributes,
|
||||
content: ActivityContent(state: state, staleDate: nil)
|
||||
)
|
||||
}
|
||||
|
||||
private static func initials(from name: String) -> String {
|
||||
String(name.split(separator: " ").prefix(2).compactMap { $0.first }).uppercased()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,13 @@
|
||||
#if os(iOS) && canImport(WidgetKit) && canImport(ActivityKit) && canImport(AppIntents)
|
||||
import ActivityKit
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct SocialIOWidgetsExtension: WidgetBundle {
|
||||
var body: some Widget {
|
||||
MailNotificationLiveActivity()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -3,17 +3,36 @@ import SwiftUI
|
||||
@main
|
||||
struct SocialIOApp: App {
|
||||
@State private var model = AppViewModel()
|
||||
@AppStorage("sio.theme") private var themeRawValue = ThemePreference.system.rawValue
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MailRootView(model: model)
|
||||
.tint(MailTheme.accent)
|
||||
.onOpenURL { url in
|
||||
model.apply(url: url)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
WindowGroup {
|
||||
rootView
|
||||
}
|
||||
.defaultSize(width: 1440, height: 900)
|
||||
|
||||
Settings {
|
||||
AppearanceSettingsView()
|
||||
.frame(width: 420, height: 300)
|
||||
}
|
||||
#else
|
||||
WindowGroup {
|
||||
rootView
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var themePreference: ThemePreference {
|
||||
ThemePreference(rawValue: themeRawValue) ?? .system
|
||||
}
|
||||
|
||||
private var rootView: some View {
|
||||
MailRootView(model: model)
|
||||
.tint(SIO.tint)
|
||||
.preferredColorScheme(themePreference.colorScheme)
|
||||
.onOpenURL { url in
|
||||
model.apply(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>social.io Widgets</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>$(IPHONEOS_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user