From 6742e63105e17d1d9cb6869c47cbd70a7ed49ddc Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 14 Jan 2026 08:51:34 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- README.md | 8 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../ContentView.swift | 45 ++ .../Models/UserProfile.swift | 15 + .../SecureStorageSampleApp.swift | 21 + .../Services/WatchConnectivityService.swift | 59 +++ .../SecureStorageSample_Watch_AppTests.swift | 17 + ...SecureStorageSample_Watch_AppUITests.swift | 41 ++ ...geSample_Watch_AppUITestsLaunchTests.swift | 33 ++ .../project.pbxproj | 413 ++++++++++++++++++ .../xcschemes/xcschememanagement.plist | 17 +- SecureStorgageSample/Models/UserProfile.swift | 15 + .../SecureStorgageSampleApp.swift | 4 + .../Services/WatchConnectivityService.swift | 35 ++ SecureStorgageSample/StorageKeys.swift | 17 +- .../Views/FileSystemDemo.swift | 29 +- 18 files changed, 778 insertions(+), 21 deletions(-) create mode 100644 SecureStorageSample Watch App/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 SecureStorageSample Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 SecureStorageSample Watch App/Assets.xcassets/Contents.json create mode 100644 SecureStorageSample Watch App/ContentView.swift create mode 100644 SecureStorageSample Watch App/Models/UserProfile.swift create mode 100644 SecureStorageSample Watch App/SecureStorageSampleApp.swift create mode 100644 SecureStorageSample Watch App/Services/WatchConnectivityService.swift create mode 100644 SecureStorageSample Watch AppTests/SecureStorageSample_Watch_AppTests.swift create mode 100644 SecureStorageSample Watch AppUITests/SecureStorageSample_Watch_AppUITests.swift create mode 100644 SecureStorageSample Watch AppUITests/SecureStorageSample_Watch_AppUITestsLaunchTests.swift create mode 100644 SecureStorgageSample/Models/UserProfile.swift create mode 100644 SecureStorgageSample/Services/WatchConnectivityService.swift diff --git a/README.md b/README.md index 6b35bb8..b9802d0 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,12 @@ This app provides interactive demos for all LocalData storage options: | **Encrypted** | Encrypted logs (AES or ChaCha20) | Encrypted File System | | **Sync** | Platform availability & sync policies | Multiple | +The project also includes a watchOS companion app target for watch-specific demos. + ## Requirements - iOS 17.0+ +- watchOS 10.0+ (companion app target) - Xcode 15+ ## Getting Started @@ -38,6 +41,11 @@ SecureStorgageSample/ ├── FileSystemDemo.swift ├── EncryptedStorageDemo.swift └── PlatformSyncDemo.swift +SecureStorageSample Watch App/ +├── SecureStorageSampleApp.swift +├── ContentView.swift +└── Services/ + └── WatchConnectivityService.swift ``` ## Storage Key Examples diff --git a/SecureStorageSample Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/SecureStorageSample Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/SecureStorageSample Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SecureStorageSample Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/SecureStorageSample Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/SecureStorageSample Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SecureStorageSample Watch App/Assets.xcassets/Contents.json b/SecureStorageSample Watch App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SecureStorageSample Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SecureStorageSample Watch App/ContentView.swift b/SecureStorageSample Watch App/ContentView.swift new file mode 100644 index 0000000..d280ae9 --- /dev/null +++ b/SecureStorageSample Watch App/ContentView.swift @@ -0,0 +1,45 @@ +// +// ContentView.swift +// SecureStorageSample Watch App +// +// Created by Matt Bruce on 1/14/26. +// + +import SwiftUI + +struct ContentView: View { + @State private var connectivity = WatchConnectivityService.shared + + var body: some View { + VStack { + if let profile = connectivity.profile { + Text(profile.name) + .bold() + Text(profile.email) + .font(.caption) + .foregroundStyle(.secondary) + Text("Age: \(profile.ageDescription)") + .font(.caption2) + .foregroundStyle(.secondary) + Text(profile.createdAt, format: .dateTime) + .font(.caption2) + .foregroundStyle(.secondary) + } else { + Text("No profile synced yet") + .font(.caption) + .foregroundStyle(.secondary) + } + + if !connectivity.statusMessage.isEmpty { + Text(connectivity.statusMessage) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/SecureStorageSample Watch App/Models/UserProfile.swift b/SecureStorageSample Watch App/Models/UserProfile.swift new file mode 100644 index 0000000..ca2c1c5 --- /dev/null +++ b/SecureStorageSample Watch App/Models/UserProfile.swift @@ -0,0 +1,15 @@ +import Foundation + +nonisolated(unsafe) +struct UserProfile: Codable, Sendable { + static let storageKeyName = "user_profile.json" + + let name: String + let email: String + let age: Int? + let createdAt: Date + + var ageDescription: String { + age.map(String.init) ?? "n/a" + } +} diff --git a/SecureStorageSample Watch App/SecureStorageSampleApp.swift b/SecureStorageSample Watch App/SecureStorageSampleApp.swift new file mode 100644 index 0000000..3ae4cfe --- /dev/null +++ b/SecureStorageSample Watch App/SecureStorageSampleApp.swift @@ -0,0 +1,21 @@ +// +// SecureStorageSampleApp.swift +// SecureStorageSample Watch App +// +// Created by Matt Bruce on 1/14/26. +// + +import SwiftUI + +@main +struct SecureStorageSample_Watch_AppApp: App { + init() { + _ = WatchConnectivityService.shared + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/SecureStorageSample Watch App/Services/WatchConnectivityService.swift b/SecureStorageSample Watch App/Services/WatchConnectivityService.swift new file mode 100644 index 0000000..6a3eff1 --- /dev/null +++ b/SecureStorageSample Watch App/Services/WatchConnectivityService.swift @@ -0,0 +1,59 @@ +import Foundation +import Observation +import WatchConnectivity + +@MainActor +@Observable +final class WatchConnectivityService: NSObject, WCSessionDelegate { + static let shared = WatchConnectivityService() + + private(set) var profile: UserProfile? + private(set) var statusMessage: String = "" + + private override init() { + super.init() + activateIfSupported() + loadCurrentContext() + } + + private func activateIfSupported() { + guard WCSession.isSupported() else { + statusMessage = "WatchConnectivity not supported" + return + } + let session = WCSession.default + session.delegate = self + session.activate() + } + + private func loadCurrentContext() { + guard WCSession.isSupported() else { return } + updateProfile(from: WCSession.default.applicationContext) + } + + private func updateProfile(from context: [String: Any]) { + guard let data = context[UserProfile.storageKeyName] as? Data else { + statusMessage = "No profile received" + return + } + + do { + profile = try JSONDecoder().decode(UserProfile.self, from: data) + statusMessage = "Profile synced" + } catch { + statusMessage = "Failed to decode profile" + } + } + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + loadCurrentContext() + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + updateProfile(from: applicationContext) + } +} diff --git a/SecureStorageSample Watch AppTests/SecureStorageSample_Watch_AppTests.swift b/SecureStorageSample Watch AppTests/SecureStorageSample_Watch_AppTests.swift new file mode 100644 index 0000000..3e349e0 --- /dev/null +++ b/SecureStorageSample Watch AppTests/SecureStorageSample_Watch_AppTests.swift @@ -0,0 +1,17 @@ +// +// SecureStorageSample_Watch_AppTests.swift +// SecureStorageSample Watch AppTests +// +// Created by Matt Bruce on 1/14/26. +// + +import Testing +@testable import SecureStorageSample_Watch_App + +struct SecureStorageSample_Watch_AppTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/SecureStorageSample Watch AppUITests/SecureStorageSample_Watch_AppUITests.swift b/SecureStorageSample Watch AppUITests/SecureStorageSample_Watch_AppUITests.swift new file mode 100644 index 0000000..3163c4e --- /dev/null +++ b/SecureStorageSample Watch AppUITests/SecureStorageSample_Watch_AppUITests.swift @@ -0,0 +1,41 @@ +// +// SecureStorageSample_Watch_AppUITests.swift +// SecureStorageSample Watch AppUITests +// +// Created by Matt Bruce on 1/14/26. +// + +import XCTest + +final class SecureStorageSample_Watch_AppUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/SecureStorageSample Watch AppUITests/SecureStorageSample_Watch_AppUITestsLaunchTests.swift b/SecureStorageSample Watch AppUITests/SecureStorageSample_Watch_AppUITestsLaunchTests.swift new file mode 100644 index 0000000..ec080a3 --- /dev/null +++ b/SecureStorageSample Watch AppUITests/SecureStorageSample_Watch_AppUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// SecureStorageSample_Watch_AppUITestsLaunchTests.swift +// SecureStorageSample Watch AppUITests +// +// Created by Matt Bruce on 1/14/26. +// + +import XCTest + +final class SecureStorageSample_Watch_AppUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/SecureStorgageSample.xcodeproj/project.pbxproj b/SecureStorgageSample.xcodeproj/project.pbxproj index bc8f3fb..9eb6695 100644 --- a/SecureStorgageSample.xcodeproj/project.pbxproj +++ b/SecureStorgageSample.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ EA179D562F17379800B1D54A /* LocalData in Frameworks */ = {isa = PBXBuildFile; productRef = EA179D552F17379800B1D54A /* LocalData */; }; + EA65D70D2F17DDEB00C48466 /* SecureStorageSample Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = EA65D6E52F17DD6700C48466 /* SecureStorageSample Watch App.app */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -25,12 +26,50 @@ remoteGlobalIDString = EA179D002F1722BB00B1D54A; remoteInfo = SecureStorgageSample; }; + EA65D6F22F17DD6800C48466 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA179CF92F1722BB00B1D54A /* Project object */; + proxyType = 1; + remoteGlobalIDString = EA65D6E42F17DD6700C48466; + remoteInfo = "SecureStorageSample Watch App"; + }; + EA65D6FC2F17DD6800C48466 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA179CF92F1722BB00B1D54A /* Project object */; + proxyType = 1; + remoteGlobalIDString = EA65D6E42F17DD6700C48466; + remoteInfo = "SecureStorageSample Watch App"; + }; + EA65D70E2F17DDEB00C48466 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA179CF92F1722BB00B1D54A /* Project object */; + proxyType = 1; + remoteGlobalIDString = EA65D6E42F17DD6700C48466; + remoteInfo = "SecureStorageSample Watch App"; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + EA179D312F1722BC00B1D54A /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + EA65D70D2F17DDEB00C48466 /* SecureStorageSample Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ EA179D012F1722BB00B1D54A /* SecureStorgageSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SecureStorgageSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; EA179D0E2F1722BC00B1D54A /* SecureStorgageSampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SecureStorgageSampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EA179D182F1722BC00B1D54A /* SecureStorgageSampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SecureStorgageSampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + EA65D6E52F17DD6700C48466 /* SecureStorageSample Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SecureStorageSample Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + EA65D6F12F17DD6800C48466 /* SecureStorageSample Watch AppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SecureStorageSample Watch AppTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + EA65D6FB2F17DD6800C48466 /* SecureStorageSample Watch AppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SecureStorageSample Watch AppUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -49,6 +88,21 @@ path = SecureStorgageSampleUITests; sourceTree = ""; }; + EA65D6E62F17DD6700C48466 /* SecureStorageSample Watch App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "SecureStorageSample Watch App"; + sourceTree = ""; + }; + EA65D6F42F17DD6800C48466 /* SecureStorageSample Watch AppTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "SecureStorageSample Watch AppTests"; + sourceTree = ""; + }; + EA65D6FE2F17DD6800C48466 /* SecureStorageSample Watch AppUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "SecureStorageSample Watch AppUITests"; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -74,6 +128,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EA65D6E22F17DD6700C48466 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA65D6EE2F17DD6800C48466 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA65D6F82F17DD6800C48466 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -83,6 +158,10 @@ EA179D032F1722BB00B1D54A /* SecureStorgageSample */, EA179D112F1722BC00B1D54A /* SecureStorgageSampleTests */, EA179D1B2F1722BC00B1D54A /* SecureStorgageSampleUITests */, + EA65D6E62F17DD6700C48466 /* SecureStorageSample Watch App */, + EA65D6F42F17DD6800C48466 /* SecureStorageSample Watch AppTests */, + EA65D6FE2F17DD6800C48466 /* SecureStorageSample Watch AppUITests */, + EA65D70C2F17DDEB00C48466 /* Frameworks */, EA179D022F1722BB00B1D54A /* Products */, ); sourceTree = ""; @@ -93,10 +172,20 @@ EA179D012F1722BB00B1D54A /* SecureStorgageSample.app */, EA179D0E2F1722BC00B1D54A /* SecureStorgageSampleTests.xctest */, EA179D182F1722BC00B1D54A /* SecureStorgageSampleUITests.xctest */, + EA65D6E52F17DD6700C48466 /* SecureStorageSample Watch App.app */, + EA65D6F12F17DD6800C48466 /* SecureStorageSample Watch AppTests.xctest */, + EA65D6FB2F17DD6800C48466 /* SecureStorageSample Watch AppUITests.xctest */, ); name = Products; sourceTree = ""; }; + EA65D70C2F17DDEB00C48466 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -107,10 +196,12 @@ EA179CFD2F1722BB00B1D54A /* Sources */, EA179CFE2F1722BB00B1D54A /* Frameworks */, EA179CFF2F1722BB00B1D54A /* Resources */, + EA179D312F1722BC00B1D54A /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( + EA65D70F2F17DDEB00C48466 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( EA179D032F1722BB00B1D54A /* SecureStorgageSample */, @@ -169,6 +260,74 @@ productReference = EA179D182F1722BC00B1D54A /* SecureStorgageSampleUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + EA65D6E42F17DD6700C48466 /* SecureStorageSample Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA65D7032F17DD6800C48466 /* Build configuration list for PBXNativeTarget "SecureStorageSample Watch App" */; + buildPhases = ( + EA65D6E12F17DD6700C48466 /* Sources */, + EA65D6E22F17DD6700C48466 /* Frameworks */, + EA65D6E32F17DD6700C48466 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + EA65D6E62F17DD6700C48466 /* SecureStorageSample Watch App */, + ); + name = "SecureStorageSample Watch App"; + packageProductDependencies = ( + ); + productName = "SecureStorageSample Watch App"; + productReference = EA65D6E52F17DD6700C48466 /* SecureStorageSample Watch App.app */; + productType = "com.apple.product-type.application"; + }; + EA65D6F02F17DD6800C48466 /* SecureStorageSample Watch AppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA65D7062F17DD6800C48466 /* Build configuration list for PBXNativeTarget "SecureStorageSample Watch AppTests" */; + buildPhases = ( + EA65D6ED2F17DD6800C48466 /* Sources */, + EA65D6EE2F17DD6800C48466 /* Frameworks */, + EA65D6EF2F17DD6800C48466 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EA65D6F32F17DD6800C48466 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + EA65D6F42F17DD6800C48466 /* SecureStorageSample Watch AppTests */, + ); + name = "SecureStorageSample Watch AppTests"; + packageProductDependencies = ( + ); + productName = "SecureStorageSample Watch AppTests"; + productReference = EA65D6F12F17DD6800C48466 /* SecureStorageSample Watch AppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + EA65D6FA2F17DD6800C48466 /* SecureStorageSample Watch AppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA65D7092F17DD6800C48466 /* Build configuration list for PBXNativeTarget "SecureStorageSample Watch AppUITests" */; + buildPhases = ( + EA65D6F72F17DD6800C48466 /* Sources */, + EA65D6F82F17DD6800C48466 /* Frameworks */, + EA65D6F92F17DD6800C48466 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EA65D6FD2F17DD6800C48466 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + EA65D6FE2F17DD6800C48466 /* SecureStorageSample Watch AppUITests */, + ); + name = "SecureStorageSample Watch AppUITests"; + packageProductDependencies = ( + ); + productName = "SecureStorageSample Watch AppUITests"; + productReference = EA65D6FB2F17DD6800C48466 /* SecureStorageSample Watch AppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -190,6 +349,17 @@ CreatedOnToolsVersion = 26.0; TestTargetID = EA179D002F1722BB00B1D54A; }; + EA65D6E42F17DD6700C48466 = { + CreatedOnToolsVersion = 26.0; + }; + EA65D6F02F17DD6800C48466 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = EA65D6E42F17DD6700C48466; + }; + EA65D6FA2F17DD6800C48466 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = EA65D6E42F17DD6700C48466; + }; }; }; buildConfigurationList = EA179CFC2F1722BB00B1D54A /* Build configuration list for PBXProject "SecureStorgageSample" */; @@ -212,6 +382,9 @@ EA179D002F1722BB00B1D54A /* SecureStorgageSample */, EA179D0D2F1722BC00B1D54A /* SecureStorgageSampleTests */, EA179D172F1722BC00B1D54A /* SecureStorgageSampleUITests */, + EA65D6E42F17DD6700C48466 /* SecureStorageSample Watch App */, + EA65D6F02F17DD6800C48466 /* SecureStorageSample Watch AppTests */, + EA65D6FA2F17DD6800C48466 /* SecureStorageSample Watch AppUITests */, ); }; /* End PBXProject section */ @@ -238,6 +411,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EA65D6E32F17DD6700C48466 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA65D6EF2F17DD6800C48466 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA65D6F92F17DD6800C48466 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -262,6 +456,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EA65D6E12F17DD6700C48466 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA65D6ED2F17DD6800C48466 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA65D6F72F17DD6800C48466 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -275,6 +490,21 @@ target = EA179D002F1722BB00B1D54A /* SecureStorgageSample */; targetProxy = EA179D192F1722BC00B1D54A /* PBXContainerItemProxy */; }; + EA65D6F32F17DD6800C48466 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EA65D6E42F17DD6700C48466 /* SecureStorageSample Watch App */; + targetProxy = EA65D6F22F17DD6800C48466 /* PBXContainerItemProxy */; + }; + EA65D6FD2F17DD6800C48466 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EA65D6E42F17DD6700C48466 /* SecureStorageSample Watch App */; + targetProxy = EA65D6FC2F17DD6800C48466 /* PBXContainerItemProxy */; + }; + EA65D70F2F17DDEB00C48466 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EA65D6E42F17DD6700C48466 /* SecureStorageSample Watch App */; + targetProxy = EA65D70E2F17DDEB00C48466 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -543,6 +773,162 @@ }; name = Release; }; + EA65D7042F17DD6800C48466 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = SecureStorageSample; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.mbrucedogs.SecureStorgageSample; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SecureStorgageSample.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + EA65D7052F17DD6800C48466 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = SecureStorageSample; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.mbrucedogs.SecureStorgageSample; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SecureStorgageSample.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + EA65D7072F17DD6800C48466 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.mbrucedogs.SecureStorageSample-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SecureStorageSample Watch App.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SecureStorageSample Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + EA65D7082F17DD6800C48466 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.mbrucedogs.SecureStorageSample-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SecureStorageSample Watch App.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SecureStorageSample Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + EA65D70A2F17DD6800C48466 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.mbrucedogs.SecureStorageSample-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = "SecureStorageSample Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + EA65D70B2F17DD6800C48466 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.mbrucedogs.SecureStorageSample-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = "SecureStorageSample Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -582,6 +968,33 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + EA65D7032F17DD6800C48466 /* Build configuration list for PBXNativeTarget "SecureStorageSample Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA65D7042F17DD6800C48466 /* Debug */, + EA65D7052F17DD6800C48466 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA65D7062F17DD6800C48466 /* Build configuration list for PBXNativeTarget "SecureStorageSample Watch AppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA65D7072F17DD6800C48466 /* Debug */, + EA65D7082F17DD6800C48466 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA65D7092F17DD6800C48466 /* Build configuration list for PBXNativeTarget "SecureStorageSample Watch AppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA65D70A2F17DD6800C48466 /* Debug */, + EA65D70B2F17DD6800C48466 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/SecureStorgageSample.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/SecureStorgageSample.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index c36b9b7..7564a6e 100644 --- a/SecureStorgageSample.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/SecureStorgageSample.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,10 +4,25 @@ SchemeUserState + SecureStorageSample Watch App.xcscheme_^#shared#^_ + + orderHint + 2 + + SecureStorageWatch Watch App.xcscheme_^#shared#^_ + + orderHint + 1 + SecureStorgageSample.xcscheme_^#shared#^_ orderHint - 0 + 1 + + SecureStorgageSampleWatch.xcscheme_^#shared#^_ + + orderHint + 3 diff --git a/SecureStorgageSample/Models/UserProfile.swift b/SecureStorgageSample/Models/UserProfile.swift new file mode 100644 index 0000000..ca2c1c5 --- /dev/null +++ b/SecureStorgageSample/Models/UserProfile.swift @@ -0,0 +1,15 @@ +import Foundation + +nonisolated(unsafe) +struct UserProfile: Codable, Sendable { + static let storageKeyName = "user_profile.json" + + let name: String + let email: String + let age: Int? + let createdAt: Date + + var ageDescription: String { + age.map(String.init) ?? "n/a" + } +} diff --git a/SecureStorgageSample/SecureStorgageSampleApp.swift b/SecureStorgageSample/SecureStorgageSampleApp.swift index 7259702..f66acc6 100644 --- a/SecureStorgageSample/SecureStorgageSampleApp.swift +++ b/SecureStorgageSample/SecureStorgageSampleApp.swift @@ -9,6 +9,10 @@ import SwiftUI @main struct SecureStorgageSampleApp: App { + init() { + _ = WatchConnectivityService.shared + } + var body: some Scene { WindowGroup { ContentView() diff --git a/SecureStorgageSample/Services/WatchConnectivityService.swift b/SecureStorgageSample/Services/WatchConnectivityService.swift new file mode 100644 index 0000000..9daddf1 --- /dev/null +++ b/SecureStorgageSample/Services/WatchConnectivityService.swift @@ -0,0 +1,35 @@ +import Foundation +import WatchConnectivity + +@MainActor +final class WatchConnectivityService: NSObject, WCSessionDelegate { + static let shared = WatchConnectivityService() + + private override init() { + super.init() + activateIfSupported() + } + + private func activateIfSupported() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + session.delegate = self + session.activate() + } + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + // Intentionally empty: activation state is handled by WCSession. + } + + func sessionDidBecomeInactive(_ session: WCSession) { + // No-op; required for iOS WCSessionDelegate. + } + + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } +} diff --git a/SecureStorgageSample/StorageKeys.swift b/SecureStorgageSample/StorageKeys.swift index d291b52..59c3888 100644 --- a/SecureStorgageSample/StorageKeys.swift +++ b/SecureStorgageSample/StorageKeys.swift @@ -123,15 +123,19 @@ extension StorageKeys { /// Stores user profile as JSON file in documents. struct UserProfileFileKey: StorageKey { - typealias Value = [String: AnyCodable] + typealias Value = UserProfile - let name = "user_profile.json" - let domain: StorageDomain = .fileSystem(directory: .documents) + let name = UserProfile.storageKeyName + let domain: StorageDomain let security: SecurityPolicy = .none - let serializer: Serializer<[String: AnyCodable]> = .json + let serializer: Serializer = .json let owner = "SampleApp" - let availability: PlatformAvailability = .phoneOnly - let syncPolicy: SyncPolicy = .never + let availability: PlatformAvailability = .phoneWithWatchSync + let syncPolicy: SyncPolicy = .automaticSmall + + init(directory: FileDirectory = .documents) { + self.domain = .fileSystem(directory: directory) + } } /// Stores cached data files. @@ -264,3 +268,4 @@ extension StorageKeys { let syncPolicy: SyncPolicy = .never } } + diff --git a/SecureStorgageSample/Views/FileSystemDemo.swift b/SecureStorgageSample/Views/FileSystemDemo.swift index becd4f4..9cb6682 100644 --- a/SecureStorgageSample/Views/FileSystemDemo.swift +++ b/SecureStorgageSample/Views/FileSystemDemo.swift @@ -12,7 +12,7 @@ struct FileSystemDemo: View { @State private var profileName = "" @State private var profileEmail = "" @State private var profileAge = "" - @State private var storedProfile: [String: AnyCodable]? + @State private var storedProfile: UserProfile? @State private var statusMessage = "" @State private var isLoading = false @State private var selectedDirectory: FileDirectory = .documents @@ -81,9 +81,10 @@ struct FileSystemDemo: View { if let profile = storedProfile { Section("Retrieved Profile") { - ForEach(Array(profile.keys.sorted()), id: \.self) { key in - LabeledContent(key.capitalized, value: String(describing: profile[key]?.value ?? "nil")) - } + LabeledContent("Name", value: profile.name) + LabeledContent("Email", value: profile.email) + LabeledContent("Age", value: profile.ageDescription) + LabeledContent("Created", value: profile.createdAt.formatted(date: .abbreviated, time: .shortened)) } } @@ -99,7 +100,7 @@ struct FileSystemDemo: View { LabeledContent("Domain", value: "File System") LabeledContent("Security", value: "None") LabeledContent("Serializer", value: "JSON") - LabeledContent("Platform", value: "Phone Only") + LabeledContent("Platform", value: "Phone + Watch Sync") } } .navigationTitle("File System") @@ -118,13 +119,13 @@ struct FileSystemDemo: View { isLoading = true Task { do { - let key = StorageKeys.UserProfileFileKey() - let profile: [String: AnyCodable] = [ - "name": AnyCodable(profileName), - "email": AnyCodable(profileEmail), - "age": AnyCodable(Int(profileAge) ?? 0), - "createdAt": AnyCodable(Date().ISO8601Format()) - ] + let key = StorageKeys.UserProfileFileKey(directory: selectedDirectory) + let profile = UserProfile( + name: profileName, + email: profileEmail, + age: Int(profileAge), + createdAt: Date() + ) try await StorageRouter.shared.set(profile, for: key) statusMessage = "✓ Saved to \(selectedDirectory == .documents ? "Documents" : "Caches")" } catch { @@ -138,7 +139,7 @@ struct FileSystemDemo: View { isLoading = true Task { do { - let key = StorageKeys.UserProfileFileKey() + let key = StorageKeys.UserProfileFileKey(directory: selectedDirectory) storedProfile = try await StorageRouter.shared.get(key) statusMessage = "✓ Loaded from file system" } catch StorageError.notFound { @@ -155,7 +156,7 @@ struct FileSystemDemo: View { isLoading = true Task { do { - let key = StorageKeys.UserProfileFileKey() + let key = StorageKeys.UserProfileFileKey(directory: selectedDirectory) try await StorageRouter.shared.remove(key) storedProfile = nil statusMessage = "✓ File deleted"