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:
229
apple/RELEASE-TESTFLIGHT.fr.md
Normal file
229
apple/RELEASE-TESTFLIGHT.fr.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Publier VniDrop sur TestFlight interne — guide complet
|
||||
|
||||
Ce guide décrit **toutes les étapes** pour compiler l'application et l'envoyer sur
|
||||
**TestFlight interne** (aucune revue Apple n'est nécessaire pour les testeurs internes).
|
||||
|
||||
- **Bundle ID** : `com.vnidrop.app`
|
||||
- **Team ID Apple** : `A8A4JSMV5D`
|
||||
- **Branche à utiliser** : `feat/release-test-flight`
|
||||
|
||||
> ⚠️ **Point crucial** : il faut compiler avec un **Xcode de version finale (release)**,
|
||||
> par exemple **Xcode 26** — **pas** une version bêta. Un envoi construit avec un Xcode
|
||||
> bêta est **refusé** par App Store Connect (« Unsupported SDK or Xcode version »).
|
||||
|
||||
---
|
||||
|
||||
## 1. Prérequis
|
||||
|
||||
- Un Mac sous **macOS stable** (pas une bêta) avec **Xcode 26** installé.
|
||||
- Un **identifiant Apple** (gratuit) — **aucun abonnement développeur payant n'est
|
||||
nécessaire de votre côté**. Le propriétaire du compte vous invitera sur le sien.
|
||||
- Une connexion internet.
|
||||
|
||||
---
|
||||
|
||||
## 2. Obtenir l'accès au compte développeur
|
||||
|
||||
Le propriétaire du compte doit vous inviter (une seule fois) :
|
||||
|
||||
1. Sur **App Store Connect** → **Utilisateurs et accès** → **Ajouter un utilisateur**.
|
||||
2. Il saisit **votre identifiant Apple** et vous attribue le rôle **Admin**
|
||||
(nécessaire pour gérer la signature) ou au minimum **App Manager**.
|
||||
3. Vous recevez un e-mail d'invitation — **acceptez-le**.
|
||||
|
||||
Ensuite, dans **Xcode** → menu **Xcode → Settings → Accounts** → **+** →
|
||||
connectez-vous avec **votre** identifiant Apple. L'équipe **VniDrop (A8A4JSMV5D)**
|
||||
doit apparaître.
|
||||
|
||||
---
|
||||
|
||||
## 3. Installer les outils
|
||||
|
||||
Dans le Terminal :
|
||||
|
||||
```bash
|
||||
# Homebrew (si absent) : voir https://brew.sh
|
||||
# Outils de génération de projet et de qualité de code
|
||||
brew install xcodegen swiftlint
|
||||
|
||||
# Rust (pour compiler le cœur natif)
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
Installez aussi les **Command Line Tools** de Xcode si demandé :
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Récupérer le projet
|
||||
|
||||
```bash
|
||||
git clone <URL_DU_DEPOT> vnidrop
|
||||
cd vnidrop
|
||||
git checkout feat/release-test-flight
|
||||
```
|
||||
|
||||
> Le fichier `.xcodeproj`, le `Local.xcconfig` et le framework compilé ne sont **pas**
|
||||
> versionnés : ils seront (re)générés localement aux étapes suivantes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Configurer la signature
|
||||
|
||||
Créez le fichier **`apple/Local.xcconfig`** (ignoré par git) avec ce contenu :
|
||||
|
||||
```
|
||||
DEVELOPMENT_TEAM = A8A4JSMV5D
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
CODE_SIGNING_ALLOWED = YES
|
||||
```
|
||||
|
||||
Cela active la signature sur cette machine sans modifier la configuration partagée
|
||||
(qui reste non signée pour l'intégration continue).
|
||||
|
||||
---
|
||||
|
||||
## 6. Compiler le cœur Rust
|
||||
|
||||
Depuis la racine du dépôt :
|
||||
|
||||
```bash
|
||||
apple/scripts/build-core.sh
|
||||
```
|
||||
|
||||
Cela produit `apple/VnidropCore/vnidrop.xcframework` (avec la tranche **arm64 device**
|
||||
requise pour TestFlight) et les liaisons Swift.
|
||||
|
||||
- La compilation **debug** (par défaut) convient parfaitement pour TestFlight.
|
||||
- Si vous voulez une compilation **release** : `apple/scripts/build-core.sh release`.
|
||||
Sur macOS stable, cela devrait fonctionner. En cas d'erreur `can't find crate`
|
||||
(dylibs de macros corrompus), nettoyez et repassez en debug :
|
||||
```bash
|
||||
cargo clean
|
||||
apple/scripts/build-core.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Incrémenter le numéro de build
|
||||
|
||||
Chaque envoi doit avoir un **numéro de build unique et supérieur** au précédent.
|
||||
Dans **`apple/project.yml`**, cherchez `CURRENT_PROJECT_VERSION` et mettez **`4`**
|
||||
(les numéros 1 à 3 ont déjà été utilisés) :
|
||||
|
||||
```yaml
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
```
|
||||
|
||||
> Pour tout envoi ultérieur, augmentez encore ce nombre (5, 6, …).
|
||||
|
||||
---
|
||||
|
||||
## 8. Générer le projet Xcode
|
||||
|
||||
```bash
|
||||
cd apple
|
||||
xcodegen generate
|
||||
```
|
||||
|
||||
Cela crée `apple/VniDrop.xcodeproj` à partir de `project.yml` et du `Local.xcconfig`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Archiver dans Xcode
|
||||
|
||||
1. Ouvrez **`apple/VniDrop.xcodeproj`** dans **Xcode 26**.
|
||||
2. En haut, sélectionnez le schéma **VniDrop** et la destination
|
||||
**Any iOS Device (arm64)** (surtout **pas** un simulateur).
|
||||
3. Menu **Product → Archive**.
|
||||
4. À la fin, la fenêtre **Organizer** s'ouvre avec votre archive.
|
||||
|
||||
---
|
||||
|
||||
## 10. Envoyer sur App Store Connect
|
||||
|
||||
1. Dans l'**Organizer**, sélectionnez l'archive → **Distribute App**.
|
||||
2. Choisissez **App Store Connect** → **Upload**.
|
||||
3. Laissez les options par défaut (**signature automatique**) → **Upload**.
|
||||
4. La question sur le chiffrement **ne sera pas posée** (déjà réglée dans l'Info.plist).
|
||||
|
||||
Patientez quelques minutes : le build apparaît ensuite dans App Store Connect avec le
|
||||
statut **« En cours de traitement »**, puis devient disponible.
|
||||
|
||||
---
|
||||
|
||||
## 11. Publier sur TestFlight interne (sans revue)
|
||||
|
||||
1. Sur **App Store Connect** → l'app **VniDrop** → onglet **TestFlight**.
|
||||
2. Attendez que le build passe de **« En cours de traitement »** à disponible.
|
||||
3. Section **Tests internes** → créez un groupe (ou utilisez celui par défaut) →
|
||||
ajoutez les **testeurs internes** (ce sont des utilisateurs de l'équipe App Store
|
||||
Connect ; le propriétaire les ajoute via **Utilisateurs et accès** si besoin).
|
||||
4. Activez le build pour le groupe.
|
||||
5. Les testeurs reçoivent un e-mail, installent l'app **TestFlight**, acceptent, puis
|
||||
installent VniDrop. **Aucune revue Apple** n'est requise pour les tests internes.
|
||||
|
||||
---
|
||||
|
||||
## 12. Solution de repli pour la signature
|
||||
|
||||
Si, à l'étape 9/10, Xcode **refuse de créer un certificat de distribution**
|
||||
automatiquement (limitation possible des comptes individuels), le **propriétaire du
|
||||
compte** doit fournir les éléments de signature :
|
||||
|
||||
1. Portail développeur → **Certificates** → créer un certificat **Apple Distribution**,
|
||||
puis l'**exporter en `.p12`** (avec la clé privée) depuis le Trousseau (Keychain).
|
||||
2. **Profiles** → créer un profil de provisioning **App Store** pour `com.vnidrop.app`.
|
||||
3. Vous transmet le `.p12` (+ son mot de passe) et le profil.
|
||||
|
||||
De votre côté :
|
||||
- Importez le `.p12` dans le **Trousseau** (double-clic).
|
||||
- Dans Xcode, désactivez la signature automatique et sélectionnez la signature
|
||||
**manuelle** avec ce profil, puis reprenez l'archivage (étape 9).
|
||||
|
||||
> 🔒 Un `.p12` de distribution permet de signer des apps au nom du propriétaire :
|
||||
> à n'utiliser qu'entre personnes de confiance. Le certificat peut être révoqué ensuite.
|
||||
|
||||
---
|
||||
|
||||
## 13. Dépannage
|
||||
|
||||
- **« Unsupported SDK or Xcode version »** → vous compilez avec un Xcode **bêta**.
|
||||
Utilisez **Xcode 26 (release)**.
|
||||
- **Échec de la phase SwiftLint** → `brew install swiftlint` (obligatoire, la build
|
||||
échoue sinon).
|
||||
- **Numéro de build déjà utilisé** → augmentez `CURRENT_PROJECT_VERSION` puis
|
||||
`xcodegen generate` à nouveau.
|
||||
- **L'app n'apparaît pas dans TestFlight** → attendez la fin du « traitement » ; la
|
||||
conformité export est déjà déclarée, aucune action supplémentaire.
|
||||
- **Le même Xcode pour le cœur Rust et l'archivage n'est pas obligatoire** (le cœur est
|
||||
du Rust), mais l'**archivage** doit impérativement se faire avec **Xcode 26 release**.
|
||||
|
||||
---
|
||||
|
||||
## Récapitulatif express
|
||||
|
||||
```bash
|
||||
# 1. Outils
|
||||
brew install xcodegen swiftlint
|
||||
|
||||
# 2. Projet
|
||||
git checkout feat/release-test-flight
|
||||
|
||||
# 3. Signature : créer apple/Local.xcconfig (voir §5)
|
||||
|
||||
# 4. Cœur natif
|
||||
apple/scripts/build-core.sh
|
||||
|
||||
# 5. Numéro de build : CURRENT_PROJECT_VERSION -> 4 dans apple/project.yml
|
||||
|
||||
# 6. Projet Xcode
|
||||
cd apple && xcodegen generate
|
||||
|
||||
# 7. Xcode 26 : schéma VniDrop, destination « Any iOS Device (arm64) »,
|
||||
# Product → Archive → Distribute App → App Store Connect → Upload
|
||||
|
||||
# 8. App Store Connect → TestFlight → Tests internes → ajouter les testeurs
|
||||
```
|
||||
44
apple/UITests/ScreenshotTests.swift
Normal file
44
apple/UITests/ScreenshotTests.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import XCTest
|
||||
|
||||
/// Captures App Store screenshots by driving the running app and grabbing the
|
||||
/// full-screen image on each tab. Run via `apple/scripts/appstore-screenshots.sh`,
|
||||
/// which boots the target simulator, runs this test, and extracts the attachments
|
||||
/// at the device's native resolution (e.g. 2064×2752 on the 13-inch iPad).
|
||||
final class ScreenshotTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testCaptureAppStoreScreenshots() {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// The top pill exposes the three primary tabs. Tap by accessibility label
|
||||
// when available, falling back to a normalized coordinate on the pill.
|
||||
let tabs: [(name: String, dx: CGFloat)] = [
|
||||
("01-Send", 0.407),
|
||||
("02-Receive", 0.487),
|
||||
("03-Settings", 0.579),
|
||||
]
|
||||
|
||||
for tab in tabs {
|
||||
let label = String(tab.name.dropFirst(3)) // "Send" / "Receive" / "Settings"
|
||||
let button = app.buttons[label]
|
||||
if button.waitForExistence(timeout: 10), button.isHittable {
|
||||
button.tap()
|
||||
} else {
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: tab.dx, dy: 0.039)).tap()
|
||||
}
|
||||
|
||||
// Let the tab transition and any content settle before capturing.
|
||||
Thread.sleep(forTimeInterval: 1.5)
|
||||
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = tab.name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -159,6 +159,25 @@ targets:
|
||||
dependencies:
|
||||
- target: VniDrop
|
||||
|
||||
# UI test bundle used only to capture App Store screenshots (iOS/iPad).
|
||||
# Driven by apple/scripts/appstore-screenshots.sh.
|
||||
VniDropUITests:
|
||||
type: bundle.ui-testing
|
||||
supportedDestinations: [iOS]
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
Release-Direct: Signing.xcconfig
|
||||
sources:
|
||||
- path: UITests
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
SWIFT_VERSION: "6.0"
|
||||
TEST_TARGET_NAME: VniDrop
|
||||
dependencies:
|
||||
- target: VniDrop
|
||||
|
||||
schemes:
|
||||
VniDrop:
|
||||
build:
|
||||
@@ -188,3 +207,13 @@ schemes:
|
||||
config: Release-Direct
|
||||
archive:
|
||||
config: Release-Direct
|
||||
|
||||
# App Store screenshot capture (see apple/scripts/appstore-screenshots.sh).
|
||||
VniDropScreenshots:
|
||||
build:
|
||||
targets:
|
||||
VniDrop: all
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- VniDropUITests
|
||||
|
||||
105
apple/scripts/appstore-screenshots.sh
Executable file
105
apple/scripts/appstore-screenshots.sh
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Captures App Store screenshots for the VniDrop iOS/iPad app by running the
|
||||
# VniDropUITests screenshot test on a simulator and extracting the attachments
|
||||
# at the device's native resolution.
|
||||
#
|
||||
# Usage:
|
||||
# apple/scripts/appstore-screenshots.sh [output-dir]
|
||||
#
|
||||
# Environment:
|
||||
# SCREENSHOT_DEVICE Simulator device name (default: "iPad Pro 13-inch (M5)")
|
||||
# 13-inch iPad → 2064×2752, accepted by App Store Connect.
|
||||
#
|
||||
# The output directory receives one PNG per tab (01-Send.png, 02-Receive.png, …).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
APPLE_DIR="$REPO_ROOT/apple"
|
||||
|
||||
DEVICE="${SCREENSHOT_DEVICE:-iPad Pro 13-inch (M5)}"
|
||||
OUT_DIR="${1:-$HOME/Desktop/vnidrop-appstore-screenshots}"
|
||||
|
||||
echo "==> Regenerating Xcode project (picks up the screenshot target)"
|
||||
(cd "$APPLE_DIR" && xcodegen generate >/dev/null)
|
||||
|
||||
echo "==> Resolving simulator: $DEVICE"
|
||||
# Match the device name literally (it contains parentheses, e.g. "(M5)"), then
|
||||
# pull the UUID from the same line.
|
||||
DEVICE_LINE="$(xcrun simctl list devices available | grep -F "$DEVICE (" | head -1)"
|
||||
UDID="$(printf '%s' "$DEVICE_LINE" \
|
||||
| grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)"
|
||||
if [ -z "${UDID:-}" ]; then
|
||||
echo "error: no available simulator named '$DEVICE'." >&2
|
||||
echo " list options with: xcrun simctl list devices available" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " udid: $UDID"
|
||||
|
||||
echo "==> Booting simulator"
|
||||
xcrun simctl boot "$UDID" 2>/dev/null || true
|
||||
xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true
|
||||
|
||||
# Clean marketing status bar (Apple's 9:41, full signal/battery).
|
||||
xcrun simctl status_bar "$UDID" override \
|
||||
--time "9:41" \
|
||||
--batteryState charged --batteryLevel 100 \
|
||||
--cellularMode active --cellularBars 4 \
|
||||
--wifiMode active --wifiBars 3 >/dev/null 2>&1 || true
|
||||
|
||||
RESULT_DIR="$(mktemp -d)"
|
||||
RESULT="$RESULT_DIR/screenshots.xcresult"
|
||||
ATT_DIR="$RESULT_DIR/attachments"
|
||||
trap 'rm -rf "$RESULT_DIR"' EXIT
|
||||
|
||||
echo "==> Running UI screenshot test (this builds and launches the app)"
|
||||
xcodebuild test \
|
||||
-project "$APPLE_DIR/VniDrop.xcodeproj" \
|
||||
-scheme VniDropScreenshots \
|
||||
-destination "platform=iOS Simulator,id=$UDID" \
|
||||
-resultBundlePath "$RESULT" \
|
||||
-only-testing:VniDropUITests \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
| tail -12
|
||||
|
||||
echo "==> Extracting screenshots"
|
||||
xcrun xcresulttool export attachments --path "$RESULT" --output-path "$ATT_DIR"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
python3 - "$ATT_DIR" "$OUT_DIR" <<'PY'
|
||||
import json, os, re, shutil, sys
|
||||
|
||||
att_dir, out_dir = sys.argv[1], sys.argv[2]
|
||||
manifest = os.path.join(att_dir, "manifest.json")
|
||||
with open(manifest) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Xcode suffixes attachment names with "_<n>_<uuid>.png"; keep only "NN-Name".
|
||||
pattern = re.compile(r"^(\d\d-[A-Za-z]+)")
|
||||
count = 0
|
||||
|
||||
def walk(node):
|
||||
global count
|
||||
if isinstance(node, dict):
|
||||
name = node.get("suggestedHumanReadableName")
|
||||
src = node.get("exportedFileName")
|
||||
m = pattern.match(name) if name else None
|
||||
if m and src:
|
||||
base = f"{m.group(1)}.png"
|
||||
shutil.copyfile(os.path.join(att_dir, src), os.path.join(out_dir, base))
|
||||
print(f" {base}")
|
||||
count += 1
|
||||
for v in node.values():
|
||||
walk(v)
|
||||
elif isinstance(node, list):
|
||||
for v in node:
|
||||
walk(v)
|
||||
|
||||
walk(data)
|
||||
if count == 0:
|
||||
sys.exit("error: no named screenshots found in the result bundle")
|
||||
PY
|
||||
|
||||
echo "==> Done. Screenshots in: $OUT_DIR"
|
||||
ls -1 "$OUT_DIR"
|
||||
Reference in New Issue
Block a user