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