mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-09 20:29:58 +02:00
chore(apple): add screenshot studio
This commit is contained in:
@@ -7,6 +7,10 @@ import Combine
|
||||
final class AppGraph: ObservableObject {
|
||||
let dependencies: AppDependencies
|
||||
let coreRepository: CoreRepository
|
||||
/// The gateway the feature models and coordinators observe. Normally the real
|
||||
/// `coreRepository`; in screenshot builds a fixture is injected so the UI shows
|
||||
/// deterministic content without the Rust core (see `ScreenshotSupport`).
|
||||
let gateway: CoreGateway
|
||||
let visibility = AppVisibility()
|
||||
let messages = UiMessageController()
|
||||
let preferencesRepository: AppPreferencesRepository
|
||||
@@ -15,10 +19,12 @@ final class AppGraph: ObservableObject {
|
||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||
let backgroundActivity: BackgroundActivityController
|
||||
|
||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil, coreGateway: CoreGateway? = nil) {
|
||||
self.dependencies = dependencies
|
||||
let coreRepository = coreRepository ?? CoreRepository()
|
||||
self.coreRepository = coreRepository
|
||||
let gateway = coreGateway ?? coreRepository
|
||||
self.gateway = gateway
|
||||
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
|
||||
self.preferencesRepository = AppPreferencesRepository(
|
||||
fallback: AppPreferencesDefaults(
|
||||
@@ -28,13 +34,13 @@ final class AppGraph: ObservableObject {
|
||||
)
|
||||
)
|
||||
self.approvalCoordinator = ApprovalCoordinator(
|
||||
repository: coreRepository,
|
||||
repository: gateway,
|
||||
notifications: dependencies.notificationService,
|
||||
visibility: visibility,
|
||||
messages: messages
|
||||
)
|
||||
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||
repository: coreRepository,
|
||||
repository: gateway,
|
||||
notifications: dependencies.notificationService,
|
||||
visibility: visibility,
|
||||
messages: messages
|
||||
|
||||
@@ -12,24 +12,39 @@ struct RootView: View {
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
#if DEBUG
|
||||
@State private var screenshotScenario: ScreenshotScenario?
|
||||
#endif
|
||||
|
||||
init(dependencies: AppDependencies) {
|
||||
#if DEBUG
|
||||
let scenario = ScreenshotScenario.current
|
||||
let graph = AppGraph(
|
||||
dependencies: dependencies,
|
||||
coreGateway: scenario.map { ScreenshotCoreGateway(scenario: $0) }
|
||||
)
|
||||
_screenshotScenario = State(initialValue: scenario)
|
||||
#else
|
||||
let graph = AppGraph(dependencies: dependencies)
|
||||
#endif
|
||||
_graph = StateObject(wrappedValue: graph)
|
||||
// The feature models observe `graph.gateway` — the real core normally, or the
|
||||
// injected screenshot fixture — so a screenshot build never starts the core.
|
||||
_appModel = StateObject(wrappedValue: AppModel(
|
||||
environment: dependencies.environment,
|
||||
repository: graph.coreRepository,
|
||||
repository: graph.gateway,
|
||||
preferences: graph.preferencesRepository,
|
||||
messages: graph.messages
|
||||
))
|
||||
_sendModel = StateObject(wrappedValue: SendModel(
|
||||
repository: graph.coreRepository,
|
||||
repository: graph.gateway,
|
||||
fileSystemService: dependencies.fileSystemService,
|
||||
preferences: graph.preferencesRepository,
|
||||
filePreviewRepository: graph.filePreviewRepository,
|
||||
messages: graph.messages
|
||||
))
|
||||
_receiveModel = StateObject(wrappedValue: ReceiveModel(
|
||||
repository: graph.coreRepository,
|
||||
repository: graph.gateway,
|
||||
fileSystemService: dependencies.fileSystemService,
|
||||
preferences: graph.preferencesRepository,
|
||||
messages: graph.messages
|
||||
@@ -38,7 +53,7 @@ struct RootView: View {
|
||||
environment: dependencies.environment,
|
||||
deviceInfoProvider: dependencies.deviceInfoProvider,
|
||||
fileSystemService: dependencies.fileSystemService,
|
||||
repository: graph.coreRepository,
|
||||
repository: graph.gateway,
|
||||
preferences: graph.preferencesRepository,
|
||||
notifications: dependencies.notificationService,
|
||||
messages: graph.messages,
|
||||
@@ -78,6 +93,15 @@ struct RootView: View {
|
||||
}
|
||||
.platformPickers(settingsModel: settingsModel)
|
||||
.task { await consumeExternalInvitations() }
|
||||
#if DEBUG
|
||||
// Once the (fixture) core reports ready, drive the app into the target screen.
|
||||
.task(id: sendModel.coreState.isInitialized) {
|
||||
guard let scenario = screenshotScenario, sendModel.coreState.isInitialized else { return }
|
||||
appModel.selectDestination(.send)
|
||||
sendModel.openTransfer(ScreenshotCoreGateway.transferId)
|
||||
if scenario == .share { sendModel.openShare() }
|
||||
}
|
||||
#endif
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .active:
|
||||
|
||||
109
apple/VniDrop/App/ScreenshotSupport.swift
Normal file
109
apple/VniDrop/App/ScreenshotSupport.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
#if DEBUG
|
||||
import Foundation
|
||||
import Combine
|
||||
import VnidropCore
|
||||
|
||||
/// Which marketing screen to stage. Selected via the `-VniScreenshot <name>` launch
|
||||
/// argument (read from `NSArgumentDomain`), set by the App Store screenshot UI test.
|
||||
enum ScreenshotScenario: String {
|
||||
case transferDetails = "transfer-details" // Send Anywhere: the detail view
|
||||
case share // Share Securely: the QR / share panel
|
||||
case approval // Choose Receivers: the receive-request modal
|
||||
|
||||
/// The active scenario for this launch, or `nil` in a normal run.
|
||||
static var current: ScreenshotScenario? {
|
||||
guard let raw = UserDefaults.standard.string(forKey: "VniScreenshot") else { return nil }
|
||||
return ScreenshotScenario(rawValue: raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixture `CoreGateway` that publishes deterministic content instead of driving
|
||||
/// the Rust core, so App Store screenshots are stable and localized. Only the reads
|
||||
/// the screenshot screens need are meaningful; mutations are inert.
|
||||
@MainActor
|
||||
final class ScreenshotCoreGateway: CoreGateway {
|
||||
private let scenario: ScreenshotScenario
|
||||
private let subject: CurrentValueSubject<CoreState, Never>
|
||||
private let signalSubject = PassthroughSubject<CoreSignal, Never>()
|
||||
|
||||
/// Deterministic fixture transfer shown across every scenario.
|
||||
static let transferId: UInt64 = 1
|
||||
private let fixtureTransfer = Transfer(
|
||||
localId: "screenshot-1",
|
||||
transferId: ScreenshotCoreGateway.transferId,
|
||||
direction: .send,
|
||||
status: .sharing,
|
||||
peerId: nil,
|
||||
transferName: "Transfer.MOV",
|
||||
contentHash: "b1946ac92492d2347c6235b4d2611184",
|
||||
fileCount: 1,
|
||||
totalSize: 9_100_000,
|
||||
ticket: "vnd://screenshot-demo-ticket-abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
accessPolicy: .requireApproval,
|
||||
createdAt: 1_722_000_000,
|
||||
updatedAt: 1_722_000_000
|
||||
)
|
||||
|
||||
init(scenario: ScreenshotScenario) {
|
||||
self.scenario = scenario
|
||||
self.subject = CurrentValueSubject(CoreState())
|
||||
}
|
||||
|
||||
var state: CoreState { subject.value }
|
||||
var statePublisher: AnyPublisher<CoreState, Never> { subject.eraseToAnyPublisher() }
|
||||
var signals: AnyPublisher<CoreSignal, Never> { signalSubject.eraseToAnyPublisher() }
|
||||
|
||||
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error> {
|
||||
subject.value = CoreState(
|
||||
isInitialized: true,
|
||||
status: CoreStatus(endpointId: "screenshot-endpoint", activeTransfers: 1, activeShares: 1),
|
||||
events: [],
|
||||
transfers: [fixtureTransfer],
|
||||
lastShare: nil,
|
||||
lastInspection: nil
|
||||
)
|
||||
// Nudge the approval coordinator to (re)read requests for the sharing transfer.
|
||||
signalSubject.send(.approvalChanged(transferId: Self.transferId))
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
|
||||
guard scenario == .approval else { return .success([]) }
|
||||
return .success([
|
||||
ReceiverRequestModel(
|
||||
id: "screenshot-request-1",
|
||||
transferId: Self.transferId,
|
||||
remoteEndpointId: "k51qzi5uqu5d-screenshot-peer-endpoint-identity",
|
||||
transferName: "Transfer.MOV",
|
||||
receiverName: nil,
|
||||
receiverDeviceName: "Mac mini",
|
||||
appVersion: "1.0",
|
||||
status: .requested,
|
||||
reason: nil,
|
||||
requestedAt: 1_722_000_000,
|
||||
respondedAt: nil,
|
||||
completedAt: nil
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Inert mutations (screenshots never exercise these)
|
||||
|
||||
func shutdown() {}
|
||||
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
||||
.failure(ScreenshotGatewayError.unsupported)
|
||||
}
|
||||
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> { .failure(ScreenshotGatewayError.unsupported) }
|
||||
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> { .success(()) }
|
||||
func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result<Void, Error> { .success(()) }
|
||||
func cancel(transferId: UInt64) async -> Result<Void, Error> { .success(()) }
|
||||
func delete(transferId: UInt64) async -> Result<Void, Error> { .success(()) }
|
||||
func clearReceiveHistory() async -> Result<UInt64, Error> { .success(0) }
|
||||
func storageUsage() async -> Result<CoreStorageUsageModel, Error> { .success(CoreStorageUsageModel(blobStoreBytes: 0, appDataBytes: 0)) }
|
||||
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { .success([]) }
|
||||
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> { .success(()) }
|
||||
func refresh() async -> Result<Void, Error> { .success(()) }
|
||||
}
|
||||
|
||||
private enum ScreenshotGatewayError: Error { case unsupported }
|
||||
#endif
|
||||
@@ -20,6 +20,12 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<!-- Required, not optional: VniDrop's iroh (QUIC/UDP) endpoint listens for
|
||||
and ACCEPTS inbound connections from peers — either device can initiate
|
||||
a transfer. See HandshakeService::accept in crates/vnidrop/src/handshake.rs.
|
||||
Removing this breaks the receive/serve half of every transfer under the
|
||||
sandbox. App Store automated review may flag it (no classic TcpListener);
|
||||
justify via App Review Information rather than removing. -->
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
Reference in New Issue
Block a user