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 {
|
final class AppGraph: ObservableObject {
|
||||||
let dependencies: AppDependencies
|
let dependencies: AppDependencies
|
||||||
let coreRepository: CoreRepository
|
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 visibility = AppVisibility()
|
||||||
let messages = UiMessageController()
|
let messages = UiMessageController()
|
||||||
let preferencesRepository: AppPreferencesRepository
|
let preferencesRepository: AppPreferencesRepository
|
||||||
@@ -15,10 +19,12 @@ final class AppGraph: ObservableObject {
|
|||||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||||
let backgroundActivity: BackgroundActivityController
|
let backgroundActivity: BackgroundActivityController
|
||||||
|
|
||||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil, coreGateway: CoreGateway? = nil) {
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
let coreRepository = coreRepository ?? CoreRepository()
|
let coreRepository = coreRepository ?? CoreRepository()
|
||||||
self.coreRepository = coreRepository
|
self.coreRepository = coreRepository
|
||||||
|
let gateway = coreGateway ?? coreRepository
|
||||||
|
self.gateway = gateway
|
||||||
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
|
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
|
||||||
self.preferencesRepository = AppPreferencesRepository(
|
self.preferencesRepository = AppPreferencesRepository(
|
||||||
fallback: AppPreferencesDefaults(
|
fallback: AppPreferencesDefaults(
|
||||||
@@ -28,13 +34,13 @@ final class AppGraph: ObservableObject {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.approvalCoordinator = ApprovalCoordinator(
|
self.approvalCoordinator = ApprovalCoordinator(
|
||||||
repository: coreRepository,
|
repository: gateway,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
messages: messages
|
messages: messages
|
||||||
)
|
)
|
||||||
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||||
repository: coreRepository,
|
repository: gateway,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
messages: messages
|
messages: messages
|
||||||
|
|||||||
@@ -12,24 +12,39 @@ struct RootView: View {
|
|||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@State private var screenshotScenario: ScreenshotScenario?
|
||||||
|
#endif
|
||||||
|
|
||||||
init(dependencies: AppDependencies) {
|
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)
|
let graph = AppGraph(dependencies: dependencies)
|
||||||
|
#endif
|
||||||
_graph = StateObject(wrappedValue: graph)
|
_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(
|
_appModel = StateObject(wrappedValue: AppModel(
|
||||||
environment: dependencies.environment,
|
environment: dependencies.environment,
|
||||||
repository: graph.coreRepository,
|
repository: graph.gateway,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
messages: graph.messages
|
messages: graph.messages
|
||||||
))
|
))
|
||||||
_sendModel = StateObject(wrappedValue: SendModel(
|
_sendModel = StateObject(wrappedValue: SendModel(
|
||||||
repository: graph.coreRepository,
|
repository: graph.gateway,
|
||||||
fileSystemService: dependencies.fileSystemService,
|
fileSystemService: dependencies.fileSystemService,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
filePreviewRepository: graph.filePreviewRepository,
|
filePreviewRepository: graph.filePreviewRepository,
|
||||||
messages: graph.messages
|
messages: graph.messages
|
||||||
))
|
))
|
||||||
_receiveModel = StateObject(wrappedValue: ReceiveModel(
|
_receiveModel = StateObject(wrappedValue: ReceiveModel(
|
||||||
repository: graph.coreRepository,
|
repository: graph.gateway,
|
||||||
fileSystemService: dependencies.fileSystemService,
|
fileSystemService: dependencies.fileSystemService,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
messages: graph.messages
|
messages: graph.messages
|
||||||
@@ -38,7 +53,7 @@ struct RootView: View {
|
|||||||
environment: dependencies.environment,
|
environment: dependencies.environment,
|
||||||
deviceInfoProvider: dependencies.deviceInfoProvider,
|
deviceInfoProvider: dependencies.deviceInfoProvider,
|
||||||
fileSystemService: dependencies.fileSystemService,
|
fileSystemService: dependencies.fileSystemService,
|
||||||
repository: graph.coreRepository,
|
repository: graph.gateway,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
messages: graph.messages,
|
messages: graph.messages,
|
||||||
@@ -78,6 +93,15 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
.platformPickers(settingsModel: settingsModel)
|
.platformPickers(settingsModel: settingsModel)
|
||||||
.task { await consumeExternalInvitations() }
|
.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
|
.onChange(of: scenePhase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .active:
|
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/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<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>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -159,6 +159,25 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- target: VniDrop
|
- 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:
|
schemes:
|
||||||
VniDrop:
|
VniDrop:
|
||||||
build:
|
build:
|
||||||
@@ -188,3 +207,13 @@ schemes:
|
|||||||
config: Release-Direct
|
config: Release-Direct
|
||||||
archive:
|
archive:
|
||||||
config: Release-Direct
|
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"
|
||||||
@@ -52,6 +52,13 @@ impl HandshakeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProtocolHandler for HandshakeService {
|
impl ProtocolHandler for HandshakeService {
|
||||||
|
// This handler ACCEPTS inbound QUIC connections from peers: iroh binds a
|
||||||
|
// listening endpoint and this device responds to connections initiated by
|
||||||
|
// the remote side. On macOS this is why the App Sandbox requires the
|
||||||
|
// `com.apple.security.network.server` entitlement (see
|
||||||
|
// apple/VniDrop/Resources/VniDrop.entitlements) in addition to
|
||||||
|
// `network.client` — either peer can initiate a transfer, so both the
|
||||||
|
// client and server sides of a connection are used.
|
||||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||||
let remote_endpoint_id = connection.remote_id().to_string();
|
let remote_endpoint_id = connection.remote_id().to_string();
|
||||||
|
|
||||||
|
|||||||
8
packaging/apple/studio/.gitignore
vendored
Normal file
8
packaging/apple/studio/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
generated/
|
||||||
|
out-*.png
|
||||||
|
assets/shots/**/*.png
|
||||||
|
assets/mockup-*.png
|
||||||
|
assets/mask-*.png
|
||||||
|
assets/globe.png
|
||||||
|
assets/ribbon.png
|
||||||
|
!assets/README.md
|
||||||
57
packaging/apple/studio/README.md
Normal file
57
packaging/apple/studio/README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# App Store screenshot studio
|
||||||
|
|
||||||
|
Code-driven, fully local App Store screenshots. **Typst** composes each frame
|
||||||
|
(gradient background + blank device mockup + app screenshot + localized caption)
|
||||||
|
and renders it to an exact-size PNG. Captions come from `strings.json` (9 locales);
|
||||||
|
app screenshots come from the real app (see capture, step 3).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- `typst` (0.15+) — installed.
|
||||||
|
- `imagemagick` — only for the tilted hero shot's perspective warp (step 2). `brew install imagemagick`.
|
||||||
|
|
||||||
|
## Render
|
||||||
|
```sh
|
||||||
|
./build.sh # all locales × screens -> generated/<Language>/
|
||||||
|
./build.sh --publish # -> ../<Language>/ (ships to App Store)
|
||||||
|
LOCALES="fr de" SCREENS="share-securely" ./build.sh
|
||||||
|
```
|
||||||
|
Default writes to `generated/` (safe). `--publish` overwrites the real language folders.
|
||||||
|
|
||||||
|
Single frame while tuning:
|
||||||
|
```sh
|
||||||
|
typst compile screens.typ out.png --input locale=fr --input screen=share-securely --ppi 72
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `strings.json` — captions per locale (first-pass translations; review before shipping).
|
||||||
|
- `screens.typ` — the composition + per-screen layout data (`screens` dict). Tweak numbers, re-run.
|
||||||
|
- `build.sh` — loops locales × screens; maps locale→folder and screen→filename.
|
||||||
|
- `assets/mockup-straight.png` — blank straight device (real alpha, black screen).
|
||||||
|
- `assets/mockup-rotated.png` — blank tilted device (for the hero shot).
|
||||||
|
- `assets/globe.png` — circle-clipped in Typst (the export has a baked checkerboard, not true alpha).
|
||||||
|
- `assets/shots/<locale>/<screen>.png` — captured app screenshots (optional; placeholder if absent).
|
||||||
|
|
||||||
|
## How the screenshot gets into the phone
|
||||||
|
The mockup's screen glass is a rectangle. `screens.typ` places the screenshot inset
|
||||||
|
into that rectangle (`sx`/`sy`/`sr` fractions) with the bezel framing it. Measure those
|
||||||
|
fractions once against `mockup-straight.png`.
|
||||||
|
|
||||||
|
## Full pipeline
|
||||||
|
```sh
|
||||||
|
./capture.sh # real localized app screens -> assets/shots/<locale>/<screen>.png
|
||||||
|
./build.sh # composite everything -> generated/<Language>/
|
||||||
|
./build.sh --publish # when happy -> ships to ../<Language>/
|
||||||
|
```
|
||||||
|
`capture.sh` drives the app into each screen via the DEBUG `-VniScreenshot` launch
|
||||||
|
argument (see `apple/VniDrop/App/ScreenshotSupport.swift`), once per locale via
|
||||||
|
`-AppleLanguages`, in dark mode with a 9:41 status bar. Screen ⇄ scenario map:
|
||||||
|
`share-securely`→`share`, `choose-receivers`→`approval`, `send-anywhere`→`transfer-details`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
1. ✅ Typst + JSON + straight-mockup compositing.
|
||||||
|
2. ✅ Tilted hero (`send-anywhere`): `warp.sh` perspective-warps the screenshot onto the
|
||||||
|
rotated mockup's screen quad (4 auto-detected corners); `build.sh` runs it automatically.
|
||||||
|
3. ✅ Transparent globe layer (placed directly — use the alpha export, not a flattened one).
|
||||||
|
4. ✅ Screenshot capture: `capture.sh` + the `#if DEBUG` fixture gateway, per locale, deterministic.
|
||||||
|
5. ⬜ Ribbon art layer for `stay-private` (export as its own transparent PNG).
|
||||||
|
6. ⬜ Layout polish: tune device positions/sizes in `screens.typ` against the originals.
|
||||||
61
packaging/apple/studio/build.sh
Executable file
61
packaging/apple/studio/build.sh
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Render all (locale x screen) App Store screenshots with Typst.
|
||||||
|
#
|
||||||
|
# ./build.sh # -> generated/<Language>/<Name>.png (safe default)
|
||||||
|
# ./build.sh --publish # -> ../<Language>/<Name>.png (ships to App Store)
|
||||||
|
# LOCALES="fr de" SCREENS="share-securely" ./build.sh # subset
|
||||||
|
#
|
||||||
|
# Screenshots are read from assets/shots/<locale>/<screen>.png when present
|
||||||
|
# (pass nothing and you get the "screenshot here" placeholder).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
PUBLISH=""; [[ "${1:-}" == "--publish" ]] && PUBLISH=1
|
||||||
|
|
||||||
|
LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"}
|
||||||
|
SCREENS=${SCREENS:-"choose-receivers send-anywhere share-securely stay-private"}
|
||||||
|
|
||||||
|
# locale code -> output folder name (matches existing packaging/apple/<Language>/)
|
||||||
|
folder_for() { case "$1" in
|
||||||
|
en) echo English;; fr) echo French;; de) echo German;; es) echo Spanish;;
|
||||||
|
it) echo Italian;; nl) echo Dutch;; pl) echo Polish;; pt) echo Portuguese;; ru) echo Russian;;
|
||||||
|
*) echo "$1";; esac; }
|
||||||
|
|
||||||
|
# screen id -> output file basename (matches existing filenames)
|
||||||
|
name_for() { case "$1" in
|
||||||
|
choose-receivers) echo "Choose Receivers";; send-anywhere) echo "Send Anywhere";;
|
||||||
|
share-securely) echo "Share Securely";; stay-private) echo "Stay private";;
|
||||||
|
*) echo "$1";; esac; }
|
||||||
|
|
||||||
|
# screen id -> device mockup mode (which mockup frames the screenshot), or empty for none
|
||||||
|
mode_for() { case "$1" in
|
||||||
|
send-anywhere) echo "rotated";; choose-receivers|share-securely) echo "straight";;
|
||||||
|
*) echo "";; esac; }
|
||||||
|
|
||||||
|
n=0
|
||||||
|
for loc in $LOCALES; do
|
||||||
|
folder=$(folder_for "$loc")
|
||||||
|
outdir=$([[ -n "$PUBLISH" ]] && echo "../$folder" || echo "generated/$folder")
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
for scr in $SCREENS; do
|
||||||
|
shot="assets/shots/$loc/$scr.png"
|
||||||
|
mode="$(mode_for "$scr")"
|
||||||
|
|
||||||
|
# Frame the screenshot into its mockup (frame.sh masks it to the real screen
|
||||||
|
# shape) when a capture and a mockup mode exist; regenerate if the shot is newer.
|
||||||
|
device_arg="none"
|
||||||
|
if [[ -n "$mode" && -f "$shot" ]]; then
|
||||||
|
device="assets/shots/$loc/$scr.device.png"
|
||||||
|
[[ ! -f "$device" || "$shot" -nt "$device" ]] && ./frame.sh "$mode" "$shot" "$device" >/dev/null
|
||||||
|
device_arg="$device"
|
||||||
|
fi
|
||||||
|
|
||||||
|
out="$outdir/$(name_for "$scr").png"
|
||||||
|
typst compile screens.typ "$out" \
|
||||||
|
--input locale="$loc" --input screen="$scr" --input device="$device_arg" --ppi 72
|
||||||
|
echo " ✅ $out"
|
||||||
|
n=$((n+1))
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Done — $n screenshot(s)$([[ -n "$PUBLISH" ]] && echo ' (published)')."
|
||||||
64
packaging/apple/studio/capture.sh
Executable file
64
packaging/apple/studio/capture.sh
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Capture real, localized app screens for the studio pipeline.
|
||||||
|
#
|
||||||
|
# Drives the app straight into each marketing screen via the DEBUG `-VniScreenshot`
|
||||||
|
# launch argument (no UI automation needed), once per locale via `-AppleLanguages`,
|
||||||
|
# and writes assets/shots/<locale>/<screen>.png at the device's native resolution.
|
||||||
|
#
|
||||||
|
# ./capture.sh # all locales
|
||||||
|
# LOCALES="en fr" ./capture.sh
|
||||||
|
# SCREENSHOT_DEVICE="iPhone 17 Pro Max" ./capture.sh
|
||||||
|
#
|
||||||
|
# Requires a Debug build (the fixture gateway is compiled under #if DEBUG).
|
||||||
|
set -euo pipefail
|
||||||
|
trap 'echo "capture.sh: failed (rc=$?) at line $LINENO" >&2' ERR
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
APPLE_DIR="$(cd ../../../apple && pwd)"
|
||||||
|
DEVICE="${SCREENSHOT_DEVICE:-iPhone 17 Pro Max}"
|
||||||
|
BUNDLE_ID="com.vnidrop.app"
|
||||||
|
LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"}
|
||||||
|
|
||||||
|
# scenario (launch arg value) -> studio screen id (output filename stem)
|
||||||
|
SCENARIOS="share:share-securely approval:choose-receivers transfer-details:send-anywhere"
|
||||||
|
|
||||||
|
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)
|
||||||
|
|
||||||
|
echo "==> Building VniDrop (Debug) for $DEVICE"
|
||||||
|
xcodebuild build -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
|
||||||
|
-destination "platform=iOS Simulator,name=$DEVICE" -configuration Debug \
|
||||||
|
CODE_SIGNING_ALLOWED=NO >/dev/null
|
||||||
|
APP="$(xcodebuild -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
|
||||||
|
-destination "platform=iOS Simulator,name=$DEVICE" -configuration Debug \
|
||||||
|
-showBuildSettings 2>/dev/null | awk -F' = ' '/ BUILT_PRODUCTS_DIR /{print $2; exit}')/VniDrop.app"
|
||||||
|
|
||||||
|
UDID="$(xcrun simctl list devices available | grep -F "$DEVICE (" | head -1 \
|
||||||
|
| grep -oiE '[0-9a-f-]{36}' | head -1)"
|
||||||
|
[ -n "$UDID" ] || { echo "error: no simulator '$DEVICE'"; exit 1; }
|
||||||
|
echo " device: $UDID"
|
||||||
|
echo " app: $APP"
|
||||||
|
|
||||||
|
xcrun simctl boot "$UDID" 2>/dev/null || true
|
||||||
|
xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl ui "$UDID" appearance dark >/dev/null 2>&1 || true # match the dark marketing look
|
||||||
|
xcrun simctl status_bar "$UDID" override --time "9:41" \
|
||||||
|
--batteryState charged --batteryLevel 100 --cellularBars 4 --wifiBars 3 >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl install "$UDID" "$APP"
|
||||||
|
|
||||||
|
for loc in $LOCALES; do
|
||||||
|
mkdir -p "assets/shots/$loc"
|
||||||
|
for pair in $SCENARIOS; do
|
||||||
|
scenario="${pair%%:*}"; screen="${pair##*:}"
|
||||||
|
xcrun simctl launch --terminate-running-process "$UDID" "$BUNDLE_ID" \
|
||||||
|
-VniScreenshot "$scenario" -AppleLanguages "($loc)" -AppleLocale "$loc" >/dev/null
|
||||||
|
sleep 4
|
||||||
|
# simctl can't write into the project tree (TCC blocks the CoreSimulator helper
|
||||||
|
# on external/again-protected volumes), so capture to a temp file and move it in.
|
||||||
|
tmp="$(mktemp -t vnishot).png"
|
||||||
|
xcrun simctl io "$UDID" screenshot "$tmp" >/dev/null 2>&1
|
||||||
|
mv "$tmp" "assets/shots/$loc/$screen.png"
|
||||||
|
echo " 📸 $loc/$screen.png"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Done. Now run ./build.sh to composite."
|
||||||
38
packaging/apple/studio/frame.sh
Executable file
38
packaging/apple/studio/frame.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Composite a screenshot into a device mockup using the mockup's real screen shape
|
||||||
|
# as the mask — so the screen curvature comes from the mockup, never a guessed radius.
|
||||||
|
# Output is a device PNG the size of the mockup, transparent outside the phone, for
|
||||||
|
# Typst to place directly.
|
||||||
|
#
|
||||||
|
# ./frame.sh straight assets/shots/en/share-securely.png out-device.png
|
||||||
|
# ./frame.sh rotated assets/shots/en/send-anywhere.png out-device.png
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
MODE="$1"; SHOT="$2"; OUT="$3"
|
||||||
|
MOCKUP="assets/mockup-$MODE.png"
|
||||||
|
MASK="assets/mask-$MODE.png"
|
||||||
|
|
||||||
|
# Cache the screen-glass mask (near-black region of the mockup, flattened off transparency).
|
||||||
|
if [[ ! -f "$MASK" || "$MOCKUP" -nt "$MASK" ]]; then
|
||||||
|
magick "$MOCKUP" -background magenta -flatten -colorspace Gray -threshold 6% -negate "$MASK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CW=$(magick identify -format "%w" "$MOCKUP"); CH=$(magick identify -format "%h" "$MOCKUP")
|
||||||
|
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
|
if [[ "$MODE" == "rotated" ]]; then
|
||||||
|
# Perspective-warp onto the tilted glass quad (canvas-sized already).
|
||||||
|
./warp.sh "$SHOT" "$tmp/screen.png" >/dev/null
|
||||||
|
else
|
||||||
|
# Cover-fit the screenshot to the screen's bounding box, placed at its offset.
|
||||||
|
read SW SH SX SY <<<"$(magick "$MASK" -format "%@" info: | tr 'x+' ' ')"
|
||||||
|
magick "$SHOT" -resize "${SW}x${SH}^" -gravity center -extent "${SW}x${SH}" "$tmp/fit.png"
|
||||||
|
magick -size "${CW}x${CH}" xc:none "$tmp/fit.png" -geometry "+${SX}+${SY}" -composite "$tmp/screen.png"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clip the screen layer to the exact glass shape (rounded corners from the mask),
|
||||||
|
# then lay it over the mockup so the bezel frames it.
|
||||||
|
magick "$tmp/screen.png" "$MASK" -alpha off -compose CopyOpacity -composite "$tmp/clipped.png"
|
||||||
|
magick "$MOCKUP" "$tmp/clipped.png" -compose over -composite "$OUT"
|
||||||
|
echo "framed -> $OUT"
|
||||||
78
packaging/apple/studio/screens.typ
Normal file
78
packaging/apple/studio/screens.typ
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// App Store screenshot composition.
|
||||||
|
// Rendered per (locale, screen) via:
|
||||||
|
// typst compile screens.typ out.png --input locale=fr --input screen=share-securely --ppi 72
|
||||||
|
// Canvas is 1284x2778pt -> at 72ppi that's exactly 1284x2778px.
|
||||||
|
|
||||||
|
#let strings = json("strings.json")
|
||||||
|
|
||||||
|
// ---- inputs (with dev-friendly defaults) --------------------------------
|
||||||
|
#let locale = sys.inputs.at("locale", default: "en")
|
||||||
|
#let screen-id = sys.inputs.at("screen", default: "share-securely")
|
||||||
|
// A pre-framed device PNG (mockup + screenshot, produced by frame.sh), or "none"
|
||||||
|
// to show the empty mockup. Screen curvature comes from the mockup mask, not Typst.
|
||||||
|
#let device-image = sys.inputs.at("device", default: "none")
|
||||||
|
|
||||||
|
#let cap = strings.locales.at(locale).at(screen-id)
|
||||||
|
|
||||||
|
// ---- canvas -------------------------------------------------------------
|
||||||
|
#let CW = 1284pt
|
||||||
|
#let CH = 2778pt
|
||||||
|
|
||||||
|
// ---- per-screen layout data --------------------------------------------
|
||||||
|
#let screens = (
|
||||||
|
"choose-receivers": (
|
||||||
|
bg: gradient.linear(angle: 160deg, rgb("#f1eafc"), rgb("#e6d8fb"), rgb("#d9c4f7")),
|
||||||
|
text: (place: "top", theme: "dark"),
|
||||||
|
device: (mockup: "straight", w: 860pt, dx: 212pt, dy: 560pt, rot: 4deg),
|
||||||
|
),
|
||||||
|
"share-securely": (
|
||||||
|
bg: gradient.linear(angle: 165deg, rgb("#efe7fb"), rgb("#e3d3f8"), rgb("#d5bff4")),
|
||||||
|
text: (place: "top", theme: "dark"),
|
||||||
|
device: (mockup: "straight", w: 820pt, dx: 232pt, dy: 470pt, rot: 0deg),
|
||||||
|
),
|
||||||
|
"send-anywhere": (
|
||||||
|
bg: gradient.linear(dir: ttb, rgb("#e9ddf9"), rgb("#ddc9f4")),
|
||||||
|
text: (place: "bottom", theme: "dark"),
|
||||||
|
globe: (w: 1180pt, dx: 52pt, dy: -160pt),
|
||||||
|
device: (mockup: "rotated", w: 900pt, dx: 90pt, dy: 980pt, rot: 0deg),
|
||||||
|
),
|
||||||
|
"stay-private": (
|
||||||
|
bg: gradient.linear(dir: ttb, rgb("#2a0f4d"), rgb("#6b3fa0"), rgb("#e9dcf8"), rgb("#ddc7f4")),
|
||||||
|
text: (place: "top", theme: "light"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
#let cfg = screens.at(screen-id)
|
||||||
|
|
||||||
|
#set page(width: CW, height: CH, margin: 0pt, fill: cfg.bg)
|
||||||
|
#set text(font: ("SF NS", "Helvetica Neue")) // "SF NS" is macOS San Francisco (= SF Pro)
|
||||||
|
|
||||||
|
// ---- compose ------------------------------------------------------------
|
||||||
|
|
||||||
|
// globe (transparent PNG)
|
||||||
|
#if "globe" in cfg [
|
||||||
|
#place(top + left, dx: cfg.globe.dx, dy: cfg.globe.dy,
|
||||||
|
image("assets/globe.png", width: cfg.globe.w))
|
||||||
|
]
|
||||||
|
|
||||||
|
// device: a pre-framed PNG (frame.sh) when available, else the empty mockup.
|
||||||
|
#if "device" in cfg [
|
||||||
|
#let d = cfg.device
|
||||||
|
#let img = if device-image != "none" { device-image } else { "assets/mockup-" + d.mockup + ".png" }
|
||||||
|
#place(top + left, dx: d.dx, dy: d.dy,
|
||||||
|
rotate(d.rot, origin: center, image(img, width: d.w)))
|
||||||
|
]
|
||||||
|
|
||||||
|
// caption
|
||||||
|
#let theme-color = if cfg.text.theme == "light" { white } else { rgb("#1b1226") }
|
||||||
|
#let sub-color = if cfg.text.theme == "light" { rgb("#f3ecfb") } else { rgb("#2c2138") }
|
||||||
|
#let caption = align(center)[
|
||||||
|
#text(size: 104pt, weight: 800, fill: theme-color)[#cap.title]
|
||||||
|
#v(10pt, weak: true)
|
||||||
|
#text(size: 60pt, weight: 600, fill: sub-color)[#cap.subtitle]
|
||||||
|
]
|
||||||
|
#if cfg.text.place == "top" [
|
||||||
|
#place(top + center, dy: 96pt, box(width: CW - 160pt, caption))
|
||||||
|
] else [
|
||||||
|
#place(bottom + center, dy: -220pt, box(width: CW - 160pt, caption))
|
||||||
|
]
|
||||||
69
packaging/apple/studio/strings.json
Normal file
69
packaging/apple/studio/strings.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Marketing captions for App Store screenshots. NOT app strings — these live only here. Translations below the English source are a first pass and should be reviewed by a native speaker.",
|
||||||
|
"screens": ["choose-receivers", "send-anywhere", "share-securely", "stay-private"],
|
||||||
|
"locales": {
|
||||||
|
"en": {
|
||||||
|
"_folder": "English",
|
||||||
|
"choose-receivers": { "title": "Choose Receivers", "subtitle": "Approve each person invited" },
|
||||||
|
"send-anywhere": { "title": "Send Anywhere", "subtitle": "Direct file transfer worldwide" },
|
||||||
|
"share-securely": { "title": "Share Securely", "subtitle": "QR, NFC or file" },
|
||||||
|
"stay-private": { "title": "Stay Private", "subtitle": "Encrypted with no server copy" }
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"_folder": "French",
|
||||||
|
"choose-receivers": { "title": "Choisissez les destinataires", "subtitle": "Approuvez chaque personne invitée" },
|
||||||
|
"send-anywhere": { "title": "Envoyez partout", "subtitle": "Transfert direct dans le monde entier" },
|
||||||
|
"share-securely": { "title": "Partagez en sécurité", "subtitle": "QR, NFC ou fichier" },
|
||||||
|
"stay-private": { "title": "Restez privé", "subtitle": "Chiffré, sans copie serveur" }
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"_folder": "German",
|
||||||
|
"choose-receivers": { "title": "Empfänger auswählen", "subtitle": "Jede eingeladene Person bestätigen" },
|
||||||
|
"send-anywhere": { "title": "Überallhin senden", "subtitle": "Direkte Dateiübertragung weltweit" },
|
||||||
|
"share-securely": { "title": "Sicher teilen", "subtitle": "QR, NFC oder Datei" },
|
||||||
|
"stay-private": { "title": "Privat bleiben", "subtitle": "Verschlüsselt, ohne Server-Kopie" }
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"_folder": "Spanish",
|
||||||
|
"choose-receivers": { "title": "Elige destinatarios", "subtitle": "Aprueba a cada invitado" },
|
||||||
|
"send-anywhere": { "title": "Envía a cualquier lugar", "subtitle": "Transferencia directa en todo el mundo" },
|
||||||
|
"share-securely": { "title": "Comparte con seguridad", "subtitle": "QR, NFC o archivo" },
|
||||||
|
"stay-private": { "title": "Mantén la privacidad", "subtitle": "Cifrado, sin copia en servidor" }
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"_folder": "Italian",
|
||||||
|
"choose-receivers": { "title": "Scegli i destinatari", "subtitle": "Approva ogni invitato" },
|
||||||
|
"send-anywhere": { "title": "Invia ovunque", "subtitle": "Trasferimento diretto in tutto il mondo" },
|
||||||
|
"share-securely": { "title": "Condividi in sicurezza","subtitle": "QR, NFC o file" },
|
||||||
|
"stay-private": { "title": "Resta privato", "subtitle": "Crittografato, senza copia sul server" }
|
||||||
|
},
|
||||||
|
"nl": {
|
||||||
|
"_folder": "Dutch",
|
||||||
|
"choose-receivers": { "title": "Kies ontvangers", "subtitle": "Keur elke genodigde goed" },
|
||||||
|
"send-anywhere": { "title": "Verstuur overal", "subtitle": "Directe bestandsoverdracht wereldwijd" },
|
||||||
|
"share-securely": { "title": "Deel veilig", "subtitle": "QR, NFC of bestand" },
|
||||||
|
"stay-private": { "title": "Blijf privé", "subtitle": "Versleuteld, geen serverkopie" }
|
||||||
|
},
|
||||||
|
"pl": {
|
||||||
|
"_folder": "Polish",
|
||||||
|
"choose-receivers": { "title": "Wybierz odbiorców", "subtitle": "Zatwierdź każdą zaproszoną osobę" },
|
||||||
|
"send-anywhere": { "title": "Wysyłaj wszędzie", "subtitle": "Bezpośredni transfer plików na całym świecie" },
|
||||||
|
"share-securely": { "title": "Udostępniaj bezpiecznie", "subtitle": "Kod QR, NFC lub plik" },
|
||||||
|
"stay-private": { "title": "Zachowaj prywatność", "subtitle": "Szyfrowane, bez kopii na serwerze" }
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"_folder": "Portuguese",
|
||||||
|
"choose-receivers": { "title": "Escolha os destinatários", "subtitle": "Aprove cada pessoa convidada" },
|
||||||
|
"send-anywhere": { "title": "Envie para qualquer lugar", "subtitle": "Transferência direta pelo mundo todo" },
|
||||||
|
"share-securely": { "title": "Compartilhe com segurança", "subtitle": "QR, NFC ou arquivo" },
|
||||||
|
"stay-private": { "title": "Mantenha a privacidade", "subtitle": "Criptografado, sem cópia no servidor" }
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"_folder": "Russian",
|
||||||
|
"choose-receivers": { "title": "Выбирайте получателей", "subtitle": "Подтверждайте каждого приглашённого" },
|
||||||
|
"send-anywhere": { "title": "Отправляйте куда угодно", "subtitle": "Прямая передача файлов по всему миру" },
|
||||||
|
"share-securely": { "title": "Делитесь безопасно", "subtitle": "QR, NFC или файл" },
|
||||||
|
"stay-private": { "title": "Оставайтесь приватными", "subtitle": "Шифрование без копии на сервере" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packaging/apple/studio/warp.sh
Executable file
25
packaging/apple/studio/warp.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Perspective-warp a flat screenshot onto the tilted mockup's screen glass.
|
||||||
|
# Output is a transparent PNG the exact size of mockup-rotated.png, so Typst can
|
||||||
|
# stack it directly over the mockup (bezel frames it).
|
||||||
|
#
|
||||||
|
# ./warp.sh assets/shots/en/send-anywhere.png assets/shots/en/send-anywhere.warped.png
|
||||||
|
#
|
||||||
|
# The 4 destination corners are the glass corners of mockup-rotated.png (1696x2528),
|
||||||
|
# auto-detected once. Re-detect if the mockup changes (see README).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
SRC="$1"; OUT="$2"
|
||||||
|
MW=1696; MH=2528 # mockup-rotated.png dimensions
|
||||||
|
TL="136,28"; TR="836,236"; BL="944,2136"; BR="1668,2312"
|
||||||
|
|
||||||
|
W=$(magick identify -format "%w" "$SRC")
|
||||||
|
H=$(magick identify -format "%h" "$SRC")
|
||||||
|
|
||||||
|
magick \
|
||||||
|
\( -size ${MW}x${MH} xc:none \) \
|
||||||
|
\( "$SRC" -virtual-pixel transparent +distort Perspective \
|
||||||
|
"0,0 $TL $((W-1)),0 $TR 0,$((H-1)) $BL $((W-1)),$((H-1)) $BR" \) \
|
||||||
|
-background none -flatten "$OUT"
|
||||||
|
echo "warped -> $OUT"
|
||||||
Reference in New Issue
Block a user