Refine app structure for persisted sessions and test coverage
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-04-18 12:29:32 +02:00
parent 243029c798
commit d534964601
18 changed files with 1850 additions and 1162 deletions

21
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode.app
- name: Build macOS app
run: xcodebuild build -project "IDPGlobal.xcodeproj" -scheme "IDPGlobal" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO
- name: Run macOS tests
run: xcodebuild test -project "IDPGlobal.xcodeproj" -scheme "IDPGlobal" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO

View File

@@ -26,6 +26,20 @@
B10000000000000000000011 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000E /* Assets.xcassets */; };
B10000000000000000000012 /* AppComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* AppComponents.swift */; };
B10000000000000000000013 /* AppComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* AppComponents.swift */; };
B10000000000000000000014 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* AppTheme.swift */; };
B10000000000000000000015 /* AppStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* AppStateStore.swift */; };
B10000000000000000000016 /* OneTimePasscodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000012 /* OneTimePasscodeGenerator.swift */; };
B10000000000000000000017 /* PairingPayloadParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* PairingPayloadParser.swift */; };
B10000000000000000000018 /* HomePanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000014 /* HomePanels.swift */; };
B10000000000000000000019 /* HomeCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000015 /* HomeCards.swift */; };
B1000000000000000000001A /* HomeSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000016 /* HomeSheets.swift */; };
B1000000000000000000001B /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* AppTheme.swift */; };
B1000000000000000000001C /* AppStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* AppStateStore.swift */; };
B1000000000000000000001D /* PairingPayloadParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* PairingPayloadParser.swift */; };
B1000000000000000000001E /* PairingPayloadParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000017 /* PairingPayloadParserTests.swift */; };
B1000000000000000000001F /* OneTimePasscodeGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */; };
B10000000000000000000020 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000019 /* AppViewModelTests.swift */; };
B10000000000000000000021 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B2000000000000000000001B /* XCTest.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -36,6 +50,13 @@
remoteGlobalIDString = B50000000000000000000002;
remoteInfo = IDPGlobalWatch;
};
B90000000000000000000003 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = B60000000000000000000001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = B50000000000000000000001;
remoteInfo = IDPGlobal;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -68,6 +89,18 @@
B2000000000000000000000D /* NFCPairingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCPairingView.swift; sourceTree = "<group>"; };
B2000000000000000000000E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B2000000000000000000000F /* AppComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppComponents.swift; sourceTree = "<group>"; };
B20000000000000000000010 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
B20000000000000000000011 /* AppStateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateStore.swift; sourceTree = "<group>"; };
B20000000000000000000012 /* OneTimePasscodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimePasscodeGenerator.swift; sourceTree = "<group>"; };
B20000000000000000000013 /* PairingPayloadParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingPayloadParser.swift; sourceTree = "<group>"; };
B20000000000000000000014 /* HomePanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePanels.swift; sourceTree = "<group>"; };
B20000000000000000000015 /* HomeCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCards.swift; sourceTree = "<group>"; };
B20000000000000000000016 /* HomeSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSheets.swift; sourceTree = "<group>"; };
B20000000000000000000017 /* PairingPayloadParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingPayloadParserTests.swift; sourceTree = "<group>"; };
B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimePasscodeGeneratorTests.swift; sourceTree = "<group>"; };
B20000000000000000000019 /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; };
B2000000000000000000001A /* IDPGlobalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IDPGlobalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
B2000000000000000000001B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -85,6 +118,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B30000000000000000000009 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B10000000000000000000021 /* XCTest.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -102,6 +143,7 @@
B2000000000000000000000E /* Assets.xcassets */,
B40000000000000000000003 /* Sources */,
B4000000000000000000000C /* WatchApp */,
B4000000000000000000000F /* Tests */,
);
name = IDPGlobal;
sourceTree = "<group>";
@@ -119,6 +161,7 @@
B40000000000000000000004 /* App */ = {
isa = PBXGroup;
children = (
B20000000000000000000010 /* AppTheme.swift */,
B2000000000000000000000F /* AppComponents.swift */,
B20000000000000000000001 /* IDPGlobalApp.swift */,
B20000000000000000000002 /* AppViewModel.swift */,
@@ -146,8 +189,11 @@
B40000000000000000000007 /* Services */ = {
isa = PBXGroup;
children = (
B20000000000000000000011 /* AppStateStore.swift */,
B20000000000000000000004 /* MockIDPService.swift */,
B20000000000000000000012 /* OneTimePasscodeGenerator.swift */,
B20000000000000000000005 /* NotificationCoordinator.swift */,
B20000000000000000000013 /* PairingPayloadParser.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -166,6 +212,7 @@
children = (
B20000000000000000000009 /* IDPGlobal.app */,
B2000000000000000000000A /* IDPGlobalWatch.app */,
B2000000000000000000001A /* IDPGlobalTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -183,7 +230,10 @@
B4000000000000000000000B /* Home */ = {
isa = PBXGroup;
children = (
B20000000000000000000015 /* HomeCards.swift */,
B20000000000000000000014 /* HomePanels.swift */,
B20000000000000000000008 /* HomeRootView.swift */,
B20000000000000000000016 /* HomeSheets.swift */,
);
path = Home;
sourceTree = "<group>";
@@ -213,6 +263,16 @@
path = Features;
sourceTree = "<group>";
};
B4000000000000000000000F /* Tests */ = {
isa = PBXGroup;
children = (
B20000000000000000000019 /* AppViewModelTests.swift */,
B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */,
B20000000000000000000017 /* PairingPayloadParserTests.swift */,
);
path = Tests;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -252,6 +312,24 @@
productReference = B2000000000000000000000A /* IDPGlobalWatch.app */;
productType = "com.apple.product-type.application";
};
B50000000000000000000003 /* IDPGlobalTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = B70000000000000000000004 /* Build configuration list for PBXNativeTarget "IDPGlobalTests" */;
buildPhases = (
B30000000000000000000008 /* Sources */,
B30000000000000000000009 /* Frameworks */,
B3000000000000000000000A /* Resources */,
);
buildRules = (
);
dependencies = (
B90000000000000000000004 /* PBXTargetDependency */,
);
name = IDPGlobalTests;
productName = IDPGlobalTests;
productReference = B2000000000000000000001A /* IDPGlobalTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -268,6 +346,10 @@
B50000000000000000000002 = {
CreatedOnToolsVersion = 26.0;
};
B50000000000000000000003 = {
CreatedOnToolsVersion = 26.0;
TestTargetID = B50000000000000000000001;
};
};
};
buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */;
@@ -285,6 +367,7 @@
targets = (
B50000000000000000000001 /* IDPGlobal */,
B50000000000000000000002 /* IDPGlobalWatch */,
B50000000000000000000003 /* IDPGlobalTests */,
);
};
/* End PBXProject section */
@@ -305,6 +388,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B3000000000000000000000A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -312,15 +402,22 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B10000000000000000000015 /* AppStateStore.swift in Sources */,
B10000000000000000000012 /* AppComponents.swift in Sources */,
B10000000000000000000014 /* AppTheme.swift in Sources */,
B10000000000000000000002 /* AppViewModel.swift in Sources */,
B10000000000000000000019 /* HomeCards.swift in Sources */,
B10000000000000000000018 /* HomePanels.swift in Sources */,
B10000000000000000000008 /* HomeRootView.swift in Sources */,
B1000000000000000000001A /* HomeSheets.swift in Sources */,
B10000000000000000000001 /* IDPGlobalApp.swift in Sources */,
B10000000000000000000006 /* LoginRootView.swift in Sources */,
B10000000000000000000004 /* MockIDPService.swift in Sources */,
B10000000000000000000010 /* NFCPairingView.swift in Sources */,
B10000000000000000000005 /* NotificationCoordinator.swift in Sources */,
B10000000000000000000016 /* OneTimePasscodeGenerator.swift in Sources */,
B10000000000000000000003 /* AppModels.swift in Sources */,
B10000000000000000000017 /* PairingPayloadParser.swift in Sources */,
B10000000000000000000007 /* QRScannerView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -329,16 +426,29 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B1000000000000000000001C /* AppStateStore.swift in Sources */,
B10000000000000000000013 /* AppComponents.swift in Sources */,
B1000000000000000000001B /* AppTheme.swift in Sources */,
B10000000000000000000009 /* AppViewModel.swift in Sources */,
B1000000000000000000000A /* AppModels.swift in Sources */,
B1000000000000000000000D /* IDPGlobalWatchApp.swift in Sources */,
B1000000000000000000000B /* MockIDPService.swift in Sources */,
B1000000000000000000000C /* NotificationCoordinator.swift in Sources */,
B1000000000000000000001D /* PairingPayloadParser.swift in Sources */,
B1000000000000000000000E /* WatchRootView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B30000000000000000000008 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B10000000000000000000020 /* AppViewModelTests.swift in Sources */,
B1000000000000000000001F /* OneTimePasscodeGeneratorTests.swift in Sources */,
B1000000000000000000001E /* PairingPayloadParserTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -348,6 +458,11 @@
target = B50000000000000000000002 /* IDPGlobalWatch */;
targetProxy = B90000000000000000000001 /* PBXContainerItemProxy */;
};
B90000000000000000000004 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = B50000000000000000000001 /* IDPGlobal */;
targetProxy = B90000000000000000000003 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -415,6 +530,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_TESTABILITY = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "idp.global";
@@ -435,6 +551,7 @@
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBSERVATION_ENABLED = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -532,6 +649,46 @@
};
name = Release;
};
B80000000000000000000007 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = macosx;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal";
TEST_TARGET_NAME = IDPGlobal;
};
name = Debug;
};
B80000000000000000000008 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = macosx;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal";
TEST_TARGET_NAME = IDPGlobal;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -562,6 +719,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B70000000000000000000004 /* Build configuration list for PBXNativeTarget "IDPGlobalTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B80000000000000000000007 /* Debug */,
B80000000000000000000008 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = B60000000000000000000001 /* Project object */;

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B50000000000000000000001"
BuildableName = "IDPGlobal.app"
BlueprintName = "IDPGlobal"
ReferencedContainer = "container:IDPGlobal.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B50000000000000000000001"
BuildableName = "IDPGlobal.app"
BlueprintName = "IDPGlobal"
ReferencedContainer = "container:IDPGlobal.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B50000000000000000000003"
BuildableName = "IDPGlobalTests.xctest"
BlueprintName = "IDPGlobalTests"
ReferencedContainer = "container:IDPGlobal.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B50000000000000000000001"
BuildableName = "IDPGlobal.app"
BlueprintName = "IDPGlobal"
ReferencedContainer = "container:IDPGlobal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B50000000000000000000001"
BuildableName = "IDPGlobal.app"
BlueprintName = "IDPGlobal"
ReferencedContainer = "container:IDPGlobal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,261 +1,4 @@
import SwiftUI
#if os(macOS)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
private extension Color {
static func adaptive(
light: (red: Double, green: Double, blue: Double, opacity: Double),
dark: (red: Double, green: Double, blue: Double, opacity: Double)
) -> Color {
#if os(macOS)
Color(
nsColor: NSColor(name: nil) { appearance in
let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight])
let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light
return NSColor(
red: components.red,
green: components.green,
blue: components.blue,
alpha: components.opacity
)
}
)
#elseif canImport(UIKit) && !os(watchOS)
Color(
uiColor: UIColor { traits in
let components = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(
red: components.red,
green: components.green,
blue: components.blue,
alpha: components.opacity
)
}
)
#elseif os(watchOS)
Color(
red: dark.red,
green: dark.green,
blue: dark.blue,
opacity: dark.opacity
)
#else
Color(
red: light.red,
green: light.green,
blue: light.blue,
opacity: light.opacity
)
#endif
}
}
enum AppTheme {
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
static let border = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.08),
dark: (1.00, 1.00, 1.00, 0.12)
)
static let shadow = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.05),
dark: (0.00, 0.00, 0.00, 0.32)
)
static let cardFill = Color.adaptive(
light: (1.00, 1.00, 1.00, 0.96),
dark: (0.11, 0.12, 0.14, 0.96)
)
static let mutedFill = Color.adaptive(
light: (0.972, 0.976, 0.970, 1.00),
dark: (0.16, 0.17, 0.19, 1.00)
)
static let backgroundTop = Color.adaptive(
light: (0.975, 0.978, 0.972, 1.00),
dark: (0.08, 0.09, 0.10, 1.00)
)
static let backgroundBottom = Color.adaptive(
light: (1.00, 1.00, 1.00, 1.00),
dark: (0.05, 0.06, 0.07, 1.00)
)
static let backgroundGlow = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.02),
dark: (1.00, 1.00, 1.00, 0.06)
)
static let chromeFill = Color.adaptive(
light: (1.00, 1.00, 1.00, 0.98),
dark: (0.10, 0.11, 0.13, 0.98)
)
}
enum AppLayout {
static let compactHorizontalPadding: CGFloat = 16
static let regularHorizontalPadding: CGFloat = 28
static let compactVerticalPadding: CGFloat = 18
static let regularVerticalPadding: CGFloat = 28
static let compactContentWidth: CGFloat = 720
static let regularContentWidth: CGFloat = 920
static let cardRadius: CGFloat = 24
static let largeCardRadius: CGFloat = 30
static let compactSectionPadding: CGFloat = 18
static let regularSectionPadding: CGFloat = 24
static let compactSectionSpacing: CGFloat = 18
static let regularSectionSpacing: CGFloat = 24
static let compactBottomDockPadding: CGFloat = 120
static let regularBottomPadding: CGFloat = 56
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
}
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactVerticalPadding : regularVerticalPadding
}
static func contentWidth(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactContentWidth : regularContentWidth
}
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionPadding : regularSectionPadding
}
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionSpacing : regularSectionSpacing
}
}
extension View {
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
background(
fill,
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
}
}
struct AppBackground: View {
var body: some View {
LinearGradient(
colors: [
AppTheme.backgroundTop,
AppTheme.backgroundBottom
],
startPoint: .top,
endPoint: .bottom
)
.overlay(alignment: .top) {
Rectangle()
.fill(AppTheme.backgroundGlow)
.frame(height: 160)
.blur(radius: 60)
.offset(y: -90)
}
.ignoresSafeArea()
}
}
struct AppScrollScreen<Content: View>: View {
let compactLayout: Bool
var bottomPadding: CGFloat? = nil
let content: () -> Content
init(
compactLayout: Bool,
bottomPadding: CGFloat? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.bottomPadding = bottomPadding
self.content = content
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
content()
}
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
}
.scrollIndicators(.hidden)
}
}
struct AppPanel<Content: View>: View {
let compactLayout: Bool
let radius: CGFloat
let content: () -> Content
init(
compactLayout: Bool,
radius: CGFloat = AppLayout.cardRadius,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.radius = radius
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
content()
}
.padding(AppLayout.sectionPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: .leading)
.appSurface(radius: radius)
}
}
struct AppBadge: View {
let title: String
var tone: Color = AppTheme.accent
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(tone)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(tone.opacity(0.10), in: Capsule())
}
}
struct AppSectionCard<Content: View>: View {
let title: String
var subtitle: String? = nil
let compactLayout: Bool
let content: () -> Content
init(
title: String,
subtitle: String? = nil,
compactLayout: Bool,
@ViewBuilder content: @escaping () -> Content
) {
self.title = title
self.subtitle = subtitle
self.compactLayout = compactLayout
self.content = content
}
var body: some View {
AppPanel(compactLayout: compactLayout) {
AppSectionTitle(title: title, subtitle: subtitle)
content()
}
}
}
struct AppSectionTitle: View {
let title: String

258
Sources/App/AppTheme.swift Normal file
View File

@@ -0,0 +1,258 @@
import SwiftUI
#if os(macOS)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
private extension Color {
static func adaptive(
light: (red: Double, green: Double, blue: Double, opacity: Double),
dark: (red: Double, green: Double, blue: Double, opacity: Double)
) -> Color {
#if os(macOS)
Color(
nsColor: NSColor(name: nil) { appearance in
let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight])
let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light
return NSColor(
red: components.red,
green: components.green,
blue: components.blue,
alpha: components.opacity
)
}
)
#elseif canImport(UIKit) && !os(watchOS)
Color(
uiColor: UIColor { traits in
let components = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(
red: components.red,
green: components.green,
blue: components.blue,
alpha: components.opacity
)
}
)
#elseif os(watchOS)
Color(
red: dark.red,
green: dark.green,
blue: dark.blue,
opacity: dark.opacity
)
#else
Color(
red: light.red,
green: light.green,
blue: light.blue,
opacity: light.opacity
)
#endif
}
}
enum AppTheme {
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
static let border = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.08),
dark: (1.00, 1.00, 1.00, 0.12)
)
static let shadow = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.05),
dark: (0.00, 0.00, 0.00, 0.32)
)
static let cardFill = Color.adaptive(
light: (1.00, 1.00, 1.00, 0.96),
dark: (0.11, 0.12, 0.14, 0.96)
)
static let mutedFill = Color.adaptive(
light: (0.972, 0.976, 0.970, 1.00),
dark: (0.16, 0.17, 0.19, 1.00)
)
static let backgroundTop = Color.adaptive(
light: (0.975, 0.978, 0.972, 1.00),
dark: (0.08, 0.09, 0.10, 1.00)
)
static let backgroundBottom = Color.adaptive(
light: (1.00, 1.00, 1.00, 1.00),
dark: (0.05, 0.06, 0.07, 1.00)
)
static let backgroundGlow = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.02),
dark: (1.00, 1.00, 1.00, 0.06)
)
static let chromeFill = Color.adaptive(
light: (1.00, 1.00, 1.00, 0.98),
dark: (0.10, 0.11, 0.13, 0.98)
)
}
enum AppLayout {
static let compactHorizontalPadding: CGFloat = 16
static let regularHorizontalPadding: CGFloat = 28
static let compactVerticalPadding: CGFloat = 18
static let regularVerticalPadding: CGFloat = 28
static let compactContentWidth: CGFloat = 720
static let regularContentWidth: CGFloat = 920
static let cardRadius: CGFloat = 24
static let largeCardRadius: CGFloat = 30
static let compactSectionPadding: CGFloat = 18
static let regularSectionPadding: CGFloat = 24
static let compactSectionSpacing: CGFloat = 18
static let regularSectionSpacing: CGFloat = 24
static let compactBottomDockPadding: CGFloat = 120
static let regularBottomPadding: CGFloat = 56
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
}
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactVerticalPadding : regularVerticalPadding
}
static func contentWidth(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactContentWidth : regularContentWidth
}
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionPadding : regularSectionPadding
}
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionSpacing : regularSectionSpacing
}
}
extension View {
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
background(
fill,
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
}
}
struct AppBackground: View {
var body: some View {
LinearGradient(
colors: [
AppTheme.backgroundTop,
AppTheme.backgroundBottom
],
startPoint: .top,
endPoint: .bottom
)
.overlay(alignment: .top) {
Rectangle()
.fill(AppTheme.backgroundGlow)
.frame(height: 160)
.blur(radius: 60)
.offset(y: -90)
}
.ignoresSafeArea()
}
}
struct AppScrollScreen<Content: View>: View {
let compactLayout: Bool
var bottomPadding: CGFloat? = nil
let content: () -> Content
init(
compactLayout: Bool,
bottomPadding: CGFloat? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.bottomPadding = bottomPadding
self.content = content
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
content()
}
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
}
.scrollIndicators(.hidden)
}
}
struct AppPanel<Content: View>: View {
let compactLayout: Bool
let radius: CGFloat
let content: () -> Content
init(
compactLayout: Bool,
radius: CGFloat = AppLayout.cardRadius,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.radius = radius
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
content()
}
.padding(AppLayout.sectionPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: .leading)
.appSurface(radius: radius)
}
}
struct AppBadge: View {
let title: String
var tone: Color = AppTheme.accent
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(tone)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(tone.opacity(0.10), in: Capsule())
}
}
struct AppSectionCard<Content: View>: View {
let title: String
var subtitle: String? = nil
let compactLayout: Bool
let content: () -> Content
init(
title: String,
subtitle: String? = nil,
compactLayout: Bool,
@ViewBuilder content: @escaping () -> Content
) {
self.title = title
self.subtitle = subtitle
self.compactLayout = compactLayout
self.content = content
}
var body: some View {
AppPanel(compactLayout: compactLayout) {
AppSectionTitle(title: title, subtitle: subtitle)
content()
}
}
}

View File

@@ -23,6 +23,7 @@ final class AppViewModel: ObservableObject {
private var hasBootstrapped = false
private let service: IDPServicing
private let notificationCoordinator: NotificationCoordinating
private let appStateStore: AppStateStoring
private let launchArguments: [String]
private var preferredLaunchSection: AppSection? {
@@ -40,10 +41,12 @@ final class AppViewModel: ObservableObject {
init(
service: IDPServicing = MockIDPService(),
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
launchArguments: [String] = ProcessInfo.processInfo.arguments
) {
self.service = service
self.notificationCoordinator = notificationCoordinator
self.appStateStore = appStateStore
self.launchArguments = launchArguments
}
@@ -79,14 +82,17 @@ final class AppViewModel: ObservableObject {
guard !hasBootstrapped else { return }
hasBootstrapped = true
restorePersistedState()
isBootstrapping = true
defer { isBootstrapping = false }
notificationPermission = await notificationCoordinator.authorizationStatus()
do {
let bootstrap = try await service.bootstrap()
suggestedPairingPayload = bootstrap.suggestedPairingPayload
manualPairingPayload = bootstrap.suggestedPairingPayload
notificationPermission = await notificationCoordinator.authorizationStatus()
manualPairingPayload = session?.pairingCode ?? bootstrap.suggestedPairingPayload
if launchArguments.contains("--mock-auto-pair"),
session == nil {
@@ -97,7 +103,9 @@ final class AppViewModel: ObservableObject {
}
}
} catch {
errorMessage = "Unable to prepare the app."
if session == nil {
errorMessage = "Unable to prepare the app."
}
}
}
@@ -144,6 +152,7 @@ final class AppViewModel: ObservableObject {
let result = try await service.signIn(with: normalizedRequest)
session = result.session
apply(snapshot: result.snapshot)
persistCurrentState()
notificationPermission = await notificationCoordinator.authorizationStatus()
selectedSection = .overview
errorMessage = nil
@@ -200,6 +209,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.identify(with: normalizedRequest)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
isScannerPresented = false
} catch let error as AppError {
@@ -218,6 +228,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.refreshDashboard()
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch {
errorMessage = "Unable to refresh the dashboard."
@@ -238,6 +249,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.simulateIncomingRequest()
apply(snapshot: snapshot)
persistCurrentState()
selectedSection = .requests
errorMessage = nil
} catch {
@@ -271,6 +283,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.markNotificationRead(id: notification.id)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch {
errorMessage = "Unable to update the notification."
@@ -278,6 +291,7 @@ final class AppViewModel: ObservableObject {
}
func signOut() {
appStateStore.clear()
session = nil
profile = nil
requests = []
@@ -298,12 +312,45 @@ final class AppViewModel: ObservableObject {
? try await service.approveRequest(id: request.id)
: try await service.rejectRequest(id: request.id)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch {
errorMessage = "Unable to update the identity check."
}
}
private func restorePersistedState() {
guard let state = appStateStore.load() else {
return
}
session = state.session
manualPairingPayload = state.session.pairingCode
apply(
snapshot: DashboardSnapshot(
profile: state.profile,
requests: state.requests,
notifications: state.notifications
)
)
}
private func persistCurrentState() {
guard let session, let profile else {
appStateStore.clear()
return
}
appStateStore.save(
PersistedAppState(
session: session,
profile: profile,
requests: requests,
notifications: notifications
)
)
}
private func apply(snapshot: DashboardSnapshot) {
profile = snapshot.profile
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }

View File

@@ -1,7 +1,7 @@
import CryptoKit
import Foundation
enum AppSection: String, CaseIterable, Identifiable, Hashable {
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
case overview
case requests
case activity
@@ -28,7 +28,7 @@ enum AppSection: String, CaseIterable, Identifiable, Hashable {
}
}
enum NotificationPermissionState: String, CaseIterable, Identifiable {
enum NotificationPermissionState: String, CaseIterable, Identifiable, Codable {
case unknown
case allowed
case provisional
@@ -72,7 +72,7 @@ struct BootstrapContext {
let suggestedPairingPayload: String
}
enum PairingTransport: String, Hashable {
enum PairingTransport: String, Hashable, Codable {
case qr
case nfc
case manual
@@ -98,7 +98,7 @@ struct PairingAuthenticationRequest: Hashable {
let signedGPSPosition: SignedGPSPosition?
}
struct SignedGPSPosition: Hashable {
struct SignedGPSPosition: Hashable, Codable {
let latitude: Double
let longitude: Double
let horizontalAccuracyMeters: Double
@@ -185,7 +185,7 @@ struct SignInResult {
let snapshot: DashboardSnapshot
}
struct MemberProfile: Identifiable, Hashable {
struct MemberProfile: Identifiable, Hashable, Codable {
let id: UUID
let name: String
let handle: String
@@ -210,7 +210,7 @@ struct MemberProfile: Identifiable, Hashable {
}
}
struct AuthSession: Identifiable, Hashable {
struct AuthSession: Identifiable, Hashable, Codable {
let id: UUID
let deviceName: String
let originHost: String
@@ -241,7 +241,7 @@ struct AuthSession: Identifiable, Hashable {
}
}
enum ApprovalRequestKind: String, CaseIterable, Hashable {
enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable {
case signIn
case accessGrant
case elevatedAction
@@ -263,7 +263,7 @@ enum ApprovalRequestKind: String, CaseIterable, Hashable {
}
}
enum ApprovalRisk: String, Hashable {
enum ApprovalRisk: String, Hashable, Codable {
case routine
case elevated
@@ -293,7 +293,7 @@ enum ApprovalRisk: String, Hashable {
}
}
enum ApprovalStatus: String, Hashable {
enum ApprovalStatus: String, Hashable, Codable {
case pending
case approved
case rejected
@@ -315,7 +315,7 @@ enum ApprovalStatus: String, Hashable {
}
}
struct ApprovalRequest: Identifiable, Hashable {
struct ApprovalRequest: Identifiable, Hashable, Codable {
let id: UUID
let title: String
let subtitle: String
@@ -382,7 +382,7 @@ struct ApprovalRequest: Identifiable, Hashable {
}
}
enum AppNotificationKind: String, Hashable {
enum AppNotificationKind: String, Hashable, Codable {
case approval
case security
case system
@@ -415,7 +415,7 @@ enum AppNotificationKind: String, Hashable {
}
}
struct AppNotification: Identifiable, Hashable {
struct AppNotification: Identifiable, Hashable, Codable {
let id: UUID
let title: String
let message: String
@@ -440,7 +440,7 @@ struct AppNotification: Identifiable, Hashable {
}
}
enum AppError: LocalizedError {
enum AppError: LocalizedError, Equatable {
case invalidPairingPayload
case missingSignedGPSPosition
case invalidSignedGPSPosition

View File

@@ -0,0 +1,46 @@
import Foundation
struct PersistedAppState: Codable, Equatable {
let session: AuthSession
let profile: MemberProfile
let requests: [ApprovalRequest]
let notifications: [AppNotification]
}
protocol AppStateStoring {
func load() -> PersistedAppState?
func save(_ state: PersistedAppState)
func clear()
}
final class UserDefaultsAppStateStore: AppStateStoring {
private let defaults: UserDefaults
private let storageKey: String
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(defaults: UserDefaults = .standard, storageKey: String = "persisted-app-state") {
self.defaults = defaults
self.storageKey = storageKey
}
func load() -> PersistedAppState? {
guard let data = defaults.data(forKey: storageKey) else {
return nil
}
return try? decoder.decode(PersistedAppState.self, from: data)
}
func save(_ state: PersistedAppState) {
guard let data = try? encoder.encode(state) else {
return
}
defaults.set(data, forKey: storageKey)
}
func clear() {
defaults.removeObject(forKey: storageKey)
}
}

View File

@@ -61,7 +61,7 @@ actor MockIDPService: IDPServicing {
try await Task.sleep(for: .milliseconds(180))
try validateSignedGPSPosition(in: request)
let context = try parsePayloadContext(from: request.pairingPayload)
let context = try PairingPayloadParser.parse(request.pairingPayload)
notifications.insert(
AppNotification(
title: "Identity proof completed",
@@ -186,7 +186,7 @@ actor MockIDPService: IDPServicing {
}
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
let context = try parsePayloadContext(from: request.pairingPayload)
let context = try PairingPayloadParser.parse(request.pairingPayload)
return AuthSession(
deviceName: context.deviceName,
@@ -199,33 +199,6 @@ actor MockIDPService: IDPServicing {
)
}
private func parsePayloadContext(from payload: String) throws -> PayloadContext {
if let components = URLComponents(string: payload),
components.scheme == "idp.global",
components.host == "pair" {
let queryItems = components.queryItems ?? []
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
return PayloadContext(
deviceName: device,
originHost: origin,
tokenPreview: String(token.suffix(6))
)
}
if payload.contains("token") || payload.contains("pair") {
return PayloadContext(
deviceName: "Manual Session",
originHost: "code.foss.global",
tokenPreview: String(payload.suffix(6))
)
}
throw AppError.invalidPairingPayload
}
private func pairingMessage(for session: AuthSession) -> String {
let transportSummary: String
switch session.pairingTransport {
@@ -246,7 +219,7 @@ actor MockIDPService: IDPServicing {
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)."
}
private func identificationMessage(for context: PayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
private func identificationMessage(for context: PairingPayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
if let signedGPSPosition {
return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
}
@@ -254,12 +227,6 @@ actor MockIDPService: IDPServicing {
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)."
}
private struct PayloadContext {
let deviceName: String
let originHost: String
let tokenPreview: String
}
private static func seedRequests() -> [ApprovalRequest] {
[
ApprovalRequest(

View File

@@ -0,0 +1,19 @@
import CryptoKit
import Foundation
enum OneTimePasscodeGenerator {
static func code(for pairingCode: String, at date: Date) -> String {
let timeSlot = Int(date.timeIntervalSince1970 / 30)
let digest = SHA256.hash(data: Data("\(pairingCode)|\(timeSlot)".utf8))
let value = digest.prefix(4).reduce(UInt32(0)) { partialResult, byte in
(partialResult << 8) | UInt32(byte)
}
return String(format: "%06d", locale: Locale(identifier: "en_US_POSIX"), Int(value % 1_000_000))
}
static func renewalCountdown(at date: Date) -> Int {
let elapsed = Int(date.timeIntervalSince1970) % 30
return elapsed == 0 ? 30 : 30 - elapsed
}
}

View File

@@ -0,0 +1,38 @@
import Foundation
struct PairingPayloadContext: Equatable {
let deviceName: String
let originHost: String
let tokenPreview: String
}
enum PairingPayloadParser {
static func parse(_ payload: String) throws -> PairingPayloadContext {
let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines)
if let components = URLComponents(string: trimmedPayload),
components.scheme == "idp.global",
components.host == "pair" {
let queryItems = components.queryItems ?? []
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
return PairingPayloadContext(
deviceName: device,
originHost: origin,
tokenPreview: String(token.suffix(6))
)
}
if trimmedPayload.contains("token") || trimmedPayload.contains("pair") {
return PairingPayloadContext(
deviceName: "Manual Session",
originHost: "code.foss.global",
tokenPreview: String(trimmedPayload.suffix(6))
)
}
throw AppError.invalidPairingPayload
}
}

View File

@@ -0,0 +1,330 @@
import SwiftUI
struct RequestList: View {
let requests: [ApprovalRequest]
let compactLayout: Bool
let activeRequestID: ApprovalRequest.ID?
let onApprove: ((ApprovalRequest) -> Void)?
let onReject: ((ApprovalRequest) -> Void)?
let onOpenRequest: (ApprovalRequest) -> Void
var body: some View {
VStack(spacing: 14) {
ForEach(requests) { request in
RequestCard(
request: request,
compactLayout: compactLayout,
isBusy: activeRequestID == request.id,
onApprove: onApprove == nil ? nil : { onApprove?(request) },
onReject: onReject == nil ? nil : { onReject?(request) },
onOpenRequest: { onOpenRequest(request) }
)
}
}
}
}
private struct RequestCard: View {
let request: ApprovalRequest
let compactLayout: Bool
let isBusy: Bool
let onApprove: (() -> Void)?
let onReject: (() -> Void)?
let onOpenRequest: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: request.kind.systemImage)
.font(.headline)
.foregroundStyle(requestAccent)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(request.title)
.font(.headline)
.multilineTextAlignment(.leading)
Text(request.source)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
AppStatusTag(title: request.status.title, tone: statusTone)
}
Text(request.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 8) {
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
Text(request.scopeSummary)
.font(.footnote)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Text(request.createdAt, style: .relative)
.font(.footnote)
.foregroundStyle(.secondary)
}
if !request.scopes.isEmpty {
Text("Proof details: \(request.scopes.joined(separator: ", "))")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
}
controls
}
.padding(compactLayout ? 18 : 20)
.appSurface(radius: 24)
}
@ViewBuilder
private var controls: some View {
if compactLayout {
VStack(alignment: .leading, spacing: 10) {
reviewButton
decisionButtons
}
} else {
HStack(spacing: 12) {
reviewButton
Spacer(minLength: 0)
decisionButtons
}
}
}
private var reviewButton: some View {
Button {
onOpenRequest()
} label: {
Label("Review proof", systemImage: "arrow.up.forward.app")
}
.buttonStyle(.bordered)
}
@ViewBuilder
private var decisionButtons: some View {
if request.status == .pending, let onApprove, let onReject {
Button {
onApprove()
} label: {
if isBusy {
ProgressView()
} else {
Label("Verify", systemImage: "checkmark.circle.fill")
}
}
.buttonStyle(.borderedProminent)
.disabled(isBusy)
Button(role: .destructive) {
onReject()
} label: {
Label("Decline", systemImage: "xmark.circle.fill")
}
.buttonStyle(.bordered)
.disabled(isBusy)
}
}
private var statusTone: Color {
switch request.status {
case .pending:
.orange
case .approved:
.green
case .rejected:
.red
}
}
private var requestAccent: Color {
switch request.status {
case .approved:
.green
case .rejected:
.red
case .pending:
request.risk == .routine ? dashboardAccent : .orange
}
}
}
struct NotificationList: View {
let notifications: [AppNotification]
let compactLayout: Bool
let onMarkRead: (AppNotification) -> Void
var body: some View {
VStack(spacing: 14) {
ForEach(notifications) { notification in
NotificationCard(
notification: notification,
compactLayout: compactLayout,
onMarkRead: { onMarkRead(notification) }
)
}
}
}
}
private struct NotificationCard: View {
let notification: AppNotification
let compactLayout: Bool
let onMarkRead: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: notification.kind.systemImage)
.font(.headline)
.foregroundStyle(accentColor)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(notification.title)
.font(.headline)
HStack(spacing: 8) {
AppStatusTag(title: notification.kind.title, tone: accentColor)
if notification.isUnread {
AppStatusTag(title: "Unread", tone: .orange)
}
}
}
Spacer(minLength: 0)
}
Text(notification.message)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if compactLayout {
VStack(alignment: .leading, spacing: 10) {
timestamp
if notification.isUnread {
markReadButton
}
}
} else {
HStack {
timestamp
Spacer(minLength: 0)
if notification.isUnread {
markReadButton
}
}
}
}
.padding(compactLayout ? 18 : 20)
.appSurface(radius: 24)
}
private var timestamp: some View {
Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened))
.font(.footnote)
.foregroundStyle(.secondary)
}
private var markReadButton: some View {
Button {
onMarkRead()
} label: {
Label("Mark read", systemImage: "checkmark")
}
.buttonStyle(.bordered)
}
private var accentColor: Color {
switch notification.kind {
case .approval:
.green
case .security:
.orange
case .system:
.blue
}
}
}
struct NotificationBellButton: View {
@ObservedObject var model: AppViewModel
var body: some View {
Button {
model.isNotificationCenterPresented = true
} label: {
Image(systemName: imageName)
.font(.headline)
.foregroundStyle(iconTone)
.frame(width: 28, height: 28, alignment: .center)
.background(alignment: .center) {
#if os(iOS)
GeometryReader { proxy in
Color.clear
.preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
}
#endif
}
}
.accessibilityLabel("Notifications")
}
private var imageName: String {
#if os(iOS)
model.unreadNotificationCount == 0 ? "bell" : "bell.fill"
#else
model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill"
#endif
}
private var iconTone: some ShapeStyle {
model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent
}
}
struct NotificationCenterSheet: View {
@ObservedObject var model: AppViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
NavigationStack {
AppScrollScreen(
compactLayout: compactLayout,
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
) {
NotificationsPanel(model: model, compactLayout: compactLayout)
}
.navigationTitle("Notifications")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
dismiss()
}
}
}
}
#if os(iOS)
.presentationDetents(compactLayout ? [.large] : [.medium, .large])
#endif
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
}

View File

@@ -0,0 +1,317 @@
import SwiftUI
struct OverviewPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if let profile = model.profile, let session = model.session {
OverviewHero(
profile: profile,
session: session,
pendingCount: model.pendingRequests.count,
unreadCount: model.unreadNotificationCount,
compactLayout: compactLayout
)
}
}
}
}
struct RequestsPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
let onOpenRequest: (ApprovalRequest) -> Void
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if model.requests.isEmpty {
AppPanel(compactLayout: compactLayout) {
EmptyStateCopy(
title: "No checks waiting",
systemImage: "checkmark.circle",
message: "Identity proof requests from sites and devices appear here."
)
}
} else {
RequestList(
requests: model.requests,
compactLayout: compactLayout,
activeRequestID: model.activeRequestID,
onApprove: { request in
Task { await model.approve(request) }
},
onReject: { request in
Task { await model.reject(request) }
},
onOpenRequest: onOpenRequest
)
}
}
}
}
struct ActivityPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if model.notifications.isEmpty {
AppPanel(compactLayout: compactLayout) {
EmptyStateCopy(
title: "No proof activity yet",
systemImage: "clock.badge.xmark",
message: "Identity proofs and security events will appear here."
)
}
} else {
NotificationList(
notifications: model.notifications,
compactLayout: compactLayout,
onMarkRead: { notification in
Task { await model.markNotificationRead(notification) }
}
)
}
}
}
}
struct NotificationsPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
AppSectionCard(title: "Delivery", compactLayout: compactLayout) {
NotificationPermissionSummary(model: model, compactLayout: compactLayout)
}
AppSectionCard(title: "Alerts", compactLayout: compactLayout) {
if model.notifications.isEmpty {
EmptyStateCopy(
title: "No alerts yet",
systemImage: "bell.slash",
message: "New passport and identity-proof alerts will accumulate here."
)
} else {
NotificationList(
notifications: model.notifications,
compactLayout: compactLayout,
onMarkRead: { notification in
Task { await model.markNotificationRead(notification) }
}
)
}
}
}
}
}
struct AccountPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if let profile = model.profile, let session = model.session {
AccountHero(profile: profile, session: session, compactLayout: compactLayout)
AppSectionCard(title: "Session", compactLayout: compactLayout) {
AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout)
}
}
AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) {
AppTextSurface(text: model.suggestedPairingPayload, monospaced: true)
}
AppSectionCard(title: "Actions", compactLayout: compactLayout) {
Button(role: .destructive) {
model.signOut()
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
}
.buttonStyle(.bordered)
}
}
}
}
private struct OverviewHero: View {
let profile: MemberProfile
let session: AuthSession
let pendingCount: Int
let unreadCount: Int
let compactLayout: Bool
private var detailColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
}
private var metricColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: 3)
}
var body: some View {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "Digital passport", tone: dashboardAccent)
VStack(alignment: .leading, spacing: 6) {
Text(profile.name)
.font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded))
.lineLimit(2)
Text("\(profile.handle)\(profile.organization)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
AppStatusTag(title: "Passport active", tone: dashboardAccent)
AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold)
}
Divider()
LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) {
AppKeyValue(label: "Device", value: session.deviceName)
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
}
Divider()
LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) {
AppMetric(title: "Pending", value: "\(pendingCount)")
AppMetric(title: "Alerts", value: "\(unreadCount)")
AppMetric(title: "Devices", value: "\(profile.deviceCount)")
}
}
}
}
private struct NotificationPermissionSummary: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: model.notificationPermission.systemImage)
.font(.headline)
.foregroundStyle(dashboardAccent)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(model.notificationPermission.title)
.font(.headline)
Text(model.notificationPermission.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if compactLayout {
VStack(alignment: .leading, spacing: 12) {
permissionButtons
}
} else {
HStack(spacing: 12) {
permissionButtons
}
}
}
}
@ViewBuilder
private var permissionButtons: some View {
Button {
Task { await model.requestNotificationAccess() }
} label: {
Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
Task { await model.sendTestNotification() }
} label: {
Label("Send test alert", systemImage: "paperplane.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
private struct AccountHero: View {
let profile: MemberProfile
let session: AuthSession
let compactLayout: Bool
var body: some View {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "Account", tone: dashboardAccent)
Text(profile.name)
.font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
.lineLimit(2)
Text(profile.handle)
.font(.headline)
.foregroundStyle(.secondary)
Text("Active client: \(session.deviceName)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
private struct AccountFactsGrid: View {
let profile: MemberProfile
let session: AuthSession
let compactLayout: Bool
private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
}
var body: some View {
LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
AppKeyValue(label: "Organization", value: profile.organization)
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Method", value: session.pairingTransport.title)
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
AppKeyValue(label: "Recovery", value: profile.recoverySummary)
if let signedGPSPosition = session.signedGPSPosition {
AppKeyValue(
label: "Signed GPS",
value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)",
monospaced: true
)
}
AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)")
}
}
}
private struct EmptyStateCopy: View {
let title: String
let systemImage: String
let message: String
var body: some View {
ContentUnavailableView(
title,
systemImage: systemImage,
description: Text(message)
)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
}

View File

@@ -1,11 +1,9 @@
import CryptoKit
import Foundation
import SwiftUI
private let dashboardAccent = AppTheme.accent
private let dashboardGold = AppTheme.warmAccent
let dashboardAccent = AppTheme.accent
let dashboardGold = AppTheme.warmAccent
private extension View {
extension View {
@ViewBuilder
func inlineNavigationTitleOnIOS() -> some View {
#if os(iOS)
@@ -123,7 +121,7 @@ private struct DashboardToolbar: ToolbarContent {
}
}
private struct NotificationBellFrameKey: PreferenceKey {
struct NotificationBellFrameKey: PreferenceKey {
static var defaultValue: CGRect? = nil
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
@@ -356,850 +354,3 @@ private struct SidebarStatusCard: View {
.padding(.vertical, 6)
}
}
private struct OverviewPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if let profile = model.profile, let session = model.session {
OverviewHero(
profile: profile,
session: session,
pendingCount: model.pendingRequests.count,
unreadCount: model.unreadNotificationCount,
compactLayout: compactLayout
)
}
}
}
}
private struct RequestsPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
let onOpenRequest: (ApprovalRequest) -> Void
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if model.requests.isEmpty {
AppPanel(compactLayout: compactLayout) {
EmptyStateCopy(
title: "No checks waiting",
systemImage: "checkmark.circle",
message: "Identity proof requests from sites and devices appear here."
)
}
} else {
RequestList(
requests: model.requests,
compactLayout: compactLayout,
activeRequestID: model.activeRequestID,
onApprove: { request in
Task { await model.approve(request) }
},
onReject: { request in
Task { await model.reject(request) }
},
onOpenRequest: onOpenRequest
)
}
}
}
}
private struct ActivityPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if model.notifications.isEmpty {
AppPanel(compactLayout: compactLayout) {
EmptyStateCopy(
title: "No proof activity yet",
systemImage: "clock.badge.xmark",
message: "Identity proofs and security events will appear here."
)
}
} else {
NotificationList(
notifications: model.notifications,
compactLayout: compactLayout,
onMarkRead: { notification in
Task { await model.markNotificationRead(notification) }
}
)
}
}
}
}
private struct NotificationsPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
AppSectionCard(title: "Delivery", compactLayout: compactLayout) {
NotificationPermissionSummary(model: model, compactLayout: compactLayout)
}
AppSectionCard(title: "Alerts", compactLayout: compactLayout) {
if model.notifications.isEmpty {
EmptyStateCopy(
title: "No alerts yet",
systemImage: "bell.slash",
message: "New passport and identity-proof alerts will accumulate here."
)
} else {
NotificationList(
notifications: model.notifications,
compactLayout: compactLayout,
onMarkRead: { notification in
Task { await model.markNotificationRead(notification) }
}
)
}
}
}
}
}
private struct AccountPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if let profile = model.profile, let session = model.session {
AccountHero(profile: profile, session: session, compactLayout: compactLayout)
AppSectionCard(title: "Session", compactLayout: compactLayout) {
AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout)
}
}
AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) {
AppTextSurface(text: model.suggestedPairingPayload, monospaced: true)
}
AppSectionCard(title: "Actions", compactLayout: compactLayout) {
Button(role: .destructive) {
model.signOut()
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
}
.buttonStyle(.bordered)
}
}
}
}
private struct OverviewHero: View {
let profile: MemberProfile
let session: AuthSession
let pendingCount: Int
let unreadCount: Int
let compactLayout: Bool
private var detailColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
}
private var metricColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: 3)
}
var body: some View {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "Digital passport", tone: dashboardAccent)
VStack(alignment: .leading, spacing: 6) {
Text(profile.name)
.font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded))
.lineLimit(2)
Text("\(profile.handle)\(profile.organization)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
AppStatusTag(title: "Passport active", tone: dashboardAccent)
AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold)
}
Divider()
LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) {
AppKeyValue(label: "Device", value: session.deviceName)
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
}
Divider()
LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) {
AppMetric(title: "Pending", value: "\(pendingCount)")
AppMetric(title: "Alerts", value: "\(unreadCount)")
AppMetric(title: "Devices", value: "\(profile.deviceCount)")
}
}
}
}
private struct NotificationPermissionSummary: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: model.notificationPermission.systemImage)
.font(.headline)
.foregroundStyle(dashboardAccent)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(model.notificationPermission.title)
.font(.headline)
Text(model.notificationPermission.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if compactLayout {
VStack(alignment: .leading, spacing: 12) {
permissionButtons
}
} else {
HStack(spacing: 12) {
permissionButtons
}
}
}
}
@ViewBuilder
private var permissionButtons: some View {
Button {
Task { await model.requestNotificationAccess() }
} label: {
Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
Task { await model.sendTestNotification() }
} label: {
Label("Send test alert", systemImage: "paperplane.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
private struct AccountHero: View {
let profile: MemberProfile
let session: AuthSession
let compactLayout: Bool
var body: some View {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "Account", tone: dashboardAccent)
Text(profile.name)
.font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
.lineLimit(2)
Text(profile.handle)
.font(.headline)
.foregroundStyle(.secondary)
Text("Active client: \(session.deviceName)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
private struct AccountFactsGrid: View {
let profile: MemberProfile
let session: AuthSession
let compactLayout: Bool
private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
}
var body: some View {
LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
AppKeyValue(label: "Organization", value: profile.organization)
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Method", value: session.pairingTransport.title)
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
AppKeyValue(label: "Recovery", value: profile.recoverySummary)
if let signedGPSPosition = session.signedGPSPosition {
AppKeyValue(
label: "Signed GPS",
value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)",
monospaced: true
)
}
AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)")
}
}
}
private struct RequestList: View {
let requests: [ApprovalRequest]
let compactLayout: Bool
let activeRequestID: ApprovalRequest.ID?
let onApprove: ((ApprovalRequest) -> Void)?
let onReject: ((ApprovalRequest) -> Void)?
let onOpenRequest: (ApprovalRequest) -> Void
var body: some View {
VStack(spacing: 14) {
ForEach(requests) { request in
RequestCard(
request: request,
compactLayout: compactLayout,
isBusy: activeRequestID == request.id,
onApprove: onApprove == nil ? nil : { onApprove?(request) },
onReject: onReject == nil ? nil : { onReject?(request) },
onOpenRequest: { onOpenRequest(request) }
)
}
}
}
}
private struct RequestCard: View {
let request: ApprovalRequest
let compactLayout: Bool
let isBusy: Bool
let onApprove: (() -> Void)?
let onReject: (() -> Void)?
let onOpenRequest: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: request.kind.systemImage)
.font(.headline)
.foregroundStyle(requestAccent)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(request.title)
.font(.headline)
.multilineTextAlignment(.leading)
Text(request.source)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
AppStatusTag(title: request.status.title, tone: statusTone)
}
Text(request.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 8) {
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
Text(request.scopeSummary)
.font(.footnote)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Text(request.createdAt, style: .relative)
.font(.footnote)
.foregroundStyle(.secondary)
}
if !request.scopes.isEmpty {
Text("Proof details: \(request.scopes.joined(separator: ", "))")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
}
controls
}
.padding(compactLayout ? 18 : 20)
.appSurface(radius: 24)
}
@ViewBuilder
private var controls: some View {
if compactLayout {
VStack(alignment: .leading, spacing: 10) {
reviewButton
decisionButtons
}
} else {
HStack(spacing: 12) {
reviewButton
Spacer(minLength: 0)
decisionButtons
}
}
}
private var reviewButton: some View {
Button {
onOpenRequest()
} label: {
Label("Review proof", systemImage: "arrow.up.forward.app")
}
.buttonStyle(.bordered)
}
@ViewBuilder
private var decisionButtons: some View {
if request.status == .pending, let onApprove, let onReject {
Button {
onApprove()
} label: {
if isBusy {
ProgressView()
} else {
Label("Verify", systemImage: "checkmark.circle.fill")
}
}
.buttonStyle(.borderedProminent)
.disabled(isBusy)
Button(role: .destructive) {
onReject()
} label: {
Label("Decline", systemImage: "xmark.circle.fill")
}
.buttonStyle(.bordered)
.disabled(isBusy)
}
}
private var statusTone: Color {
switch request.status {
case .pending:
.orange
case .approved:
.green
case .rejected:
.red
}
}
private var requestAccent: Color {
switch request.status {
case .approved:
.green
case .rejected:
.red
case .pending:
request.risk == .routine ? dashboardAccent : .orange
}
}
}
private struct NotificationList: View {
let notifications: [AppNotification]
let compactLayout: Bool
let onMarkRead: (AppNotification) -> Void
var body: some View {
VStack(spacing: 14) {
ForEach(notifications) { notification in
NotificationCard(
notification: notification,
compactLayout: compactLayout,
onMarkRead: { onMarkRead(notification) }
)
}
}
}
}
private struct NotificationCard: View {
let notification: AppNotification
let compactLayout: Bool
let onMarkRead: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: notification.kind.systemImage)
.font(.headline)
.foregroundStyle(accentColor)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(notification.title)
.font(.headline)
HStack(spacing: 8) {
AppStatusTag(title: notification.kind.title, tone: accentColor)
if notification.isUnread {
AppStatusTag(title: "Unread", tone: .orange)
}
}
}
Spacer(minLength: 0)
}
Text(notification.message)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if compactLayout {
VStack(alignment: .leading, spacing: 10) {
timestamp
if notification.isUnread {
markReadButton
}
}
} else {
HStack {
timestamp
Spacer(minLength: 0)
if notification.isUnread {
markReadButton
}
}
}
}
.padding(compactLayout ? 18 : 20)
.appSurface(radius: 24)
}
private var timestamp: some View {
Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened))
.font(.footnote)
.foregroundStyle(.secondary)
}
private var markReadButton: some View {
Button {
onMarkRead()
} label: {
Label("Mark read", systemImage: "checkmark")
}
.buttonStyle(.bordered)
}
private var accentColor: Color {
switch notification.kind {
case .approval:
.green
case .security:
.orange
case .system:
.blue
}
}
}
private struct NotificationBellButton: View {
@ObservedObject var model: AppViewModel
var body: some View {
Button {
model.isNotificationCenterPresented = true
} label: {
Image(systemName: imageName)
.font(.headline)
.foregroundStyle(iconTone)
.frame(width: 28, height: 28, alignment: .center)
.background(alignment: .center) {
#if os(iOS)
GeometryReader { proxy in
Color.clear
.preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
}
#endif
}
}
.accessibilityLabel("Notifications")
}
private var imageName: String {
#if os(iOS)
model.unreadNotificationCount == 0 ? "bell" : "bell.fill"
#else
model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill"
#endif
}
private var iconTone: some ShapeStyle {
model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent
}
}
private struct NotificationCenterSheet: View {
@ObservedObject var model: AppViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
NavigationStack {
AppScrollScreen(
compactLayout: compactLayout,
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
) {
NotificationsPanel(model: model, compactLayout: compactLayout)
}
.navigationTitle("Notifications")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
dismiss()
}
}
}
}
#if os(iOS)
.presentationDetents(compactLayout ? [.large] : [.medium, .large])
#endif
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
}
private struct RequestDetailSheet: View {
let request: ApprovalRequest
@ObservedObject var model: AppViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
AppScrollScreen(
compactLayout: true,
bottomPadding: AppLayout.compactBottomDockPadding
) {
RequestDetailHero(request: request)
AppSectionCard(title: "Summary", compactLayout: true) {
AppKeyValue(label: "Source", value: request.source)
AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Risk", value: request.risk.summary)
AppKeyValue(label: "Type", value: request.kind.title)
}
AppSectionCard(title: "Proof details", compactLayout: true) {
if request.scopes.isEmpty {
Text("No explicit proof details were provided by the mock backend.")
.foregroundStyle(.secondary)
} else {
Text(request.scopes.joined(separator: "\n"))
.font(.body.monospaced())
.foregroundStyle(.secondary)
}
}
AppSectionCard(title: "Guidance", compactLayout: true) {
Text(request.trustDetail)
.foregroundStyle(.secondary)
Text(request.risk.guidance)
.font(.headline)
}
if request.status == .pending {
AppSectionCard(title: "Actions", compactLayout: true) {
VStack(spacing: 12) {
Button {
Task {
await model.approve(request)
dismiss()
}
} label: {
if model.activeRequestID == request.id {
ProgressView()
} else {
Label("Verify identity", systemImage: "checkmark.circle.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(model.activeRequestID == request.id)
Button(role: .destructive) {
Task {
await model.reject(request)
dismiss()
}
} label: {
Label("Decline", systemImage: "xmark.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(model.activeRequestID == request.id)
}
}
}
}
.navigationTitle("Review Proof")
.inlineNavigationTitleOnIOS()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
}
}
}
private struct RequestDetailHero: View {
let request: ApprovalRequest
private var accent: Color {
switch request.status {
case .approved:
.green
case .rejected:
.red
case .pending:
request.risk == .routine ? dashboardAccent : .orange
}
}
var body: some View {
AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) {
AppBadge(title: request.kind.title, tone: accent)
Text(request.title)
.font(.system(size: 30, weight: .bold, design: .rounded))
.lineLimit(3)
Text(request.subtitle)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
AppStatusTag(title: request.status.title, tone: accent)
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
}
}
}
}
private struct OneTimePasscodeSheet: View {
let session: AuthSession
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
NavigationStack {
TimelineView(.periodic(from: .now, by: 1)) { context in
let code = passcode(at: context.date)
let secondsRemaining = renewalCountdown(at: context.date)
AppScrollScreen(compactLayout: compactLayout) {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "One-time passcode", tone: dashboardGold)
Text("OTP")
.font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded))
Text("Share this code only with the site or device asking you to prove that it is really you.")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(code)
.font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit())
.tracking(compactLayout ? 4 : 6)
.frame(maxWidth: .infinity)
.padding(.vertical, compactLayout ? 16 : 20)
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
HStack(spacing: 8) {
AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold)
AppStatusTag(title: session.originHost, tone: dashboardAccent)
}
Divider()
AppKeyValue(label: "Client", value: session.deviceName)
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
}
}
}
.navigationTitle("OTP")
.inlineNavigationTitleOnIOS()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
}
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
private func passcode(at date: Date) -> String {
let timeSlot = Int(date.timeIntervalSince1970 / 30)
let digest = SHA256.hash(data: Data("\(session.pairingCode)|\(timeSlot)".utf8))
let value = digest.prefix(4).reduce(UInt32(0)) { partialResult, byte in
(partialResult << 8) | UInt32(byte)
}
return String(format: "%06d", locale: Locale(identifier: "en_US_POSIX"), Int(value % 1_000_000))
}
private func renewalCountdown(at date: Date) -> Int {
let elapsed = Int(date.timeIntervalSince1970) % 30
return elapsed == 0 ? 30 : 30 - elapsed
}
}
private struct EmptyStateCopy: View {
let title: String
let systemImage: String
let message: String
var body: some View {
ContentUnavailableView(
title,
systemImage: systemImage,
description: Text(message)
)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
}

View File

@@ -0,0 +1,188 @@
import SwiftUI
struct RequestDetailSheet: View {
let request: ApprovalRequest
@ObservedObject var model: AppViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
AppScrollScreen(
compactLayout: true,
bottomPadding: AppLayout.compactBottomDockPadding
) {
RequestDetailHero(request: request)
AppSectionCard(title: "Summary", compactLayout: true) {
AppKeyValue(label: "Source", value: request.source)
AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Risk", value: request.risk.summary)
AppKeyValue(label: "Type", value: request.kind.title)
}
AppSectionCard(title: "Proof details", compactLayout: true) {
if request.scopes.isEmpty {
Text("No explicit proof details were provided by the mock backend.")
.foregroundStyle(.secondary)
} else {
Text(request.scopes.joined(separator: "\n"))
.font(.body.monospaced())
.foregroundStyle(.secondary)
}
}
AppSectionCard(title: "Guidance", compactLayout: true) {
Text(request.trustDetail)
.foregroundStyle(.secondary)
Text(request.risk.guidance)
.font(.headline)
}
if request.status == .pending {
AppSectionCard(title: "Actions", compactLayout: true) {
VStack(spacing: 12) {
Button {
Task {
await model.approve(request)
dismiss()
}
} label: {
if model.activeRequestID == request.id {
ProgressView()
} else {
Label("Verify identity", systemImage: "checkmark.circle.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(model.activeRequestID == request.id)
Button(role: .destructive) {
Task {
await model.reject(request)
dismiss()
}
} label: {
Label("Decline", systemImage: "xmark.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(model.activeRequestID == request.id)
}
}
}
}
.navigationTitle("Review Proof")
.inlineNavigationTitleOnIOS()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
}
}
}
private struct RequestDetailHero: View {
let request: ApprovalRequest
private var accent: Color {
switch request.status {
case .approved:
.green
case .rejected:
.red
case .pending:
request.risk == .routine ? dashboardAccent : .orange
}
}
var body: some View {
AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) {
AppBadge(title: request.kind.title, tone: accent)
Text(request.title)
.font(.system(size: 30, weight: .bold, design: .rounded))
.lineLimit(3)
Text(request.subtitle)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
AppStatusTag(title: request.status.title, tone: accent)
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
}
}
}
}
struct OneTimePasscodeSheet: View {
let session: AuthSession
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
NavigationStack {
TimelineView(.periodic(from: .now, by: 1)) { context in
let code = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date)
let secondsRemaining = OneTimePasscodeGenerator.renewalCountdown(at: context.date)
AppScrollScreen(compactLayout: compactLayout) {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "One-time passcode", tone: dashboardGold)
Text("OTP")
.font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded))
Text("Share this code only with the site or device asking you to prove that it is really you.")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(code)
.font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit())
.tracking(compactLayout ? 4 : 6)
.frame(maxWidth: .infinity)
.padding(.vertical, compactLayout ? 16 : 20)
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
HStack(spacing: 8) {
AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold)
AppStatusTag(title: session.originHost, tone: dashboardAccent)
}
Divider()
AppKeyValue(label: "Client", value: session.deviceName)
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
}
}
}
.navigationTitle("OTP")
.inlineNavigationTitleOnIOS()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
}
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
}

View File

@@ -0,0 +1,249 @@
import XCTest
@testable import IDPGlobal
@MainActor
final class AppViewModelTests: XCTestCase {
func testBootstrapRestoresPersistedState() async {
let session = makeSession()
let profile = makeProfile()
let snapshot = makeSnapshot(profile: profile)
let store = InMemoryAppStateStore(
state: PersistedAppState(
session: session,
profile: profile,
requests: snapshot.requests,
notifications: snapshot.notifications
)
)
let service = StubService(
bootstrapContext: BootstrapContext(suggestedPairingPayload: "idp.global://pair?token=fresh-token&origin=code.foss.global&device=Fresh%20Browser"),
signInResult: SignInResult(session: session, snapshot: snapshot),
dashboardSnapshot: snapshot
)
let coordinator = StubNotificationCoordinator(status: .allowed)
let model = AppViewModel(
service: service,
notificationCoordinator: coordinator,
appStateStore: store,
launchArguments: []
)
await model.bootstrap()
XCTAssertEqual(model.session, session)
XCTAssertEqual(model.profile, profile)
XCTAssertEqual(model.requests.map(\.id), snapshot.requests.sorted { $0.createdAt > $1.createdAt }.map(\.id))
XCTAssertEqual(model.notifications.map(\.id), snapshot.notifications.sorted { $0.sentAt > $1.sentAt }.map(\.id))
XCTAssertEqual(model.manualPairingPayload, session.pairingCode)
XCTAssertEqual(model.suggestedPairingPayload, "idp.global://pair?token=fresh-token&origin=code.foss.global&device=Fresh%20Browser")
XCTAssertEqual(model.notificationPermission, .allowed)
}
func testSignInPersistsAuthenticatedState() async {
let session = makeSession()
let profile = makeProfile()
let snapshot = makeSnapshot(profile: profile)
let store = InMemoryAppStateStore()
let service = StubService(
bootstrapContext: BootstrapContext(suggestedPairingPayload: session.pairingCode),
signInResult: SignInResult(session: session, snapshot: snapshot),
dashboardSnapshot: snapshot
)
let model = AppViewModel(
service: service,
notificationCoordinator: StubNotificationCoordinator(status: .allowed),
appStateStore: store,
launchArguments: []
)
await model.signIn(with: session.pairingCode, transport: .preview)
XCTAssertEqual(model.session, session)
XCTAssertEqual(store.storedState?.session, session)
XCTAssertEqual(store.storedState?.profile, profile)
XCTAssertEqual(store.storedState?.requests.map(\.id), snapshot.requests.sorted { $0.createdAt > $1.createdAt }.map(\.id))
XCTAssertEqual(store.storedState?.notifications.map(\.id), snapshot.notifications.sorted { $0.sentAt > $1.sentAt }.map(\.id))
}
func testSignOutClearsPersistedState() async {
let session = makeSession()
let profile = makeProfile()
let snapshot = makeSnapshot(profile: profile)
let store = InMemoryAppStateStore(
state: PersistedAppState(
session: session,
profile: profile,
requests: snapshot.requests,
notifications: snapshot.notifications
)
)
let model = AppViewModel(
service: StubService(
bootstrapContext: BootstrapContext(suggestedPairingPayload: session.pairingCode),
signInResult: SignInResult(session: session, snapshot: snapshot),
dashboardSnapshot: snapshot
),
notificationCoordinator: StubNotificationCoordinator(status: .allowed),
appStateStore: store,
launchArguments: []
)
await model.bootstrap()
model.signOut()
XCTAssertNil(model.session)
XCTAssertNil(model.profile)
XCTAssertTrue(store.didClear)
XCTAssertNil(store.storedState)
}
private func makeSession() -> AuthSession {
AuthSession(
deviceName: "Safari on Berlin MBP",
originHost: "code.foss.global",
pairedAt: Date(timeIntervalSince1970: 1_700_000_000),
tokenPreview: "berlin",
pairingCode: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP",
pairingTransport: .preview
)
}
private func makeProfile() -> MemberProfile {
MemberProfile(
name: "Phil Kunz",
handle: "phil@idp.global",
organization: "idp.global",
deviceCount: 4,
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
)
}
private func makeSnapshot(profile: MemberProfile) -> DashboardSnapshot {
DashboardSnapshot(
profile: profile,
requests: [
ApprovalRequest(
title: "Later request",
subtitle: "Newer",
source: "later.idp.global",
createdAt: Date(timeIntervalSince1970: 200),
kind: .signIn,
risk: .routine,
scopes: ["proof:basic"],
status: .pending
),
ApprovalRequest(
title: "Earlier request",
subtitle: "Older",
source: "earlier.idp.global",
createdAt: Date(timeIntervalSince1970: 100),
kind: .elevatedAction,
risk: .elevated,
scopes: ["proof:high"],
status: .approved
)
],
notifications: [
AppNotification(
title: "Older notification",
message: "Oldest",
sentAt: Date(timeIntervalSince1970: 100),
kind: .system,
isUnread: false
),
AppNotification(
title: "Newer notification",
message: "Newest",
sentAt: Date(timeIntervalSince1970: 200),
kind: .security,
isUnread: true
)
]
)
}
}
private final class InMemoryAppStateStore: AppStateStoring {
var storedState: PersistedAppState?
var didClear = false
init(state: PersistedAppState? = nil) {
storedState = state
}
func load() -> PersistedAppState? {
storedState
}
func save(_ state: PersistedAppState) {
storedState = state
didClear = false
}
func clear() {
storedState = nil
didClear = true
}
}
private actor StubService: IDPServicing {
private let bootstrapContext: BootstrapContext
private let signInResult: SignInResult
private let dashboardSnapshot: DashboardSnapshot
init(bootstrapContext: BootstrapContext, signInResult: SignInResult, dashboardSnapshot: DashboardSnapshot) {
self.bootstrapContext = bootstrapContext
self.signInResult = signInResult
self.dashboardSnapshot = dashboardSnapshot
}
func bootstrap() async throws -> BootstrapContext {
bootstrapContext
}
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
signInResult
}
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
dashboardSnapshot
}
func refreshDashboard() async throws -> DashboardSnapshot {
dashboardSnapshot
}
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
dashboardSnapshot
}
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
dashboardSnapshot
}
func simulateIncomingRequest() async throws -> DashboardSnapshot {
dashboardSnapshot
}
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
dashboardSnapshot
}
}
private final class StubNotificationCoordinator: NotificationCoordinating {
private let status: NotificationPermissionState
init(status: NotificationPermissionState) {
self.status = status
}
func authorizationStatus() async -> NotificationPermissionState {
status
}
func requestAuthorization() async throws -> NotificationPermissionState {
status
}
func scheduleTestNotification(title: String, body: String) async throws {}
}

View File

@@ -0,0 +1,23 @@
import XCTest
@testable import IDPGlobal
final class OneTimePasscodeGeneratorTests: XCTestCase {
func testCodeStaysStableWithinWindow() {
let firstDate = Date(timeIntervalSince1970: 60)
let secondDate = Date(timeIntervalSince1970: 89)
let nextWindowDate = Date(timeIntervalSince1970: 90)
let firstCode = OneTimePasscodeGenerator.code(for: "pairing-payload", at: firstDate)
let secondCode = OneTimePasscodeGenerator.code(for: "pairing-payload", at: secondDate)
let nextWindowCode = OneTimePasscodeGenerator.code(for: "pairing-payload", at: nextWindowDate)
XCTAssertEqual(firstCode, secondCode)
XCTAssertNotEqual(firstCode, nextWindowCode)
}
func testRenewalCountdownResetsAtBoundary() {
XCTAssertEqual(OneTimePasscodeGenerator.renewalCountdown(at: Date(timeIntervalSince1970: 90)), 30)
XCTAssertEqual(OneTimePasscodeGenerator.renewalCountdown(at: Date(timeIntervalSince1970: 119)), 1)
XCTAssertEqual(OneTimePasscodeGenerator.renewalCountdown(at: Date(timeIntervalSince1970: 120)), 30)
}
}

View File

@@ -0,0 +1,28 @@
import XCTest
@testable import IDPGlobal
final class PairingPayloadParserTests: XCTestCase {
func testParsesStructuredPairingPayload() throws {
let payload = "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
let context = try PairingPayloadParser.parse(payload)
XCTAssertEqual(context.deviceName, "Safari on Berlin MBP")
XCTAssertEqual(context.originHost, "code.foss.global")
XCTAssertEqual(context.tokenPreview, "berlin")
}
func testParsesManualFallbackPayload() throws {
let context = try PairingPayloadParser.parse("manual pair token 1234567890")
XCTAssertEqual(context.deviceName, "Manual Session")
XCTAssertEqual(context.originHost, "code.foss.global")
XCTAssertEqual(context.tokenPreview, "567890")
}
func testRejectsInvalidPayload() {
XCTAssertThrowsError(try PairingPayloadParser.parse("https://example.com")) { error in
XCTAssertEqual(error as? AppError, .invalidPairingPayload)
}
}
}