Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-18 12:33:35 -06:00
parent 2b679b0167
commit fa3cb3e017
12 changed files with 160 additions and 54 deletions

View File

@ -0,0 +1,11 @@
{
"folders": [
{
"path": "."
},
{
"path": "../_Packages/Bedrock"
}
],
"settings": {}
}

View File

@ -8,7 +8,9 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69DC812F3C199C00592220 /* Bedrock */; }; EA69DC822F3C199C00592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69DC812F3C199C00592220 /* Bedrock */; };
EA69E9272F3D4B5700592220 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA69E9262F3D4B5700592220 /* Bedrock */; }; EA7568D32F4639EE006196BB /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA7568D22F4639EE006196BB /* Bedrock */; };
EA7568D52F463A28006196BB /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA7568D42F463A28006196BB /* Bedrock */; };
EA7568D72F463A2E006196BB /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA7568D62F463A2E006196BB /* Bedrock */; };
EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* Bedrock */; }; EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* Bedrock */; };
EAAE892A2F12DE110075BC8A /* BusinessCardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; EAAE892A2F12DE110075BC8A /* BusinessCardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = EACLIP0012F200000000002 /* BusinessCardClip.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; EACLIP0012F200000000001 /* BusinessCardClip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = EACLIP0012F200000000002 /* BusinessCardClip.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@ -150,6 +152,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EA7568D32F4639EE006196BB /* Bedrock in Frameworks */,
EA837E672F107D6800077F87 /* Bedrock in Frameworks */, EA837E672F107D6800077F87 /* Bedrock in Frameworks */,
EA69DC822F3C199C00592220 /* Bedrock in Frameworks */, EA69DC822F3C199C00592220 /* Bedrock in Frameworks */,
); );
@ -173,6 +176,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EA7568D72F463A2E006196BB /* Bedrock in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -180,7 +184,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EA69E9272F3D4B5700592220 /* Bedrock in Frameworks */, EA7568D52F463A28006196BB /* Bedrock in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -255,6 +259,7 @@
packageProductDependencies = ( packageProductDependencies = (
EA837E662F107D6800077F87 /* Bedrock */, EA837E662F107D6800077F87 /* Bedrock */,
EA69DC812F3C199C00592220 /* Bedrock */, EA69DC812F3C199C00592220 /* Bedrock */,
EA7568D22F4639EE006196BB /* Bedrock */,
); );
productName = BusinessCard; productName = BusinessCard;
productReference = EA8379232F105F2600077F87 /* Business Card.app */; productReference = EA8379232F105F2600077F87 /* Business Card.app */;
@ -323,6 +328,7 @@
); );
name = "BusinessCardWatch Watch App"; name = "BusinessCardWatch Watch App";
packageProductDependencies = ( packageProductDependencies = (
EA7568D62F463A2E006196BB /* Bedrock */,
); );
productName = "BusinessCardWatch Watch App"; productName = "BusinessCardWatch Watch App";
productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */; productReference = EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */;
@ -345,7 +351,7 @@
); );
name = BusinessCardClip; name = BusinessCardClip;
packageProductDependencies = ( packageProductDependencies = (
EA69E9262F3D4B5700592220 /* Bedrock */, EA7568D42F463A28006196BB /* Bedrock */,
); );
productName = BusinessCardClip; productName = BusinessCardClip;
productReference = EACLIP0012F200000000002 /* BusinessCardClip.app */; productReference = EACLIP0012F200000000002 /* BusinessCardClip.app */;
@ -392,7 +398,7 @@
mainGroup = EA83791A2F105F2600077F87; mainGroup = EA83791A2F105F2600077F87;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
EA69DC802F3C199C00592220 /* XCLocalSwiftPackageReference "../Bedrock" */, EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = EA8379242F105F2600077F87 /* Products */; productRefGroup = EA8379242F105F2600077F87 /* Products */;
@ -1001,9 +1007,9 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
EA69DC802F3C199C00592220 /* XCLocalSwiftPackageReference "../Bedrock" */ = { EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = ../Bedrock; relativePath = ../_Packages/Bedrock;
}; };
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
@ -1012,9 +1018,18 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Bedrock; productName = Bedrock;
}; };
EA69E9262F3D4B5700592220 /* Bedrock */ = { EA7568D22F4639EE006196BB /* Bedrock */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = EA69DC802F3C199C00592220 /* XCLocalSwiftPackageReference "../Bedrock" */; productName = Bedrock;
};
EA7568D42F463A28006196BB /* Bedrock */ = {
isa = XCSwiftPackageProductDependency;
package = EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */;
productName = Bedrock;
};
EA7568D62F463A2E006196BB /* Bedrock */ = {
isa = XCSwiftPackageProductDependency;
package = EA7568D12F4639EE006196BB /* XCLocalSwiftPackageReference "../_Packages/Bedrock" */;
productName = Bedrock; productName = Bedrock;
}; };
EA837E662F107D6800077F87 /* Bedrock */ = { EA837E662F107D6800077F87 /* Bedrock */ = {

View File

@ -53,7 +53,7 @@
<CommandLineArguments> <CommandLineArguments>
<CommandLineArgument <CommandLineArgument
argument = "--clip-debug=preview" argument = "--clip-debug=preview"
isEnabled = "YES"> isEnabled = "NO">
</CommandLineArgument> </CommandLineArgument>
<CommandLineArgument <CommandLineArgument
argument = "--clip-debug=loading" argument = "--clip-debug=loading"
@ -68,6 +68,13 @@
isEnabled = "NO"> isEnabled = "NO">
</CommandLineArgument> </CommandLineArgument>
</CommandLineArguments> </CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCAppClipURL"
value = "https://topdoglabs.com/bc?id=FE866C3C-EEBC-4A66-B176-11D0F01E28DF"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"

View File

@ -7,7 +7,7 @@
<key>BusinessCard.xcscheme_^#shared#^_</key> <key>BusinessCard.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>2</integer> <integer>1</integer>
</dict> </dict>
<key>BusinessCardClip.xcscheme_^#shared#^_</key> <key>BusinessCardClip.xcscheme_^#shared#^_</key>
<dict> <dict>
@ -17,7 +17,7 @@
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key> <key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>3</integer> <integer>2</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>

View File

@ -817,6 +817,10 @@
} }
} }
}, },
"Show first-run onboarding again on next app launch" : {
"comment" : "A description of the reset onboarding feature.",
"isCommentAutoGenerated" : true
},
"Social Media" : { "Social Media" : {
}, },

View File

@ -1,38 +1,38 @@
{ {
"colors" : [ "colors": [
{ {
"color" : { "color": {
"color-space" : "srgb", "color-space": "srgb",
"components" : { "components": {
"alpha" : "1.000", "alpha": "1.000",
"blue" : "0.860", "blue": "0.280",
"green" : "0.470", "green": "0.330",
"red" : "0.220" "red": "0.950"
} }
}, },
"idiom" : "universal" "idiom": "universal"
}, },
{ {
"appearances" : [ "appearances": [
{ {
"appearance" : "luminosity", "appearance": "luminosity",
"value" : "dark" "value": "dark"
} }
], ],
"color" : { "color": {
"color-space" : "srgb", "color-space": "srgb",
"components" : { "components": {
"alpha" : "1.000", "alpha": "1.000",
"blue" : "0.950", "blue": "0.280",
"green" : "0.650", "green": "0.330",
"red" : "0.350" "red": "0.950"
} }
}, },
"idiom" : "universal" "idiom": "universal"
} }
], ],
"info" : { "info": {
"author" : "xcode", "author": "xcode",
"version" : 1 "version": 1
} }
} }

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import AppIntents import AppIntents
import Bedrock
@main @main
struct BusinessCardClipApp: App { struct BusinessCardClipApp: App {
@ -37,30 +38,50 @@ struct BusinessCardClipApp: App {
#endif #endif
} }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
Design.debugLog("Clip: onContinueUserActivity fired, webpageURL=\(activity.webpageURL?.absoluteString ?? "nil")")
handleUserActivity(activity) handleUserActivity(activity)
} }
.onOpenURL { url in .onOpenURL { url in
Design.debugLog("Clip: onOpenURL fired, url=\(url.absoluteString)")
handleURL(url) handleURL(url)
} }
.task { .task {
Design.debugLog("Clip: .task running - checking launch sources")
#if DEBUG #if DEBUG
debugState = parseDebugStateArgument() if let dbg = parseDebugStateArgument() {
Design.debugLog("Clip: debug state from args: \(dbg.rawValue)")
debugState = dbg
return
}
#endif #endif
if let envURL = ProcessInfo.processInfo.environment["_XCAppClipURL"],
let url = URL(string: envURL) {
Design.debugLog("Clip: _XCAppClipURL from env: \(envURL)")
handleURL(url)
} else {
Design.debugLog("Clip: no _XCAppClipURL in env, no debug args. recordName=\(recordName ?? "nil")")
}
} }
} }
} }
private func handleUserActivity(_ activity: NSUserActivity) { private func handleUserActivity(_ activity: NSUserActivity) {
guard let url = activity.webpageURL else { return } Design.debugLog("Clip: handleUserActivity, webpageURL=\(activity.webpageURL?.absoluteString ?? "nil")")
guard let url = activity.webpageURL else {
Design.debugLog("Clip: handleUserActivity - no webpageURL, skipping")
return
}
handleURL(url) handleURL(url)
} }
private func handleURL(_ url: URL) { private func handleURL(_ url: URL) {
Design.debugLog("Clip: handleURL \(url.absoluteString)")
guard let id = extractRecordName(from: url) else { guard let id = extractRecordName(from: url) else {
Design.debugLog("Clip: extractRecordName failed for \(url.absoluteString)")
launchErrorMessage = ClipError.invalidRecord.localizedDescription launchErrorMessage = ClipError.invalidRecord.localizedDescription
return return
} }
Design.debugLog("Clip: extracted recordName=\(id), setting recordName")
launchErrorMessage = nil launchErrorMessage = nil
recordName = id recordName = id
} }

View File

@ -43,12 +43,12 @@ final class ClipCardStore {
} }
} }
/// Saves the currently loaded card to Contacts. /// Saves the currently loaded card to Contacts via CNContactStore.
/// Note: App Clips cannot use CNContactStore; use the share sheet flow instead.
func saveToContacts() async { func saveToContacts() async {
guard let snapshot else { return } guard let snapshot else { return }
do { do {
try await contactSave.saveContact(vCardData: snapshot.vCardData) try await contactSave.saveContact(vCardData: snapshot.vCardData)
// Best-effort cleanup so saved cards do not linger in public CloudKit.
try? await cloudKit.deleteSharedCard(recordName: snapshot.recordName) try? await cloudKit.deleteSharedCard(recordName: snapshot.recordName)
state = .saved state = .saved
} catch { } catch {
@ -56,6 +56,13 @@ final class ClipCardStore {
} }
} }
/// Best-effort cleanup after user shares the vCard (e.g. via share sheet).
/// App Clips use the share sheet instead of CNContactStore.
func cleanupAfterShare() async {
guard let snapshot else { return }
try? await cloudKit.deleteSharedCard(recordName: snapshot.recordName)
}
private func userMessage(for error: Error, fallback: ClipError) -> String { private func userMessage(for error: Error, fallback: ClipError) -> String {
if let clipError = error as? ClipError { if let clipError = error as? ClipError {
return clipError.localizedDescription return clipError.localizedDescription

View File

@ -16,7 +16,7 @@ struct ClipRootView: View {
ClipLoadingView() ClipLoadingView()
case .loaded(let snapshot): case .loaded(let snapshot):
ClipCardPreview(snapshot: snapshot) { ClipCardPreview(snapshot: snapshot) {
Task { await store.saveToContacts() } Task { await store.cleanupAfterShare() }
} }
case .saved: case .saved:
ClipSuccessView() ClipSuccessView()

View File

@ -9,24 +9,25 @@ struct ClipCardPreview: View {
} }
let snapshot: SharedCardSnapshot let snapshot: SharedCardSnapshot
/// Called when share sheet is dismissed; use for CloudKit cleanup.
let onSave: () -> Void let onSave: () -> Void
@State private var showShareSheet = false
var body: some View { var body: some View {
VStack(spacing: ClipDesign.Spacing.xLarge) { ScrollView(showsIndicators: false) {
Spacer(minLength: ClipDesign.Spacing.medium) VStack(spacing: ClipDesign.Spacing.xLarge) {
previewCard
.frame(maxWidth: ClipDesign.Size.previewMaxWidth)
previewCard VStack(spacing: ClipDesign.Spacing.medium) {
.frame(maxWidth: ClipDesign.Size.previewMaxWidth) // Add to Contacts - uses share sheet because App Clips cannot use CNContactStore
.padding(.horizontal, ClipDesign.Spacing.large)
VStack(spacing: ClipDesign.Spacing.medium) {
// Save button
ClipPrimaryButton( ClipPrimaryButton(
title: String(localized: "Save to Contacts"), title: String(localized: "Add to Contacts"),
systemImage: "person.crop.circle.badge.plus", systemImage: "person.crop.circle.badge.plus",
action: onSave action: { showShareSheet = true }
) )
.accessibilityLabel(Text("Save \(snapshot.displayName) to contacts")) .accessibilityLabel(Text("Add \(snapshot.displayName) to contacts"))
// Get full app prompt // Get full app prompt
Button { Button {
@ -36,10 +37,16 @@ struct ClipCardPreview: View {
.styled(.subheading) .styled(.subheading)
.foregroundStyle(Color.Clip.accent) .foregroundStyle(Color.Clip.accent)
} }
}
.padding(.horizontal, ClipDesign.Spacing.xLarge)
}
.padding(.horizontal, ClipDesign.Spacing.large)
}
.sheet(isPresented: $showShareSheet) {
ClipShareSheet(vCardData: snapshot.vCardData) {
showShareSheet = false
onSave()
} }
.padding(.horizontal, ClipDesign.Spacing.xLarge)
Spacer(minLength: ClipDesign.Spacing.large)
} }
} }

View File

@ -18,6 +18,7 @@ struct ClipPrimaryButton: View {
HStack(spacing: ClipDesign.Spacing.small) { HStack(spacing: ClipDesign.Spacing.small) {
if let systemImage { if let systemImage {
Image(systemName: systemImage) Image(systemName: systemImage)
.foregroundStyle(Color.Clip.background)
} }
Text(title) Text(title)
.styled(.headingEmphasis, emphasis: .custom(Color.Clip.background)) .styled(.headingEmphasis, emphasis: .custom(Color.Clip.background))

View File

@ -0,0 +1,33 @@
import SwiftUI
import UIKit
/// Presents a share sheet with a vCard file so the user can add to Contacts.
/// App Clips cannot use CNContactStore; the share sheet's "Add to Contacts" option works instead.
struct ClipShareSheet: UIViewControllerRepresentable {
let vCardData: String
let onDismiss: (() -> Void)?
init(vCardData: String, onDismiss: (() -> Void)? = nil) {
self.vCardData = vCardData
self.onDismiss = onDismiss
}
func makeUIViewController(context: Context) -> UIActivityViewController {
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("contact-\(UUID().uuidString).vcf")
let data = Data(vCardData.utf8)
try? data.write(to: tempURL)
let controller = UIActivityViewController(
activityItems: [tempURL],
applicationActivities: nil
)
controller.completionWithItemsHandler = { _, _, _, _ in
try? FileManager.default.removeItem(at: tempURL)
onDismiss?()
}
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}