6 Commits

Author SHA1 Message Date
b3a55172a2 docs(apple): document Mac target in screenshot studio README 2026-08-09 18:20:53 +02:00
5e8ba09fa5 chore: gitignore local compliance material 2026-08-09 18:20:53 +02:00
7aabfdf065 feat(apple): add MacBook screenshot studio target
Add a Mac (16:10, 2880x1800) platform to the screenshot studio: a
MacBook .usdz model, native-app capture path in capture.sh (no simulator),
and all four marketing layouts. Fix the screen texturing so the capture
fills the display exactly — plain [0,1] planar UVs with a thin black matte
baked into the shot for the rounded-corner margin (replacing the UV
overscan that smeared the edge). Add a horizontal encryption-flow mode
(beams/stream) for the side-by-side laptops in stay-private.
2026-08-09 18:20:53 +02:00
98661cef15 feat(studio): iPad screenshot support + cleanup
Generalize the studio into a Platform/DeviceModel abstraction so it renders
both iPhone and iPad sets from one code path.

- Platform: per-target canvas (iPad 2064x2752), 3D model, capture sim, and
  output subfolder (iPad -> <Language>/iPad/). PLATFORM=iphone|ipad.
- DeviceModel: screen material, orientation (yaw) fix, optional glass mesh,
  graphite recolor — the per-model quirks the renderer needs.
- SceneKit renderer: auto-generates planar UVs for screen meshes that ship
  without them; textures every material matching the screen name; zFar scaled
  for large-unit models; body recolor skips the real screen material.
- iPad uses a CC BY 4.0 model (commercial-safe) with real bezel geometry;
  first-pass iPad layouts for all four screens. capture.sh is PLATFORM-aware.

Remove now-dead code: the 2D mockup fallback (ClipDeviceRenderer,
MockupGeometry), the orbit ring and binary-tunnel (superseded by the route arc
and the encryption-flow), unused DeviceModel UV/inset workarounds; refresh the
README and .gitignore to match.
2026-08-09 18:20:53 +02:00
4bac61e505 feat(studio): native SwiftUI + SceneKit App Store screenshot pipeline
Replace the Typst/ImageMagick studio with a SwiftPM executable that composes
each marketing screen as a SwiftUI view and renders it headlessly via
ImageRenderer to exact 1284x2778 PNGs.

- Devices are a real iPhone 17 Pro Max .usdz textured + posed in SceneKit
  (graphite body, studio IBL); screen curvature/bezel are the model's geometry.
- Per-screen layouts (pose, globe, route arc, encryption flow) in ScreenSpec.
- send-anywhere: globe + Paris->LA route arc; uses the app's share screen.
- stay-private: converging beams -> glowing padlock -> binary protection stream,
  with localized CHIFFREMENT/PROTECTION banners and an auto-sizing header panel.
- Captions auto-shrink per locale (ViewThatFits) so long translations never clip.
- Screenshots are transient (generated/shots, git-ignored), captured per locale;
  generate.sh does capture + composite in one shot.
- Commit the .usdz model (CC BY 4.0, see assets/ATTRIBUTION.md) and globe asset.
- Remove the superseded Typst pipeline (screens.typ, build.sh, frame.sh, warp.sh).
2026-08-09 18:20:52 +02:00
06d33361c2 chore(apple): add screenshot studio 2026-08-09 18:20:52 +02:00
28 changed files with 2067 additions and 7 deletions

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ output/
.screenshots
apple/RELEASE-MACOS.md
apple/Generated/*.xcconfig
# Compliance material — kept locally, never committed
compliance/

View 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
```

View 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)
}
}
}

View File

@@ -7,6 +7,10 @@ import Combine
final class AppGraph: ObservableObject {
let dependencies: AppDependencies
let coreRepository: CoreRepository
/// The gateway the feature models and coordinators observe. Normally the real
/// `coreRepository`; in screenshot builds a fixture is injected so the UI shows
/// deterministic content without the Rust core (see `ScreenshotSupport`).
let gateway: CoreGateway
let visibility = AppVisibility()
let messages = UiMessageController()
let preferencesRepository: AppPreferencesRepository
@@ -15,10 +19,12 @@ final class AppGraph: ObservableObject {
let transferNotificationCoordinator: TransferNotificationCoordinator
let backgroundActivity: BackgroundActivityController
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil, coreGateway: CoreGateway? = nil) {
self.dependencies = dependencies
let coreRepository = coreRepository ?? CoreRepository()
self.coreRepository = coreRepository
let gateway = coreGateway ?? coreRepository
self.gateway = gateway
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
self.preferencesRepository = AppPreferencesRepository(
fallback: AppPreferencesDefaults(
@@ -28,13 +34,13 @@ final class AppGraph: ObservableObject {
)
)
self.approvalCoordinator = ApprovalCoordinator(
repository: coreRepository,
repository: gateway,
notifications: dependencies.notificationService,
visibility: visibility,
messages: messages
)
self.transferNotificationCoordinator = TransferNotificationCoordinator(
repository: coreRepository,
repository: gateway,
notifications: dependencies.notificationService,
visibility: visibility,
messages: messages

View File

@@ -12,24 +12,39 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase
#if DEBUG
@State private var screenshotScenario: ScreenshotScenario?
#endif
init(dependencies: AppDependencies) {
#if DEBUG
let scenario = ScreenshotScenario.current
let graph = AppGraph(
dependencies: dependencies,
coreGateway: scenario.map { ScreenshotCoreGateway(scenario: $0) }
)
_screenshotScenario = State(initialValue: scenario)
#else
let graph = AppGraph(dependencies: dependencies)
#endif
_graph = StateObject(wrappedValue: graph)
// The feature models observe `graph.gateway` the real core normally, or the
// injected screenshot fixture so a screenshot build never starts the core.
_appModel = StateObject(wrappedValue: AppModel(
environment: dependencies.environment,
repository: graph.coreRepository,
repository: graph.gateway,
preferences: graph.preferencesRepository,
messages: graph.messages
))
_sendModel = StateObject(wrappedValue: SendModel(
repository: graph.coreRepository,
repository: graph.gateway,
fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository,
filePreviewRepository: graph.filePreviewRepository,
messages: graph.messages
))
_receiveModel = StateObject(wrappedValue: ReceiveModel(
repository: graph.coreRepository,
repository: graph.gateway,
fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository,
messages: graph.messages
@@ -38,7 +53,7 @@ struct RootView: View {
environment: dependencies.environment,
deviceInfoProvider: dependencies.deviceInfoProvider,
fileSystemService: dependencies.fileSystemService,
repository: graph.coreRepository,
repository: graph.gateway,
preferences: graph.preferencesRepository,
notifications: dependencies.notificationService,
messages: graph.messages,
@@ -78,6 +93,15 @@ struct RootView: View {
}
.platformPickers(settingsModel: settingsModel)
.task { await consumeExternalInvitations() }
#if DEBUG
// Once the (fixture) core reports ready, drive the app into the target screen.
.task(id: sendModel.coreState.isInitialized) {
guard let scenario = screenshotScenario, sendModel.coreState.isInitialized else { return }
appModel.selectDestination(.send)
sendModel.openTransfer(ScreenshotCoreGateway.transferId)
if scenario == .share { sendModel.openShare() }
}
#endif
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active:

View 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

View File

@@ -20,6 +20,12 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<!-- Required, not optional: VniDrop's iroh (QUIC/UDP) endpoint listens for
and ACCEPTS inbound connections from peers — either device can initiate
a transfer. See HandshakeService::accept in crates/vnidrop/src/handshake.rs.
Removing this breaks the receive/serve half of every transfer under the
sandbox. App Store automated review may flag it (no classic TcpListener);
justify via App Review Information rather than removing. -->
<key>com.apple.security.network.server</key>
<true/>
</dict>

View File

@@ -159,6 +159,25 @@ targets:
dependencies:
- target: VniDrop
# UI test bundle used only to capture App Store screenshots (iOS/iPad).
# Driven by apple/scripts/appstore-screenshots.sh.
VniDropUITests:
type: bundle.ui-testing
supportedDestinations: [iOS]
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
Release-Direct: Signing.xcconfig
sources:
- path: UITests
settings:
base:
GENERATE_INFOPLIST_FILE: YES
SWIFT_VERSION: "6.0"
TEST_TARGET_NAME: VniDrop
dependencies:
- target: VniDrop
schemes:
VniDrop:
build:
@@ -188,3 +207,13 @@ schemes:
config: Release-Direct
archive:
config: Release-Direct
# App Store screenshot capture (see apple/scripts/appstore-screenshots.sh).
VniDropScreenshots:
build:
targets:
VniDrop: all
test:
config: Debug
targets:
- VniDropUITests

View 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"

View File

@@ -52,6 +52,13 @@ impl 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> {
let remote_endpoint_id = connection.remote_id().to_string();

4
packaging/apple/studio/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
generated/
out-*.png
.build/
.swiftpm/

View 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"
)
]
)

View 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).

View 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
}

View 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"
}
}
}

View File

@@ -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
}
}

View 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.50.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)
}
}

View 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, topbottom / startend
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 leftright
// (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 leftright instead of topbottom
}
// 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 leftlock, a binary stream runs lockright.
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),
]
),
]
}

View 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))
}
}

View 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) }

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

120
packaging/apple/studio/capture.sh Executable file
View 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."

View 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 "$@"

View 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": "ЗАЩИТА"
}
}
}
}