mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-09 20:29:58 +02:00
Compare commits
6 Commits
master
...
feat/scree
| Author | SHA1 | Date | |
|---|---|---|---|
| b3a55172a2 | |||
| 5e8ba09fa5 | |||
| 7aabfdf065 | |||
| 98661cef15 | |||
| 4bac61e505 | |||
| 06d33361c2 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ output/
|
|||||||
.screenshots
|
.screenshots
|
||||||
apple/RELEASE-MACOS.md
|
apple/RELEASE-MACOS.md
|
||||||
apple/Generated/*.xcconfig
|
apple/Generated/*.xcconfig
|
||||||
|
|
||||||
|
# Compliance material — kept locally, never committed
|
||||||
|
compliance/
|
||||||
|
|||||||
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();
|
||||||
|
|
||||||
|
|||||||
4
packaging/apple/studio/.gitignore
vendored
Normal file
4
packaging/apple/studio/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
generated/
|
||||||
|
out-*.png
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
13
packaging/apple/studio/Package.swift
Normal file
13
packaging/apple/studio/Package.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// swift-tools-version:6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "studio",
|
||||||
|
platforms: [.macOS(.v14)],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "studio",
|
||||||
|
path: "Sources/studio"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
76
packaging/apple/studio/README.md
Normal file
76
packaging/apple/studio/README.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# App Store screenshot studio
|
||||||
|
|
||||||
|
Code-driven, fully local App Store screenshots — composed natively with **SwiftUI +
|
||||||
|
`ImageRenderer`** and **SceneKit** (no Typst, no ImageMagick). Each marketing screen is a
|
||||||
|
SwiftUI view (gradient + 3D device + captions + generated artwork) rendered off-screen to
|
||||||
|
an exact-size PNG. Captions come from `strings.json` (9 locales); the app screenshots on
|
||||||
|
the device screens are captured from the real app.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Xcode / Swift toolchain (macOS 26+). That's it.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
```sh
|
||||||
|
swift run studio # all locales × screens -> generated/<Language>/
|
||||||
|
swift run studio --publish # -> ../<Language>/ (ships to App Store)
|
||||||
|
LOCALES="fr de" SCREENS="share-securely" swift run studio # subset
|
||||||
|
PLATFORM=ipad swift run studio # iPad set -> generated/<Language>/iPad/
|
||||||
|
PLATFORM=mac swift run studio # Mac set -> generated/<Language>/Mac/
|
||||||
|
```
|
||||||
|
`PLATFORM` must match between `capture.sh` and `swift run studio` (they read the same
|
||||||
|
`generated/shots/<platform>/` tree).
|
||||||
|
Run from this directory (it reads `strings.json` and `assets/` relative to cwd).
|
||||||
|
|
||||||
|
## Full pipeline
|
||||||
|
```sh
|
||||||
|
./capture.sh # real localized app screens -> generated/shots/<platform>/<locale>/
|
||||||
|
swift run studio # composite everything -> generated/<Language>/[iPad/]
|
||||||
|
./generate.sh # capture + composite in one shot (forwards PLATFORM / --publish)
|
||||||
|
```
|
||||||
|
`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. Screenshots are transient (git-ignored, regenerated per
|
||||||
|
run) — during layout iteration, capture once then re-run `swift run studio` on its own.
|
||||||
|
- **iPhone / iPad** run in the simulator (9:41 status-bar override) via `simctl`.
|
||||||
|
- **Mac** has no simulator: `capture.sh` builds the native app, launches the binary
|
||||||
|
directly, sizes its window with `osascript`, and grabs it with `screencapture`. Grant
|
||||||
|
the terminal **Accessibility + Screen Recording** permission the first time.
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
`PLATFORM=iphone` (default), `ipad`, or `mac`. Each `Platform`
|
||||||
|
(`Sources/studio/Platform.swift`) carries its canvas size, capture target, output
|
||||||
|
subfolder, and a `DeviceModel` — the `.usdz`, its screen material, the orientation fix
|
||||||
|
(yaw so the screen faces the camera), optional front-glass mesh to hide, whether to tint
|
||||||
|
the body graphite, a `fillFraction` (how much of the frame the device fills; lower for
|
||||||
|
wide 3/4 poses that would otherwise clip), and a `screenPad` (black margin baked around
|
||||||
|
the shot so the window clears the display's rounded corners). Adding a device is a new
|
||||||
|
`DeviceModel` + `Platform` case + a layout set. (The studio is Apple-only today; the
|
||||||
|
same SwiftUI/SceneKit approach is intended to extend to Android and Windows later.)
|
||||||
|
|
||||||
|
## Device rendering
|
||||||
|
`SceneKitDeviceRenderer` textures the captured screenshot onto the model's screen mesh and
|
||||||
|
renders it off-screen. Curvature, bezel and body are the model's real geometry; poses
|
||||||
|
(pitch/yaw/roll) and a studio environment (IBL + bloom) are applied in the scene. It
|
||||||
|
auto-generates planar `[0,1]` UVs for screen meshes that ship without them, and mattes the
|
||||||
|
shot onto a slightly larger black canvas (`screenPad`) so the display edge stays black
|
||||||
|
instead of smearing the capture's corner pixels.
|
||||||
|
|
||||||
|
## Assets (`assets/`)
|
||||||
|
- `iphone-17-pro-max.usdz`, `ipad-pro.usdz`, `macbook-air.usdz` — the 3D devices
|
||||||
|
(committed; CC BY 4.0, see `ATTRIBUTION.md`).
|
||||||
|
- `globe.png` — for the send-anywhere hero (committed).
|
||||||
|
- `generated/shots/<platform>/<locale>/*.png` — captured app screens (transient).
|
||||||
|
|
||||||
|
## Layout & tuning
|
||||||
|
- `Sources/studio/ScreenSpec.swift` — per-platform layouts (`iphone` / `ipad` / `mac`):
|
||||||
|
gradient, device pose/position/size, globe + route (send-anywhere), the encryption flow
|
||||||
|
(stay-private: `beams` → `lock` → `stream` + `banners`), caption placement. `beams` and
|
||||||
|
`stream` take a `horizontal` flag for side-by-side devices (used by Mac stay-private).
|
||||||
|
- `Sources/studio/ScreenFrame.swift` — the composition (layer order, caption fitting,
|
||||||
|
generated artwork).
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
`share-securely`, `choose-receivers`, `send-anywhere` (globe + Paris→LA route arc,
|
||||||
|
reuses the share screenshot), `stay-private` (encryption beams → padlock → binary
|
||||||
|
protection stream, with localized CHIFFREMENT/PROTECTION banners; two stacked phones on
|
||||||
|
iPhone/iPad, two side-by-side laptops with a horizontal flow on Mac).
|
||||||
10
packaging/apple/studio/Sources/studio/DeviceView.swift
Normal file
10
packaging/apple/studio/Sources/studio/DeviceView.swift
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// The device is a swappable layer behind this protocol; `SceneKitDeviceRenderer` is the
|
||||||
|
// current implementation (textures the app screenshot onto a real .usdz model).
|
||||||
|
protocol DeviceRenderer {
|
||||||
|
// Produces the device as a SwiftUI view sized to `spec.height` (width follows the
|
||||||
|
// device aspect), already framing `shot` and posed per `spec`.
|
||||||
|
@MainActor func view(shot: NSImage?, spec: DeviceSpec) -> AnyView
|
||||||
|
}
|
||||||
77
packaging/apple/studio/Sources/studio/Platform.swift
Normal file
77
packaging/apple/studio/Sources/studio/Platform.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// A 3D device model + the per-model quirks the renderer needs (screen material name,
|
||||||
|
// orientation fix so the screen faces the camera, optional front-glass mesh to hide,
|
||||||
|
// whether to tint the body graphite).
|
||||||
|
struct DeviceModel {
|
||||||
|
var url: URL
|
||||||
|
var screenMaterial: String
|
||||||
|
var glassMaterial: String? // nil = no separate glass mesh
|
||||||
|
var bodyYaw: Double = 0 // deg about Y to face the screen toward the camera (-Z)
|
||||||
|
var recolorBody: Bool = false // graphite tint
|
||||||
|
// Fraction of the square render the device's upright height fills. Lower = more margin
|
||||||
|
// for wide 3/4 poses (a laptop's fanned base projects past a tight frame and clips).
|
||||||
|
var fillFraction: CGFloat = 0.66
|
||||||
|
// Overscan for auto-generated screen UVs: >0 shrinks the screenshot slightly so its
|
||||||
|
// edges aren't hidden when the screen mesh is a touch larger than the visible display.
|
||||||
|
var screenPad: CGFloat = 0
|
||||||
|
|
||||||
|
static func iphone(_ assets: URL) -> DeviceModel {
|
||||||
|
DeviceModel(url: assets.appendingPathComponent("iphone-17-pro-max.usdz"),
|
||||||
|
screenMaterial: "_7ProMax_Screen", glassMaterial: "glass",
|
||||||
|
bodyYaw: 0, recolorBody: true)
|
||||||
|
}
|
||||||
|
static func ipad(_ assets: URL) -> DeviceModel {
|
||||||
|
// Screen mesh "Material_002" (display only; the bezel is real geometry). Front
|
||||||
|
// faces +Z, so yaw 180° to face -Z. Standard UVs — no remap or bezel padding.
|
||||||
|
DeviceModel(url: assets.appendingPathComponent("ipad-pro.usdz"),
|
||||||
|
screenMaterial: "Material_002", glassMaterial: nil,
|
||||||
|
bodyYaw: 180, recolorBody: true)
|
||||||
|
}
|
||||||
|
static func macbook(_ assets: URL) -> DeviceModel {
|
||||||
|
// Open-laptop model; display mesh "screen_black" (no UVs — auto-generated). Front
|
||||||
|
// faces +Z, so yaw 180° to face -Z.
|
||||||
|
DeviceModel(url: assets.appendingPathComponent("macbook-air.usdz"),
|
||||||
|
screenMaterial: "screen_black", glassMaterial: nil,
|
||||||
|
bodyYaw: 180, recolorBody: false, fillFraction: 0.46, screenPad: 0.008)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An App Store target: canvas size, device model, capture simulator, output subfolder.
|
||||||
|
enum Platform: String {
|
||||||
|
case iphone, ipad, mac
|
||||||
|
|
||||||
|
var canvas: CGSize {
|
||||||
|
switch self {
|
||||||
|
case .iphone: CGSize(width: 1284, height: 2778) // 6.5" iPhone
|
||||||
|
case .ipad: CGSize(width: 2064, height: 2752) // 13" iPad Pro (matches the M5 sim)
|
||||||
|
case .mac: CGSize(width: 2880, height: 1800) // Mac App Store (16:10 landscape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceModel(assets: URL) -> DeviceModel {
|
||||||
|
switch self {
|
||||||
|
case .iphone: DeviceModel.iphone(assets)
|
||||||
|
case .ipad: DeviceModel.ipad(assets)
|
||||||
|
case .mac: DeviceModel.macbook(assets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulator used by capture.sh (informational here; capture reads its own env).
|
||||||
|
var simulator: String {
|
||||||
|
switch self {
|
||||||
|
case .iphone: "iPhone 17 Pro Max"
|
||||||
|
case .ipad: "iPad Pro 13-inch (M5)"
|
||||||
|
case .mac: "My Mac"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output goes under <Language>/<subfolder> so the sets stay separate.
|
||||||
|
var outputSubfolder: String {
|
||||||
|
switch self {
|
||||||
|
case .iphone: "iPhone"
|
||||||
|
case .ipad: "iPad"
|
||||||
|
case .mac: "Mac"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SceneKit
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// Full 3D device layer: textures the app screenshot onto the real iPhone .usdz screen
|
||||||
|
// mesh and renders it with SceneKit off-screen. Slots behind the same DeviceRenderer
|
||||||
|
// protocol as the 2D compositor — ScreenFrame is unchanged. Tilt is baked into the
|
||||||
|
// scene (a real camera + perspective), not faked.
|
||||||
|
|
||||||
|
struct SceneKitDeviceRenderer: DeviceRenderer {
|
||||||
|
let model: DeviceModel
|
||||||
|
|
||||||
|
var supersample: CGFloat = 2 // render big, SwiftUI downscales for clean edges
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func view(shot: NSImage?, spec: DeviceSpec) -> AnyView {
|
||||||
|
// Render into a square (so any pose fits without clipping); the upright phone
|
||||||
|
// occupies `heightFraction` of it, so displaySide maps that to spec.height.
|
||||||
|
let displaySide = spec.height / model.fillFraction
|
||||||
|
let renderSide = min(displaySide * supersample, 3200)
|
||||||
|
guard let img = render(shot: shot, spec: spec, side: renderSide) else {
|
||||||
|
return AnyView(Color.clear.frame(width: displaySide, height: displaySide))
|
||||||
|
}
|
||||||
|
return AnyView(Image(nsImage: img).resizable().frame(width: displaySide, height: displaySide))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func render(shot: NSImage?, spec: DeviceSpec, side: CGFloat) -> NSImage? {
|
||||||
|
guard let scene = try? SCNScene(url: model.url),
|
||||||
|
let device = MTLCreateSystemDefaultDevice() else { return nil }
|
||||||
|
let root = scene.rootNode
|
||||||
|
let d2r = Double.pi / 180
|
||||||
|
|
||||||
|
// Orient the model so its screen faces the camera (-Z). Some models (iPad) have
|
||||||
|
// the screen on ±X, so a per-model yaw fix is applied first.
|
||||||
|
let oriented = SCNNode()
|
||||||
|
for child in root.childNodes { oriented.addChildNode(child) }
|
||||||
|
oriented.eulerAngles = SCNVector3(0, model.bodyYaw * d2r, 0)
|
||||||
|
|
||||||
|
// Re-parent under a pivot so the pose rotates it about its own centre. Compute the
|
||||||
|
// (oriented) bounding box before applying the pose. rootNode.boundingBox aggregates
|
||||||
|
// the big ancestor unit-scale correctly (flattenedClone collapses these models).
|
||||||
|
let pivot = SCNNode()
|
||||||
|
pivot.addChildNode(oriented)
|
||||||
|
root.addChildNode(pivot)
|
||||||
|
let (bmin, bmax) = pivot.boundingBox
|
||||||
|
let center = SCNVector3((bmin.x + bmax.x) / 2, (bmin.y + bmax.y) / 2, (bmin.z + bmax.z) / 2)
|
||||||
|
let height = CGFloat(bmax.y - bmin.y)
|
||||||
|
pivot.pivot = SCNMatrix4MakeTranslation(center.x, center.y, center.z)
|
||||||
|
pivot.position = center
|
||||||
|
pivot.eulerAngles = SCNVector3(spec.pose.pitch * d2r, spec.pose.yaw * d2r, spec.pose.roll * d2r)
|
||||||
|
|
||||||
|
// Texture the screenshot onto the screen mesh, shown flat and full-brightness.
|
||||||
|
// The model's screen carries a wavy normal map (a "screen protector" look) that
|
||||||
|
// ripples the image — clear it so the app content stays crisp, as Apple requires.
|
||||||
|
// Some screen meshes ship with no UV coordinates (an image can't map onto them —
|
||||||
|
// it renders as a flat colour). Generate planar UVs from the vertex positions.
|
||||||
|
addPlanarUVsToScreen(in: pivot, material: model.screenMaterial)
|
||||||
|
|
||||||
|
// Texture EVERY material with the screen name (some models split the display into
|
||||||
|
// several meshes that share the material name); setting only the first leaves the
|
||||||
|
// rest white.
|
||||||
|
// A thin black margin baked around the shot so the window's own corners clear the
|
||||||
|
// display's rounded edge — done in the image (not via UV overscan) so the clamped
|
||||||
|
// border samples black instead of smearing the capture's rounded-corner pixels.
|
||||||
|
let screenShot = (shot != nil && model.screenPad > 0) ? matted(shot!, pad: model.screenPad) : shot
|
||||||
|
for mat in materials(named: model.screenMaterial, in: pivot) where spec.blackScreen || shot != nil {
|
||||||
|
mat.diffuse.contents = spec.blackScreen ? NSColor.black : screenShot
|
||||||
|
mat.lightingModel = .constant
|
||||||
|
mat.normal.contents = nil
|
||||||
|
mat.emission.contents = nil
|
||||||
|
mat.metalness.contents = NSNumber(value: 0)
|
||||||
|
mat.roughness.contents = NSNumber(value: 1)
|
||||||
|
mat.isDoubleSided = false
|
||||||
|
mat.diffuse.wrapS = .clamp
|
||||||
|
mat.diffuse.wrapT = .clamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the front glass mesh (if any) — it has its own normal-mapped waviness that
|
||||||
|
// reflects as swirls over the screen. We keep the crisp display instead.
|
||||||
|
if let glass = model.glassMaterial { hideMeshes(withMaterial: glass, in: pivot) }
|
||||||
|
|
||||||
|
// Recolor a silver body to graphite (iPhone); models already dark (iPad) skip it.
|
||||||
|
if model.recolorBody { recolorBody(in: pivot) }
|
||||||
|
|
||||||
|
// Camera on the screen side (front = -Z), oriented by hand: look(at:) renders
|
||||||
|
// nothing here, but a straight 180° yaw does. Framed so an upright phone height
|
||||||
|
// fills `heightFraction` of the square canvas (leaving margin for the pose).
|
||||||
|
let fovV = 18.0
|
||||||
|
let cam = SCNCamera()
|
||||||
|
cam.usesOrthographicProjection = false
|
||||||
|
cam.fieldOfView = fovV
|
||||||
|
cam.projectionDirection = .vertical
|
||||||
|
// zFar must comfortably exceed the camera distance; models differ hugely in unit
|
||||||
|
// scale (iPhone ~16 units tall, iPad ~300), so keep this large.
|
||||||
|
cam.zNear = 0.01; cam.zFar = 100_000
|
||||||
|
// HDR + subtle bloom so bright specular highlights on the metal/glass glow like a
|
||||||
|
// real product shot instead of clipping flat.
|
||||||
|
cam.wantsHDR = true
|
||||||
|
cam.wantsExposureAdaptation = false
|
||||||
|
cam.bloomThreshold = 0.9
|
||||||
|
cam.bloomIntensity = 0.25
|
||||||
|
cam.bloomBlurRadius = 6
|
||||||
|
let camNode = SCNNode(); camNode.camera = cam
|
||||||
|
let dist = Double(height) / (2 * Double(model.fillFraction) * tan(fovV / 2 * .pi / 180))
|
||||||
|
camNode.position = SCNVector3(center.x, center.y, center.z - SCNFloat(dist))
|
||||||
|
camNode.eulerAngles = SCNVector3(0, Double.pi, 0)
|
||||||
|
root.addChildNode(camNode)
|
||||||
|
|
||||||
|
lightRig(into: root, center: center)
|
||||||
|
// Image-based lighting: a studio softbox environment gives the metal frame and
|
||||||
|
// glass real reflections/highlights — this is what stops it looking plasticky.
|
||||||
|
scene.lightingEnvironment.contents = Self.studioEnvironment
|
||||||
|
scene.lightingEnvironment.intensity = 1.5
|
||||||
|
|
||||||
|
let renderer = SCNRenderer(device: device, options: nil)
|
||||||
|
renderer.scene = scene
|
||||||
|
renderer.pointOfView = camNode
|
||||||
|
renderer.autoenablesDefaultLighting = false
|
||||||
|
let px = CGSize(width: side, height: side)
|
||||||
|
// First snapshot primes the renderer and comes back empty; the second is real.
|
||||||
|
_ = renderer.snapshot(atTime: 0, with: CGSize(width: 16, height: 16), antialiasingMode: .none)
|
||||||
|
return renderer.snapshot(atTime: 0, with: px, antialiasingMode: .multisampling4X)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func lightRig(into root: SCNNode, center: SCNVector3) {
|
||||||
|
func light(_ type: SCNLight.LightType, _ intensity: CGFloat, at pos: SCNVector3) {
|
||||||
|
let l = SCNLight(); l.type = type; l.intensity = intensity; l.temperature = 6500
|
||||||
|
l.castsShadow = false
|
||||||
|
let n = SCNNode(); n.light = l; n.position = pos; n.look(at: center)
|
||||||
|
root.addChildNode(n)
|
||||||
|
}
|
||||||
|
// Low ambient (the environment map does the fill); a crisp key for the highlight
|
||||||
|
// streak down the frame, a fill from the other side, and a top rim so the upper
|
||||||
|
// frame/bezel catches a highlight too instead of reading flat.
|
||||||
|
light(.ambient, 180, at: center)
|
||||||
|
light(.directional, 850, at: SCNVector3(center.x - 0.5, center.y + 0.6, center.z - 0.8))
|
||||||
|
light(.directional, 320, at: SCNVector3(center.x + 0.7, center.y - 0.2, center.z - 0.6))
|
||||||
|
light(.directional, 1100, at: SCNVector3(center.x - 0.15, center.y + 1.3, center.z - 0.5)) // top rim
|
||||||
|
light(.spot, 900, at: SCNVector3(center.x + 0.2, center.y + 1.1, center.z - 0.7)) // top glint
|
||||||
|
}
|
||||||
|
|
||||||
|
// A procedural equirectangular studio environment: bright "ceiling" softbox fading to
|
||||||
|
// a darker floor, so reflective surfaces show a gradient with a hot highlight band.
|
||||||
|
static let studioEnvironment: NSImage = {
|
||||||
|
let w = 1024, h = 512
|
||||||
|
let img = NSImage(size: NSSize(width: w, height: h))
|
||||||
|
img.lockFocus()
|
||||||
|
let grad = NSGradient(colorsAndLocations:
|
||||||
|
(NSColor(white: 1.0, alpha: 1), 0.0), // top = ceiling (bright)
|
||||||
|
(NSColor(white: 0.9, alpha: 1), 0.28),
|
||||||
|
(NSColor(white: 0.5, alpha: 1), 0.55),
|
||||||
|
(NSColor(white: 0.22, alpha: 1), 0.8),
|
||||||
|
(NSColor(white: 0.1, alpha: 1), 1.0)) // bottom = floor (dark)
|
||||||
|
grad?.draw(in: NSRect(x: 0, y: 0, width: w, height: h), angle: 90)
|
||||||
|
// Two softbox bands (upper "ceiling" + lower "floor bounce") so reflective
|
||||||
|
// surfaces catch a highlight at both the top and bottom of the frame.
|
||||||
|
NSColor.white.setFill()
|
||||||
|
NSBezierPath(roundedRect: NSRect(x: w / 4, y: Int(Double(h) * 0.66), width: w / 2, height: h / 8),
|
||||||
|
xRadius: 20, yRadius: 20).fill()
|
||||||
|
NSColor(white: 0.75, alpha: 1).setFill()
|
||||||
|
NSBezierPath(roundedRect: NSRect(x: w / 5, y: Int(Double(h) * 0.24), width: Int(Double(w) * 0.6), height: h / 10),
|
||||||
|
xRadius: 16, yRadius: 16).fill()
|
||||||
|
img.unlockFocus()
|
||||||
|
return img
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Tint the phone body graphite. Skips the screen, hidden glass, and camera lenses.
|
||||||
|
// Uses `multiply` so the metallic reflections survive (just darkened), giving a
|
||||||
|
// space-black look with sharp rail highlights rather than bright silver.
|
||||||
|
private func recolorBody(in node: SCNNode) {
|
||||||
|
let skip = ["screen", "glass", "lens", "logo"]
|
||||||
|
let graphite = NSColor(calibratedRed: 0.19, green: 0.19, blue: 0.21, alpha: 1)
|
||||||
|
func walk(_ n: SCNNode) {
|
||||||
|
for m in n.geometry?.materials ?? [] {
|
||||||
|
let name = (m.name ?? "").lowercased()
|
||||||
|
// Never tint the screen — its material name may not contain "screen"
|
||||||
|
// (the iPad's is just "Material").
|
||||||
|
if m.name == model.screenMaterial { continue }
|
||||||
|
if skip.contains(where: { name.contains($0) }) { continue }
|
||||||
|
m.multiply.contents = graphite
|
||||||
|
// Sharper, brighter specular so the frame highlights "pop" like polished
|
||||||
|
// metal instead of a soft satin.
|
||||||
|
m.metalness.contents = NSNumber(value: 1.0)
|
||||||
|
m.roughness.contents = NSNumber(value: 0.22)
|
||||||
|
}
|
||||||
|
n.childNodes.forEach(walk)
|
||||||
|
}
|
||||||
|
walk(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideMeshes(withMaterial substring: String, in node: SCNNode) {
|
||||||
|
if node.geometry?.materials.contains(where: { ($0.name ?? "").localizedCaseInsensitiveContains(substring) }) == true {
|
||||||
|
node.isHidden = true
|
||||||
|
}
|
||||||
|
for c in node.childNodes { hideMeshes(withMaterial: substring, in: c) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the screen mesh planar UVs (mapped over its two largest-extent axes) when it
|
||||||
|
// has none, so a screenshot texture maps across the display.
|
||||||
|
private func addPlanarUVsToScreen(in node: SCNNode, material name: String) {
|
||||||
|
guard let g = node.geometry,
|
||||||
|
g.materials.contains(where: { ($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame }),
|
||||||
|
g.sources(for: .texcoord).isEmpty,
|
||||||
|
let vsrc = g.sources(for: .vertex).first else {
|
||||||
|
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read vertex positions.
|
||||||
|
var pos: [(Float, Float, Float)] = []
|
||||||
|
let stride = vsrc.dataStride, off = vsrc.dataOffset
|
||||||
|
vsrc.data.withUnsafeBytes { (p: UnsafeRawBufferPointer) in
|
||||||
|
for i in 0..<vsrc.vectorCount {
|
||||||
|
let b = off + i * stride
|
||||||
|
pos.append((p.load(fromByteOffset: b, as: Float.self),
|
||||||
|
p.load(fromByteOffset: b + 4, as: Float.self),
|
||||||
|
p.load(fromByteOffset: b + 8, as: Float.self)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let xs = pos.map(\.0), ys = pos.map(\.1), zs = pos.map(\.2)
|
||||||
|
let ext = [xs.max()! - xs.min()!, ys.max()! - ys.min()!, zs.max()! - zs.min()!]
|
||||||
|
// Screen plane = the two axes with the largest extent (drop the thin normal axis).
|
||||||
|
let normalAxis = ext.firstIndex(of: ext.min()!)!
|
||||||
|
let planeAxes = [0, 1, 2].filter { $0 != normalAxis } // [u-axis, v-axis]
|
||||||
|
func comp(_ p: (Float, Float, Float), _ a: Int) -> Float { a == 0 ? p.0 : (a == 1 ? p.1 : p.2) }
|
||||||
|
let ua = planeAxes[0], va = planeAxes[1]
|
||||||
|
let umin = [xs, ys, zs][ua].min()!, urange = max(1e-6, ext[ua])
|
||||||
|
let vmin = [xs, ys, zs][va].min()!, vrange = max(1e-6, ext[va])
|
||||||
|
// Plain [0,1] planar mapping — the screenshot fills the mesh exactly. Any margin the
|
||||||
|
// display needs is baked into the image (see `matted`), not added here, so the clamped
|
||||||
|
// edge stays black instead of smearing the capture's corner pixels.
|
||||||
|
let uvs: [CGPoint] = pos.map {
|
||||||
|
CGPoint(x: CGFloat((comp($0, ua) - umin) / urange),
|
||||||
|
y: CGFloat(1 - (comp($0, va) - vmin) / vrange)) // flip V for top-left origin
|
||||||
|
}
|
||||||
|
let uvSource = SCNGeometrySource(textureCoordinates: uvs)
|
||||||
|
let newGeo = SCNGeometry(sources: g.sources(for: .vertex) + g.sources(for: .normal) + [uvSource],
|
||||||
|
elements: g.elements)
|
||||||
|
newGeo.materials = g.materials
|
||||||
|
node.geometry = newGeo
|
||||||
|
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the shot centred on a black canvas `pad` larger on each side, so texturing it
|
||||||
|
// leaves a thin black border around the window inside the display's rounded corners.
|
||||||
|
private func matted(_ shot: NSImage, pad: CGFloat) -> NSImage {
|
||||||
|
let s = shot.size
|
||||||
|
let canvas = NSSize(width: s.width * (1 + 2 * pad), height: s.height * (1 + 2 * pad))
|
||||||
|
let out = NSImage(size: canvas)
|
||||||
|
out.lockFocus()
|
||||||
|
NSColor.black.setFill()
|
||||||
|
NSRect(origin: .zero, size: canvas).fill()
|
||||||
|
shot.draw(in: NSRect(x: s.width * pad, y: s.height * pad, width: s.width, height: s.height))
|
||||||
|
out.unlockFocus()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private func materials(named name: String, in node: SCNNode) -> [SCNMaterial] {
|
||||||
|
var out = (node.geometry?.materials ?? []).filter {
|
||||||
|
($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame
|
||||||
|
}
|
||||||
|
for c in node.childNodes { out += materials(named: name, in: c) }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
230
packaging/apple/studio/Sources/studio/ScreenFrame.swift
Normal file
230
packaging/apple/studio/Sources/studio/ScreenFrame.swift
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// The full 1284x2778 marketing frame: gradient + globe + device + caption.
|
||||||
|
// Placement mirrors the old screens.typ: images are pinned top-left and pushed by
|
||||||
|
// (dx, dy); captions are centered and pinned to the top or bottom edge.
|
||||||
|
|
||||||
|
struct ScreenFrame: View {
|
||||||
|
let spec: ScreenSpec
|
||||||
|
let caption: Caption
|
||||||
|
let globe: Image?
|
||||||
|
let shot: NSImage?
|
||||||
|
let device: DeviceRenderer?
|
||||||
|
var canvas: CGSize = CGSize(width: 1284, height: 2778)
|
||||||
|
|
||||||
|
private var allDevices: [DeviceSpec] { spec.device.map { [$0] } ?? spec.devices }
|
||||||
|
|
||||||
|
private var titleColor: Color { spec.captionTheme == .light ? .white : Color(hex: "#1b1226") }
|
||||||
|
private var subColor: Color { spec.captionTheme == .light ? Color(hex: "#f3ecfb") : Color(hex: "#2c2138") }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let cw = canvas.width, ch = canvas.height
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
spec.bg.gradient
|
||||||
|
|
||||||
|
if let g = spec.globe, let globe {
|
||||||
|
globe.resizable().scaledToFit()
|
||||||
|
.frame(width: g.width)
|
||||||
|
.saturation(0.82)
|
||||||
|
.brightness(-0.06)
|
||||||
|
.offset(x: g.dx, y: g.dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route sits on the globe but behind the phone, so it appears to pass through.
|
||||||
|
if let rt = spec.route { routeLayer(rt) }
|
||||||
|
|
||||||
|
// Encryption flow (behind the phones so beams/stream tuck into them).
|
||||||
|
if let b = spec.beams { beamsLayer(b) }
|
||||||
|
if let s = spec.stream { streamLayer(s) }
|
||||||
|
|
||||||
|
if let device {
|
||||||
|
// The renderer bakes the pose into the 3D scene; we place each phone by
|
||||||
|
// its center on the canvas and add a soft contact shadow.
|
||||||
|
ForEach(Array(allDevices.enumerated()), id: \.offset) { _, d in
|
||||||
|
device.view(shot: shot, spec: d)
|
||||||
|
.shadow(color: d.shadow ? .black.opacity(0.28) : .clear,
|
||||||
|
radius: 60, x: 0, y: 34)
|
||||||
|
.position(x: d.cx, y: d.cy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Padlock + banner labels sit on top of the flow.
|
||||||
|
if let l = spec.lock { lockLayer(l) }
|
||||||
|
ForEach(Array(spec.banners.enumerated()), id: \.offset) { _, b in
|
||||||
|
bannerLayer(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
captionLayer
|
||||||
|
}
|
||||||
|
// Pin to top-leading: oversized layers (e.g. the globe frame is wider than the
|
||||||
|
// canvas) must be clipped from the origin, NOT re-centered — otherwise the whole
|
||||||
|
// composition, captions included, shifts left by (contentWidth - cw) / 2.
|
||||||
|
.frame(width: cw, height: ch, alignment: .topLeading)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bezier(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
|
||||||
|
let u = 1 - t
|
||||||
|
let a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t
|
||||||
|
return CGPoint(x: a * p0.x + b * c1.x + c * c2.x + d * p1.x,
|
||||||
|
y: a * p0.y + b * c1.y + c * c2.y + d * p1.y)
|
||||||
|
}
|
||||||
|
static func bezierTangent(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
|
||||||
|
let u = 1 - t
|
||||||
|
let a = 3 * u * u, b = 6 * u * t, c = 3 * t * t
|
||||||
|
return CGPoint(x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p1.x - c2.x),
|
||||||
|
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p1.y - c2.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer route: a glowing arc from the departure city to the arrival city, with a
|
||||||
|
// pulsing marker at each end. Drawn on the globe, behind the phone.
|
||||||
|
private func routeLayer(_ r: RouteSpec) -> some View {
|
||||||
|
let color = Color(hex: r.color)
|
||||||
|
let path = Path { p in
|
||||||
|
p.move(to: r.from)
|
||||||
|
p.addCurve(to: r.to, control1: r.c1, control2: r.c2)
|
||||||
|
}
|
||||||
|
return ZStack {
|
||||||
|
path.stroke(color.opacity(0.55), style: StrokeStyle(lineWidth: r.lineWidth * 2.6, lineCap: .round))
|
||||||
|
.blur(radius: 22)
|
||||||
|
path.stroke(
|
||||||
|
LinearGradient(colors: [Color(hex: "#c98bff"), Color(hex: "#7c3aed"), Color(hex: "#c98bff")],
|
||||||
|
startPoint: .topTrailing, endPoint: .bottomLeading),
|
||||||
|
style: StrokeStyle(lineWidth: r.lineWidth, lineCap: .round))
|
||||||
|
path.stroke(.white.opacity(0.85), style: StrokeStyle(lineWidth: r.lineWidth * 0.35, lineCap: .round))
|
||||||
|
.blur(radius: 1)
|
||||||
|
marker(at: r.from, color)
|
||||||
|
marker(at: r.to, color)
|
||||||
|
}
|
||||||
|
.frame(width: canvas.width, height: canvas.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func marker(at pt: CGPoint, _ color: Color) -> some View {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(color.opacity(0.5)).frame(width: 60, height: 60).blur(radius: 14)
|
||||||
|
Circle().fill(color).frame(width: 30, height: 30)
|
||||||
|
Circle().fill(.white).frame(width: 14, height: 14)
|
||||||
|
}
|
||||||
|
.position(pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converging encryption beams: thin glowing lines fanning from the top phone's edge
|
||||||
|
// down to a single convergence point (above the lock).
|
||||||
|
private func beamsLayer(_ b: BeamsSpec) -> some View {
|
||||||
|
let color = Color(hex: b.color)
|
||||||
|
return Canvas { ctx, _ in
|
||||||
|
for i in 0..<b.count {
|
||||||
|
let f = b.count == 1 ? 0 : Double(i) / Double(b.count - 1) - 0.5 // -0.5…0.5
|
||||||
|
// Fan across the cross axis from the start line, converging on (cx, cy).
|
||||||
|
let start = b.horizontal
|
||||||
|
? CGPoint(x: b.y0, y: b.cy + CGFloat(f) * 2 * b.spread)
|
||||||
|
: CGPoint(x: b.cx + CGFloat(f) * 2 * b.spread, y: b.y0)
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: start)
|
||||||
|
path.addLine(to: CGPoint(x: b.cx, y: b.cy))
|
||||||
|
let op = 0.25 + 0.35 * (1 - abs(f) * 2) // brighter toward the centre beam
|
||||||
|
ctx.stroke(path, with: .color(color.opacity(op)),
|
||||||
|
style: StrokeStyle(lineWidth: 2.2, lineCap: .round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: canvas.width, height: canvas.height)
|
||||||
|
.blur(radius: 0.6)
|
||||||
|
.shadow(color: color.opacity(0.7), radius: 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical binary "protection" stream from the lock down to the receiving phone.
|
||||||
|
private func streamLayer(_ s: StreamSpec) -> some View {
|
||||||
|
let color = Color(hex: s.color)
|
||||||
|
return Canvas { ctx, _ in
|
||||||
|
let spacing = 44.0
|
||||||
|
let count = max(1, Int((s.y1 - s.y0) / spacing))
|
||||||
|
for i in 0...count {
|
||||||
|
let t = Double(i) / Double(count)
|
||||||
|
let along = s.y0 + (s.y1 - s.y0) * t
|
||||||
|
let at = s.horizontal ? CGPoint(x: along, y: s.cx) : CGPoint(x: s.cx, y: along)
|
||||||
|
let bit = (i % 3 == 0) ? "0" : "1"
|
||||||
|
// fade in near the lock and out near the receiving device
|
||||||
|
let op = 0.5 + 0.5 * sin(Double.pi * t)
|
||||||
|
var res = ctx.resolve(Text(bit)
|
||||||
|
.font(.system(size: 30, weight: .semibold, design: .monospaced)))
|
||||||
|
res.shading = .color(color.opacity(op))
|
||||||
|
ctx.draw(res, at: at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: canvas.width, height: canvas.height)
|
||||||
|
.shadow(color: color.opacity(0.6), radius: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glowing padlock (SF Symbol) — the encryption focal point.
|
||||||
|
private func lockLayer(_ l: LockSpec) -> some View {
|
||||||
|
let color = Color(hex: l.color)
|
||||||
|
return Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: l.size, weight: .regular))
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(colors: [.white, color], startPoint: .top, endPoint: .bottom))
|
||||||
|
.shadow(color: color.opacity(0.9), radius: 30)
|
||||||
|
.shadow(color: color.opacity(0.6), radius: 60)
|
||||||
|
.position(x: l.cx, y: l.cy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localized banner label (CHIFFREMENT / PROTECTION) from strings.json.
|
||||||
|
private func bannerLayer(_ b: Banner) -> some View {
|
||||||
|
let text = (b.kind == .encryption ? caption.encryption : caption.protection) ?? ""
|
||||||
|
return Text(text)
|
||||||
|
.font(.system(size: 48, weight: .bold))
|
||||||
|
.tracking(3)
|
||||||
|
.foregroundStyle(Color(hex: "#e7dcf7"))
|
||||||
|
.position(x: b.cx, y: b.cy)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func captionBlock(_ titleSize: CGFloat, _ subSize: CGFloat) -> some View {
|
||||||
|
let content = VStack(spacing: 10) {
|
||||||
|
Text(caption.title)
|
||||||
|
.font(.system(size: titleSize, weight: .heavy))
|
||||||
|
.foregroundStyle(titleColor)
|
||||||
|
Text(caption.subtitle)
|
||||||
|
.font(.system(size: subSize, weight: .semibold))
|
||||||
|
.foregroundStyle(subColor)
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(width: canvas.width - (spec.headerBackdrop ? 300 : 160))
|
||||||
|
|
||||||
|
if spec.headerBackdrop {
|
||||||
|
// The panel is the caption's background, so it grows with the content — long
|
||||||
|
// translations (Russian, etc.) get a taller/wider backdrop automatically.
|
||||||
|
let shape = RoundedRectangle(cornerRadius: 48, style: .continuous)
|
||||||
|
content
|
||||||
|
.padding(.horizontal, 56)
|
||||||
|
.padding(.vertical, 44)
|
||||||
|
.background(shape.fill(Color(hex: "#180a30").opacity(0.82))
|
||||||
|
.overlay(shape.stroke(Color.white.opacity(0.16), lineWidth: 1.5)))
|
||||||
|
.shadow(color: .black.opacity(0.35), radius: 24, y: 10)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var captionLayer: some View {
|
||||||
|
let top = spec.captionPlace == .top
|
||||||
|
// Keep the size for short captions, but shrink long translations (e.g. Russian /
|
||||||
|
// Polish / Portuguese wrap both lines) so the taller block still fits its band and
|
||||||
|
// never overlaps the phone. ViewThatFits picks the largest variant that fits.
|
||||||
|
// The top band is shorter than the bottom one because those screens' phones are
|
||||||
|
// large and start high, leaving less room above them.
|
||||||
|
let region: CGFloat = top ? (spec.headerBackdrop ? 560 : 290) : 360
|
||||||
|
let fitted = ViewThatFits(in: .vertical) {
|
||||||
|
captionBlock(104, 60)
|
||||||
|
captionBlock(92, 54)
|
||||||
|
captionBlock(82, 48)
|
||||||
|
captionBlock(72, 44)
|
||||||
|
}
|
||||||
|
.frame(width: canvas.width, height: region, alignment: top ? .top : .bottom)
|
||||||
|
|
||||||
|
return fitted
|
||||||
|
.padding(top ? .top : .bottom, top ? 96 : 110)
|
||||||
|
.frame(width: canvas.width, height: canvas.height,
|
||||||
|
alignment: top ? .top : .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
302
packaging/apple/studio/Sources/studio/ScreenSpec.swift
Normal file
302
packaging/apple/studio/Sources/studio/ScreenSpec.swift
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Layout data for each marketing screen. Numbers are in pixels (the canvas renders
|
||||||
|
// at scale 1, so a value of 104 == 104px). Ported from the old screens.typ `screens`
|
||||||
|
// dict — tune these against the originals.
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
// "#rrggbb"
|
||||||
|
init(hex: String) {
|
||||||
|
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||||
|
let v = UInt64(s, radix: 16) ?? 0
|
||||||
|
self.init(
|
||||||
|
.sRGB,
|
||||||
|
red: Double((v >> 16) & 0xff) / 255,
|
||||||
|
green: Double((v >> 8) & 0xff) / 255,
|
||||||
|
blue: Double(v & 0xff) / 255,
|
||||||
|
opacity: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CaptionPlace { case top, bottom }
|
||||||
|
enum CaptionTheme { case dark, light }
|
||||||
|
|
||||||
|
struct GradientSpec {
|
||||||
|
let stops: [String] // hex colors, top→bottom / start→end
|
||||||
|
let start: UnitPoint
|
||||||
|
let end: UnitPoint
|
||||||
|
|
||||||
|
var gradient: LinearGradient {
|
||||||
|
LinearGradient(colors: stops.map { Color(hex: $0) }, startPoint: start, endPoint: end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 3D device layer. The screenshot is textured onto the model's screen and the phone
|
||||||
|
// is posed in real 3D (pitch/yaw/roll), positioned by its center on the canvas and
|
||||||
|
// sized by its upright height — matching how the originals were laid out.
|
||||||
|
struct Pose {
|
||||||
|
var pitch: CGFloat = 0 // deg, about X (top tips away/toward viewer)
|
||||||
|
var yaw: CGFloat = 0 // deg, about Y (turn left/right, reveals a side edge)
|
||||||
|
var roll: CGFloat = 0 // deg, about Z (in-plane spin)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeviceSpec {
|
||||||
|
var height: CGFloat // upright phone height in canvas px (drives scale)
|
||||||
|
var cx: CGFloat // phone center X on the 1284-wide canvas
|
||||||
|
var cy: CGFloat // phone center Y on the 2778-tall canvas
|
||||||
|
var pose: Pose = Pose()
|
||||||
|
var shadow: Bool = true // soft contact shadow under the phone
|
||||||
|
var blackScreen: Bool = false // texture the screen solid black (ignore the shot)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobeSpec {
|
||||||
|
var width: CGFloat
|
||||||
|
var dx: CGFloat
|
||||||
|
var dy: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transfer route: two city markers (departure + arrival) on the globe joined by a
|
||||||
|
// glowing arc that swoops down and passes behind the phone — "send from Paris to LA,
|
||||||
|
// through your phone". Generated natively.
|
||||||
|
struct RouteSpec {
|
||||||
|
var from: CGPoint // departure marker (on the globe)
|
||||||
|
var to: CGPoint // arrival marker (on the globe)
|
||||||
|
var c1: CGPoint // Bézier controls — pull down to bulge the arc through the phone
|
||||||
|
var c2: CGPoint
|
||||||
|
var lineWidth: CGFloat = 12
|
||||||
|
var color: String = "#a855f7"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stay-private redesign: a vertical encryption flow between two stacked phones —
|
||||||
|
// converging light beams (encryption) → a glowing padlock → a binary stream (protection).
|
||||||
|
struct BeamsSpec {
|
||||||
|
var cx: CGFloat // convergence x
|
||||||
|
var y0: CGFloat // start-line coordinate: the y the beams fan from (x when horizontal)
|
||||||
|
var spread: CGFloat // half-extent the beams fan across at the start line
|
||||||
|
var cy: CGFloat // converge to this point
|
||||||
|
var count: Int = 22
|
||||||
|
var color: String = "#9ec3ff"
|
||||||
|
// Horizontal: beams fan across y from a vertical start line and converge left→right
|
||||||
|
// (for side-by-side devices) instead of fanning across x from a horizontal line.
|
||||||
|
var horizontal: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LockSpec {
|
||||||
|
var cx: CGFloat
|
||||||
|
var cy: CGFloat
|
||||||
|
var size: CGFloat
|
||||||
|
var color: String = "#a9c9ff"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamSpec {
|
||||||
|
var cx: CGFloat // cross-axis centre (x for a vertical stream, y for a horizontal one)
|
||||||
|
var y0: CGFloat // along-axis start (below the lock; right of the lock when horizontal)
|
||||||
|
var y1: CGFloat // along-axis end (the receiving device)
|
||||||
|
var color: String = "#9ec3ff"
|
||||||
|
var horizontal: Bool = false // flow left→right instead of top→bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json.
|
||||||
|
enum BannerKind { case encryption, protection }
|
||||||
|
struct Banner {
|
||||||
|
var kind: BannerKind
|
||||||
|
var cx: CGFloat
|
||||||
|
var cy: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScreenSpec {
|
||||||
|
let id: String
|
||||||
|
let bg: GradientSpec
|
||||||
|
let captionPlace: CaptionPlace
|
||||||
|
let captionTheme: CaptionTheme
|
||||||
|
var globe: GlobeSpec? = nil
|
||||||
|
var route: RouteSpec? = nil
|
||||||
|
var device: DeviceSpec? = nil
|
||||||
|
var devices: [DeviceSpec] = [] // multiple phones (e.g. stay-private)
|
||||||
|
var beams: BeamsSpec? = nil
|
||||||
|
var lock: LockSpec? = nil
|
||||||
|
var stream: StreamSpec? = nil
|
||||||
|
var banners: [Banner] = []
|
||||||
|
var headerBackdrop: Bool = false // dark scrim behind the top caption
|
||||||
|
// Which captured screenshot to texture (defaults to `id`). The hero reuses the
|
||||||
|
// approval modal shot, matching the original.
|
||||||
|
var shotId: String? = nil
|
||||||
|
|
||||||
|
static func all(for platform: Platform) -> [String: ScreenSpec] {
|
||||||
|
switch platform {
|
||||||
|
case .iphone: iphone
|
||||||
|
case .ipad: ipad
|
||||||
|
case .mac: mac
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let iphone: [String: ScreenSpec] = [
|
||||||
|
// Straight-on hero, large and centered, showing the Share/QR sheet.
|
||||||
|
"share-securely": ScreenSpec(
|
||||||
|
id: "share-securely",
|
||||||
|
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(
|
||||||
|
height: 2300, cx: 642, cy: 1560,
|
||||||
|
pose: Pose(pitch: 0, yaw: 0, roll: 0))
|
||||||
|
),
|
||||||
|
// Tilted hero: turned to reveal the right edge, top sloping down-right.
|
||||||
|
"choose-receivers": ScreenSpec(
|
||||||
|
id: "choose-receivers",
|
||||||
|
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(
|
||||||
|
height: 2160, cx: 620, cy: 1620,
|
||||||
|
pose: Pose(pitch: -9, yaw: -14, roll: 7))
|
||||||
|
),
|
||||||
|
// Strongly tilted, lying diagonally in the lower-left; globe + orbit above.
|
||||||
|
"send-anywhere": ScreenSpec(
|
||||||
|
id: "send-anywhere",
|
||||||
|
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .bottom, captionTheme: .dark,
|
||||||
|
globe: GlobeSpec(width: 1520, dx: -10, dy: -200),
|
||||||
|
route: RouteSpec(
|
||||||
|
from: CGPoint(x: 1284 - 289, y: 226), // departure — Paris (Europe, right of globe)
|
||||||
|
to: CGPoint(x: 211, y: 296), // arrival — Los Angeles (upper-left of globe)
|
||||||
|
c1: CGPoint(x: 1500, y: 1980), // pull the arc down through the phone
|
||||||
|
c2: CGPoint(x: -300, y: 1980),
|
||||||
|
lineWidth: 12),
|
||||||
|
device: DeviceSpec(
|
||||||
|
height: 1550, cx: 581, cy: 1600,
|
||||||
|
pose: Pose(pitch: 35, yaw: 35, roll: 15)),
|
||||||
|
shotId: "share-securely"
|
||||||
|
),
|
||||||
|
// Two partial phones with black screens + a generated binary data ribbon.
|
||||||
|
// Vertical encryption flow: two stacked centred phones, converging beams into a
|
||||||
|
// glowing padlock, then a binary "protection" stream down to the receiver.
|
||||||
|
"stay-private": ScreenSpec(
|
||||||
|
id: "stay-private",
|
||||||
|
bg: GradientSpec(
|
||||||
|
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"],
|
||||||
|
start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .light,
|
||||||
|
devices: [
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1500, cx: 642, cy: -20, // top phone, only its lower part shows
|
||||||
|
pose: Pose(roll: 180), shadow: false, blackScreen: true),
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1500, cx: 642, cy: 2820, // bottom phone, only its upper part shows
|
||||||
|
pose: Pose(), shadow: false, blackScreen: true),
|
||||||
|
],
|
||||||
|
beams: BeamsSpec(cx: 642, y0: 700, spread: 150, cy: 1120, count: 22),
|
||||||
|
lock: LockSpec(cx: 642, cy: 1330, size: 300),
|
||||||
|
stream: StreamSpec(cx: 642, y0: 1520, y1: 2360),
|
||||||
|
banners: [
|
||||||
|
Banner(kind: .encryption, cx: 642, cy: 1120),
|
||||||
|
Banner(kind: .protection, cx: 642, cy: 1620),
|
||||||
|
],
|
||||||
|
headerBackdrop: true
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
// iPad Pro layouts (canvas 2048x2732, centre x = 1024). First pass — same
|
||||||
|
// compositions as iPhone, retuned for the squarer, larger canvas.
|
||||||
|
static let ipad: [String: ScreenSpec] = [
|
||||||
|
"share-securely": ScreenSpec(
|
||||||
|
id: "share-securely",
|
||||||
|
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(height: 1950, cx: 1024, cy: 1520, pose: Pose())
|
||||||
|
),
|
||||||
|
"choose-receivers": ScreenSpec(
|
||||||
|
id: "choose-receivers",
|
||||||
|
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(
|
||||||
|
height: 1950, cx: 1000, cy: 1460, pose: Pose(pitch: -9, yaw: -14, roll: 7))
|
||||||
|
),
|
||||||
|
"send-anywhere": ScreenSpec(
|
||||||
|
id: "send-anywhere",
|
||||||
|
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .bottom, captionTheme: .dark,
|
||||||
|
globe: GlobeSpec(width: 2000, dx: 24, dy: -240),
|
||||||
|
route: RouteSpec(
|
||||||
|
from: CGPoint(x: 1342, y: 323), to: CGPoint(x: 315, y: 423),
|
||||||
|
c1: CGPoint(x: 2100, y: 2000), c2: CGPoint(x: -100, y: 2000), lineWidth: 14),
|
||||||
|
device: DeviceSpec(
|
||||||
|
height: 1180, cx: 1000, cy: 1720, pose: Pose(pitch: 12, yaw: 18, roll: -11)),
|
||||||
|
shotId: "share-securely"
|
||||||
|
),
|
||||||
|
"stay-private": ScreenSpec(
|
||||||
|
id: "stay-private",
|
||||||
|
bg: GradientSpec(
|
||||||
|
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .light,
|
||||||
|
devices: [
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1300, cx: 1024, cy: 120, pose: Pose(roll: 180), shadow: false,
|
||||||
|
blackScreen: true),
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1300, cx: 1024, cy: 2760, pose: Pose(), shadow: false, blackScreen: true
|
||||||
|
),
|
||||||
|
],
|
||||||
|
beams: BeamsSpec(cx: 1024, y0: 720, spread: 200, cy: 1220, count: 24),
|
||||||
|
lock: LockSpec(cx: 1024, cy: 1400, size: 320),
|
||||||
|
stream: StreamSpec(cx: 1024, y0: 1600, y1: 2360),
|
||||||
|
banners: [
|
||||||
|
Banner(kind: .encryption, cx: 1024, cy: 1200),
|
||||||
|
Banner(kind: .protection, cx: 1024, cy: 1680),
|
||||||
|
],
|
||||||
|
headerBackdrop: true
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MacBook layouts (canvas 2880x1800, landscape, centre x = 1440). First pass.
|
||||||
|
static let mac: [String: ScreenSpec] = [
|
||||||
|
"share-securely": ScreenSpec(
|
||||||
|
id: "share-securely",
|
||||||
|
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(height: 1500, cx: 1440, cy: 1110, pose: Pose())
|
||||||
|
),
|
||||||
|
"choose-receivers": ScreenSpec(
|
||||||
|
id: "choose-receivers",
|
||||||
|
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(height: 1500, cx: 1440, cy: 1110, pose: Pose())
|
||||||
|
),
|
||||||
|
// Globe backdrop in the upper half, route arcing through a centred laptop, caption
|
||||||
|
// at the bottom. Reuses the share capture (matches the other platforms).
|
||||||
|
"send-anywhere": ScreenSpec(
|
||||||
|
id: "send-anywhere",
|
||||||
|
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .bottom, captionTheme: .light,
|
||||||
|
globe: GlobeSpec(width: 1320, dx: 780, dy: -150),
|
||||||
|
route: RouteSpec(
|
||||||
|
from: CGPoint(x: 1650, y: 222), to: CGPoint(x: 976, y: 274),
|
||||||
|
c1: CGPoint(x: 5200, y: 1150), c2: CGPoint(x: -2000, y: 1150), lineWidth: 13),
|
||||||
|
device: DeviceSpec(height: 1300, cx: 1440, cy: 960, pose: Pose()),
|
||||||
|
headerBackdrop: true,
|
||||||
|
shotId: "share-securely"
|
||||||
|
),
|
||||||
|
// Vertical encryption flow between two stacked laptops (same structure as the phone
|
||||||
|
// layouts, retuned for the landscape canvas).
|
||||||
|
"stay-private": ScreenSpec(
|
||||||
|
id: "stay-private",
|
||||||
|
bg: GradientSpec(
|
||||||
|
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .light,
|
||||||
|
// Two laptops on the left and right; encryption flows horizontally between them:
|
||||||
|
// beams converge left→lock, a binary stream runs lock→right.
|
||||||
|
devices: [
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1040, cx: 120, cy: 1000, pose: Pose(), shadow: false, blackScreen: true),
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1040, cx: 2760, cy: 1000, pose: Pose(), shadow: false, blackScreen: true
|
||||||
|
),
|
||||||
|
],
|
||||||
|
beams: BeamsSpec(cx: 1290, y0: 760, spread: 300, cy: 1000, count: 26, horizontal: true),
|
||||||
|
lock: LockSpec(cx: 1440, cy: 1000, size: 300),
|
||||||
|
stream: StreamSpec(cx: 1000, y0: 1600, y1: 2140, horizontal: true),
|
||||||
|
banners: [
|
||||||
|
Banner(kind: .encryption, cx: 940, cy: 1360),
|
||||||
|
Banner(kind: .protection, cx: 1960, cy: 1360),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
51
packaging/apple/studio/Sources/studio/Strings.swift
Normal file
51
packaging/apple/studio/Sources/studio/Strings.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Mirrors strings.json. Captions live only here (not app strings).
|
||||||
|
struct Caption: Decodable {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
// Optional in-composition banner labels (stay-private): CHIFFREMENT / PROTECTION.
|
||||||
|
let encryption: String?
|
||||||
|
let protection: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocaleStrings: Decodable {
|
||||||
|
let folder: String
|
||||||
|
private let screens: [String: Caption]
|
||||||
|
|
||||||
|
subscript(_ screen: String) -> Caption? { screens[screen] }
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey { case folder = "_folder" }
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
// `_folder` is a reserved key; every other key is a screen id -> Caption.
|
||||||
|
let dyn = try decoder.container(keyedBy: DynamicKey.self)
|
||||||
|
var acc: [String: Caption] = [:]
|
||||||
|
var folderName = ""
|
||||||
|
for key in dyn.allKeys {
|
||||||
|
if key.stringValue == "_folder" {
|
||||||
|
folderName = try dyn.decode(String.self, forKey: key)
|
||||||
|
} else if let cap = try? dyn.decode(Caption.self, forKey: key) {
|
||||||
|
acc[key.stringValue] = cap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.folder = folderName
|
||||||
|
self.screens = acc
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DynamicKey: CodingKey {
|
||||||
|
var stringValue: String
|
||||||
|
var intValue: Int? { nil }
|
||||||
|
init?(stringValue: String) { self.stringValue = stringValue }
|
||||||
|
init?(intValue: Int) { nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Strings: Decodable {
|
||||||
|
let screens: [String]
|
||||||
|
let locales: [String: LocaleStrings]
|
||||||
|
|
||||||
|
static func load(_ url: URL) throws -> Strings {
|
||||||
|
try JSONDecoder().decode(Strings.self, from: Data(contentsOf: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
111
packaging/apple/studio/Sources/studio/main.swift
Normal file
111
packaging/apple/studio/Sources/studio/main.swift
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Code-driven App Store screenshots, rendered natively with SwiftUI + ImageRenderer.
|
||||||
|
//
|
||||||
|
// swift run studio # -> generated/<Language>/<Name>.png
|
||||||
|
// swift run studio --publish # -> ../<Language>/<Name>.png (ships)
|
||||||
|
// LOCALES="fr de" SCREENS="share-securely" swift run studio
|
||||||
|
//
|
||||||
|
// Screenshots come from generated/shots/<platform>/<locale>/<screen>.png (from
|
||||||
|
// ./capture.sh) — transient build output, regenerated per run, never committed. The 3D
|
||||||
|
// device models + globe come from assets/. Run from the studio directory.
|
||||||
|
|
||||||
|
// Screen id -> output file basename (matches the existing App Store filenames).
|
||||||
|
let nameFor: [String: String] = [
|
||||||
|
"choose-receivers": "Choose Receivers", "send-anywhere": "Send Anywhere",
|
||||||
|
"share-securely": "Share Securely", "stay-private": "Stay private",
|
||||||
|
]
|
||||||
|
|
||||||
|
func env(_ key: String, _ fallback: String) -> String {
|
||||||
|
let v = ProcessInfo.processInfo.environment[key]
|
||||||
|
return (v?.isEmpty == false) ? v! : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadNSImage(_ path: String) -> (NSImage, CGSize)? {
|
||||||
|
guard let ns = NSImage(contentsOfFile: path) else { return nil }
|
||||||
|
let size = ns.representations.first.map { CGSize(width: $0.pixelsWide, height: $0.pixelsHigh) } ?? ns.size
|
||||||
|
return (ns, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func writePNG(_ view: some View, to url: URL) throws {
|
||||||
|
let renderer = ImageRenderer(content: view)
|
||||||
|
renderer.scale = 1
|
||||||
|
guard let cg = renderer.cgImage else {
|
||||||
|
throw NSError(domain: "studio", code: 1, userInfo: [NSLocalizedDescriptionKey: "render failed"])
|
||||||
|
}
|
||||||
|
let rep = NSBitmapImageRep(cgImage: cg)
|
||||||
|
rep.size = NSSize(width: cg.width, height: cg.height)
|
||||||
|
guard let data = rep.representation(using: .png, properties: [:]) else {
|
||||||
|
throw NSError(domain: "studio", code: 2, userInfo: [NSLocalizedDescriptionKey: "png encode failed"])
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
try data.write(to: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func run() throws {
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
let publish = args.contains("--publish")
|
||||||
|
let cwd = FileManager.default.currentDirectoryPath
|
||||||
|
let base = URL(fileURLWithPath: cwd)
|
||||||
|
|
||||||
|
let strings = try Strings.load(base.appendingPathComponent("strings.json"))
|
||||||
|
|
||||||
|
let locales = env("LOCALES", "en fr de es it nl pl pt ru").split(separator: " ").map(String.init)
|
||||||
|
let screens = env("SCREENS", "choose-receivers send-anywhere share-securely stay-private")
|
||||||
|
.split(separator: " ").map(String.init)
|
||||||
|
|
||||||
|
let globe = loadNSImage(base.appendingPathComponent("assets/globe.png").path).map { Image(nsImage: $0.0) }
|
||||||
|
|
||||||
|
// Target platform: canvas size + 3D device model. PLATFORM=iphone|ipad (default iphone).
|
||||||
|
let platform = Platform(rawValue: env("PLATFORM", "iphone")) ?? .iphone
|
||||||
|
let canvas = platform.canvas
|
||||||
|
let model = platform.deviceModel(assets: base.appendingPathComponent("assets"))
|
||||||
|
let device: DeviceRenderer? = FileManager.default.fileExists(atPath: model.url.path)
|
||||||
|
? SceneKitDeviceRenderer(model: model) : nil
|
||||||
|
if device == nil {
|
||||||
|
FileHandle.standardError.write(Data("warning: missing model \(model.url.lastPathComponent) — devices will be blank\n".utf8))
|
||||||
|
}
|
||||||
|
let specs = ScreenSpec.all(for: platform)
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
for loc in locales {
|
||||||
|
guard let ls = strings.locales[loc] else {
|
||||||
|
FileHandle.standardError.write(Data("warning: no strings for locale \(loc)\n".utf8)); continue
|
||||||
|
}
|
||||||
|
// iPhone -> <Language>/, iPad -> <Language>/iPad/ so the sets stay separate.
|
||||||
|
var outDir = publish
|
||||||
|
? base.appendingPathComponent("../\(ls.folder)").standardized
|
||||||
|
: base.appendingPathComponent("generated/\(ls.folder)")
|
||||||
|
if !platform.outputSubfolder.isEmpty {
|
||||||
|
outDir = outDir.appendingPathComponent(platform.outputSubfolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
for scr in screens {
|
||||||
|
guard let spec = specs[scr], let caption = ls[scr] else {
|
||||||
|
FileHandle.standardError.write(Data("warning: skipping \(loc)/\(scr)\n".utf8)); continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let shotId = spec.shotId ?? scr
|
||||||
|
let shotsDir = env("SHOTS_DIR", "generated/shots/\(platform.rawValue)")
|
||||||
|
let shot = loadNSImage(base.appendingPathComponent("\(shotsDir)/\(loc)/\(shotId).png").path)?.0
|
||||||
|
let hasDevice = spec.device != nil || !spec.devices.isEmpty
|
||||||
|
let frame = ScreenFrame(spec: spec, caption: caption, globe: globe, shot: shot,
|
||||||
|
device: hasDevice ? device : nil, canvas: canvas)
|
||||||
|
let out = outDir.appendingPathComponent("\(nameFor[scr] ?? scr).png")
|
||||||
|
try writePNG(frame, to: out)
|
||||||
|
print(" ✅ \(out.path)")
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print("\nDone — \(count) screenshot(s)\(publish ? " (published)" : "").")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageRenderer needs an AppKit context for text/font resolution.
|
||||||
|
let app = NSApplication.shared
|
||||||
|
app.setActivationPolicy(.prohibited)
|
||||||
|
do { try MainActor.assumeIsolated { try run() } }
|
||||||
|
catch { FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)); exit(1) }
|
||||||
22
packaging/apple/studio/assets/ATTRIBUTION.md
Normal file
22
packaging/apple/studio/assets/ATTRIBUTION.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Third-party asset attributions
|
||||||
|
|
||||||
|
## iphone-17-pro-max.usdz
|
||||||
|
- **Title:** iphone 17 pro max silver
|
||||||
|
- **Author:** TechFreak (Sketchfab)
|
||||||
|
- **Source:** https://sketchfab.com/3d-models/iphone-17-pro-max-silver-9dbfe0d846f341bf9fc501a854f5de1a
|
||||||
|
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
|
||||||
|
|
||||||
|
## ipad-pro.usdz
|
||||||
|
- **Title:** 2018 Apple iPad Pro
|
||||||
|
- **Author:** lazercar (Sketchfab)
|
||||||
|
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
|
||||||
|
|
||||||
|
## macbook-air.usdz
|
||||||
|
- **Title:** MacBook Air
|
||||||
|
- **Author:** Jakob (Sketchfab — https://sketchfab.com/jakob3dlindblom)
|
||||||
|
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
|
||||||
|
|
||||||
|
CC BY 4.0 requires visible credit wherever these models (or renders derived from them)
|
||||||
|
are published. The App Store screenshots produced by this studio are derivatives, so
|
||||||
|
keep this attribution with the project. If a suitable place exists (e.g. the app's
|
||||||
|
acknowledgements / licenses screen), surface the credit there as well.
|
||||||
BIN
packaging/apple/studio/assets/globe.png
Normal file
BIN
packaging/apple/studio/assets/globe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 MiB |
BIN
packaging/apple/studio/assets/ipad-pro.usdz
Normal file
BIN
packaging/apple/studio/assets/ipad-pro.usdz
Normal file
Binary file not shown.
BIN
packaging/apple/studio/assets/iphone-17-pro-max.usdz
Normal file
BIN
packaging/apple/studio/assets/iphone-17-pro-max.usdz
Normal file
Binary file not shown.
BIN
packaging/apple/studio/assets/macbook-air.usdz
Normal file
BIN
packaging/apple/studio/assets/macbook-air.usdz
Normal file
Binary file not shown.
120
packaging/apple/studio/capture.sh
Executable file
120
packaging/apple/studio/capture.sh
Executable file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/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)"
|
||||||
|
BUNDLE_ID="com.vnidrop.app"
|
||||||
|
LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"}
|
||||||
|
|
||||||
|
# PLATFORM=iphone|ipad selects the simulator and the shots subdir (must match main.swift).
|
||||||
|
PLATFORM="${PLATFORM:-iphone}"
|
||||||
|
case "$PLATFORM" in
|
||||||
|
ipad) DEVICE="${SCREENSHOT_DEVICE:-iPad Pro 13-inch (M5)}";;
|
||||||
|
*) DEVICE="${SCREENSHOT_DEVICE:-iPhone 17 Pro Max}";;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# scenario (launch arg value) -> studio screen id (output filename stem).
|
||||||
|
# send-anywhere reuses the share screenshot (see ScreenSpec shotId), so it isn't
|
||||||
|
# captured separately; stay-private uses black screens (no capture).
|
||||||
|
SCENARIOS="share:share-securely approval:choose-receivers"
|
||||||
|
|
||||||
|
# Screenshots are transient build output, regenerated per run — never committed. They
|
||||||
|
# live under generated/ (git-ignored), not in assets/.
|
||||||
|
SHOTS_DIR="${SHOTS_DIR:-generated/shots/$PLATFORM}"
|
||||||
|
|
||||||
|
# ---- macOS: no simulator. Build the native app, drive it with the fixture args, size its
|
||||||
|
# window to 16:10 and grab it with screencapture. Needs Accessibility + Screen Recording
|
||||||
|
# permission granted to the terminal (you'll be prompted the first time).
|
||||||
|
if [ "$PLATFORM" = "mac" ]; then
|
||||||
|
WIN_X=60; WIN_Y=80; WIN_W=1440; WIN_H=900 # 16:10, matches the MacBook screen aspect
|
||||||
|
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)
|
||||||
|
echo "==> Building VniDrop (Debug) for macOS"
|
||||||
|
xcodebuild build -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
|
||||||
|
-destination 'platform=macOS' -configuration Debug CODE_SIGNING_ALLOWED=NO >/dev/null
|
||||||
|
APP="$(xcodebuild -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
|
||||||
|
-destination 'platform=macOS' -configuration Debug -showBuildSettings 2>/dev/null \
|
||||||
|
| awk -F' = ' '/ BUILT_PRODUCTS_DIR /{print $2; exit}')/VniDrop.app"
|
||||||
|
EXE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist" 2>/dev/null || echo VniDrop)"
|
||||||
|
EXE="$APP/Contents/MacOS/$EXE_NAME"
|
||||||
|
echo " app: $APP (exe: $EXE_NAME)"
|
||||||
|
echo " (grant Accessibility + Screen Recording to your terminal if prompted)"
|
||||||
|
for loc in $LOCALES; do
|
||||||
|
mkdir -p "$SHOTS_DIR/$loc"
|
||||||
|
for pair in $SCENARIOS; do
|
||||||
|
scenario="${pair%%:*}"; screen="${pair##*:}"
|
||||||
|
pkill -x "$EXE_NAME" 2>/dev/null || true; sleep 1
|
||||||
|
# Launch the binary directly (avoids LaunchServices -1712 for DerivedData apps).
|
||||||
|
"$EXE" -VniScreenshot "$scenario" \
|
||||||
|
-AppleLanguages "($loc)" -AppleLocale "$loc" -AppleInterfaceStyle Dark &
|
||||||
|
sleep 4
|
||||||
|
osascript >/dev/null 2>&1 <<-OSA || true
|
||||||
|
tell application "System Events" to tell process "$EXE_NAME"
|
||||||
|
set frontmost to true
|
||||||
|
set position of front window to {$WIN_X, $WIN_Y}
|
||||||
|
set size of front window to {$WIN_W, $WIN_H}
|
||||||
|
end tell
|
||||||
|
OSA
|
||||||
|
sleep 1
|
||||||
|
tmp="$(mktemp -t vnishot).png"
|
||||||
|
screencapture -x -R${WIN_X},${WIN_Y},${WIN_W},${WIN_H} "$tmp"
|
||||||
|
mv "$tmp" "$SHOTS_DIR/$loc/$screen.png"
|
||||||
|
echo " 🖥️ $SHOTS_DIR/$loc/$screen.png"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
pkill -x "$EXE_NAME" 2>/dev/null || true
|
||||||
|
echo ""; echo "Done. Now run 'PLATFORM=mac swift run studio' to composite."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
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 "$SHOTS_DIR/$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" "$SHOTS_DIR/$loc/$screen.png"
|
||||||
|
echo " 📸 $SHOTS_DIR/$loc/$screen.png"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Done. Now run 'swift run studio' to composite."
|
||||||
16
packaging/apple/studio/generate.sh
Executable file
16
packaging/apple/studio/generate.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-shot: capture fresh per-locale screenshots from the app, then composite every
|
||||||
|
# marketing screen. Screenshots are transient (generated/shots, git-ignored) and are
|
||||||
|
# regenerated each run — never committed.
|
||||||
|
#
|
||||||
|
# ./generate.sh # capture + composite -> generated/<Language>/
|
||||||
|
# ./generate.sh --publish # capture + composite -> ../<Language>/ (ships)
|
||||||
|
# LOCALES="en fr" ./generate.sh # subset of locales
|
||||||
|
#
|
||||||
|
# During layout iteration you don't need to re-capture every time: run `./capture.sh`
|
||||||
|
# once, then `swift run studio` on its own (it reads the existing generated/shots).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
./capture.sh
|
||||||
|
swift run studio "$@"
|
||||||
200
packaging/apple/studio/strings.json
Normal file
200
packaging/apple/studio/strings.json
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
{
|
||||||
|
"_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",
|
||||||
|
"encryption": "ENCRYPTION",
|
||||||
|
"protection": "PROTECTION"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"encryption": "CHIFFREMENT",
|
||||||
|
"protection": "PROTECTION"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"encryption": "VERSCHLÜSSELUNG",
|
||||||
|
"protection": "SCHUTZ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"encryption": "CIFRADO",
|
||||||
|
"protection": "PROTECCIÓN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"encryption": "CRITTOGRAFIA",
|
||||||
|
"protection": "PROTEZIONE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"encryption": "VERSLEUTELING",
|
||||||
|
"protection": "BESCHERMING"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"encryption": "SZYFROWANIE",
|
||||||
|
"protection": "OCHRONA"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"encryption": "CRIPTOGRAFIA",
|
||||||
|
"protection": "PROTEÇÃO"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"_folder": "Russian",
|
||||||
|
"choose-receivers": {
|
||||||
|
"title": "Выбирайте получателей",
|
||||||
|
"subtitle": "Подтверждайте каждого приглашённого"
|
||||||
|
},
|
||||||
|
"send-anywhere": {
|
||||||
|
"title": "Отправляйте куда угодно",
|
||||||
|
"subtitle": "Прямая передача файлов по всему миру"
|
||||||
|
},
|
||||||
|
"share-securely": {
|
||||||
|
"title": "Делитесь безопасно",
|
||||||
|
"subtitle": "QR, NFC или файл"
|
||||||
|
},
|
||||||
|
"stay-private": {
|
||||||
|
"title": "Оставайтесь приватными",
|
||||||
|
"subtitle": "Шифрование без копии на сервере",
|
||||||
|
"encryption": "ШИФРОВАНИЕ",
|
||||||
|
"protection": "ЗАЩИТА"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user