mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-09 20:29:58 +02:00
Compare commits
23 Commits
v0.2.4
...
feat/scree
| Author | SHA1 | Date | |
|---|---|---|---|
| b3a55172a2 | |||
| 5e8ba09fa5 | |||
| 7aabfdf065 | |||
| 98661cef15 | |||
| 4bac61e505 | |||
| 06d33361c2 | |||
|
|
387298d137 | ||
| 3118afdba0 | |||
|
|
e8c3eadfc8 | ||
| 877083a3ed | |||
| 7cb2270d56 | |||
| eb8498168d | |||
| ac6e837560 | |||
| 3e18378610 | |||
| b68d338097 | |||
| b8a002a2ad | |||
| 232fb125d3 | |||
|
|
d52ac52cea | ||
| fe97c21c7a | |||
| c670dda0a9 | |||
| ff391f5502 | |||
| 9079c81409 | |||
| 5424da855e |
8
.github/workflows/apple-release.yml
vendored
8
.github/workflows/apple-release.yml
vendored
@@ -134,6 +134,12 @@ jobs:
|
|||||||
- name: Build, sign & notarize DMG
|
- name: Build, sign & notarize DMG
|
||||||
run: make build-apple-dmg
|
run: make build-apple-dmg
|
||||||
|
|
||||||
|
- name: Package prebuilt core
|
||||||
|
# build-apple-dmg builds the release Rust core + Swift bindings; bundle them
|
||||||
|
# (xcframework + Vnidrop.swift + checksum) as a release asset so consumers can
|
||||||
|
# skip building the core. See apple/scripts/package-core.sh.
|
||||||
|
run: make package-apple-core
|
||||||
|
|
||||||
- name: Upload notarization diagnostics
|
- name: Upload notarization diagnostics
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
@@ -156,5 +162,7 @@ jobs:
|
|||||||
apple/dist/VniDrop-*.dmg
|
apple/dist/VniDrop-*.dmg
|
||||||
apple/dist/VniDrop-*.build-info.json
|
apple/dist/VniDrop-*.build-info.json
|
||||||
apple/dist/appcast.xml
|
apple/dist/appcast.xml
|
||||||
|
apple/dist/VnidropCore-*.zip
|
||||||
|
apple/dist/VnidropCore-*.zip.sha256
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ output/
|
|||||||
.screenshots
|
.screenshots
|
||||||
apple/RELEASE-MACOS.md
|
apple/RELEASE-MACOS.md
|
||||||
apple/Generated/*.xcconfig
|
apple/Generated/*.xcconfig
|
||||||
|
|
||||||
|
# Compliance material — kept locally, never committed
|
||||||
|
compliance/
|
||||||
|
|||||||
13
Makefile
13
Makefile
@@ -12,7 +12,7 @@ include $(ROOT)/make/release.mk
|
|||||||
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
||||||
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
||||||
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
||||||
.PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple
|
.PHONY: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
|
||||||
.PHONY: prepare-release check-version check-release check-localization localization localization-migrate
|
.PHONY: prepare-release check-version check-release check-localization localization localization-migrate
|
||||||
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
|
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
|
||||||
|
|
||||||
@@ -71,8 +71,9 @@ check-version: ## Validate the canonical version and its platform mappings.
|
|||||||
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
|
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
|
||||||
|
|
||||||
check-release: ## Validate coordinated release scripts and workflow YAML.
|
check-release: ## Validate coordinated release scripts and workflow YAML.
|
||||||
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
|
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh apple/scripts/generate-appconfig.sh apple/scripts/tests/test-generate-appconfig.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
|
||||||
cd $(ROOT) && apple/scripts/tests/test-notarize.sh
|
cd $(ROOT) && apple/scripts/tests/test-notarize.sh
|
||||||
|
cd $(ROOT) && apple/scripts/tests/test-generate-appconfig.sh
|
||||||
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
|
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
|
||||||
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
|
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
|
||||||
cd $(ROOT) && packaging/release/test-assemble-release.sh
|
cd $(ROOT) && packaging/release/test-assemble-release.sh
|
||||||
@@ -135,7 +136,10 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings.
|
|||||||
apple-version-config: ## Generate derived Store and Direct Apple build settings.
|
apple-version-config: ## Generate derived Store and Direct Apple build settings.
|
||||||
cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all
|
cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all
|
||||||
|
|
||||||
apple-project: apple-core localization apple-version-config ## Generate the native Apple Xcode project.
|
apple-app-config: ## Generate AppConfig.swift from the shared app.properties.
|
||||||
|
cd $(ROOT) && apple/scripts/generate-appconfig.sh
|
||||||
|
|
||||||
|
apple-project: apple-core localization apple-version-config apple-app-config ## Generate the native Apple Xcode project.
|
||||||
cd $(ROOT)/apple && $(XCODEGEN) generate
|
cd $(ROOT)/apple && $(XCODEGEN) generate
|
||||||
|
|
||||||
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
|
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
|
||||||
@@ -150,6 +154,9 @@ build-apple-macos-direct: apple-project ## Build the direct-download macOS targe
|
|||||||
build-apple-dmg: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
|
build-apple-dmg: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
|
||||||
cd $(ROOT) && apple/scripts/build-dmg.sh
|
cd $(ROOT) && apple/scripts/build-dmg.sh
|
||||||
|
|
||||||
|
package-apple-core: ## Zip the prebuilt core (xcframework + bindings) + checksum into apple/dist (build the core first).
|
||||||
|
cd $(ROOT) && apple/scripts/package-core.sh
|
||||||
|
|
||||||
open-apple: build-apple-macos ## Build and launch the native macOS app.
|
open-apple: build-apple-macos ## Build and launch the native macOS app.
|
||||||
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
|
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
|
||||||
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"
|
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -127,18 +127,18 @@ people, especially when using **Anyone with this transfer**.
|
|||||||
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
||||||
Windows, and Linux
|
Windows, and Linux
|
||||||
- Strict custom HTTPS relay profiles with safe apply and rollback
|
- Strict custom HTTPS relay profiles with safe apply and rollback
|
||||||
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
- Optional user-submitted bug reports with transfer contents, invitations, and
|
||||||
excluded
|
file paths excluded
|
||||||
|
|
||||||
## Privacy by design
|
## Privacy by design
|
||||||
|
|
||||||
- **No hosted transfer copy.** VniDrop does not upload file contents to its
|
- **No hosted transfer copy.** VniDrop does not upload file contents to a bug-report
|
||||||
diagnostics service or a VniDrop storage bucket.
|
service or a VniDrop storage bucket.
|
||||||
- **Encrypted in transit.** Iroh connections are authenticated and encrypted
|
- **Encrypted in transit.** Iroh connections are authenticated and encrypted
|
||||||
end to end, including when a relay is needed.
|
end to end, including when a relay is needed.
|
||||||
- **Local control.** Transfer history and sharing state stay on the device.
|
- **Local control.** Transfer history and sharing state stay on the device.
|
||||||
- **Sensitive invitations.** An invitation can grant access, so it is
|
- **Sensitive invitations.** An invitation can grant access, so it is
|
||||||
deliberately excluded from product logs and diagnostics.
|
deliberately excluded from product logs and bug reports.
|
||||||
- **Explicit access.** Approval is required by default, and stopping a share
|
- **Explicit access.** Approval is required by default, and stopping a share
|
||||||
removes access immediately.
|
removes access immediately.
|
||||||
|
|
||||||
|
|||||||
4
app.properties
Normal file
4
app.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Public, app-wide configuration shared by every platform (Apple + KMP).
|
||||||
|
# Plain KEY=VALUE so it is parsed identically by shell, Gradle, and codegen.
|
||||||
|
# Injected into the apps at build time — never hardcode these values in app code.
|
||||||
|
PRIVACY_POLICY_URL=https://vnidrop.sudosy.fr/privacy/
|
||||||
@@ -114,7 +114,7 @@ The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
|
|||||||
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
|
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
|
||||||
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
|
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
|
||||||
dependency); swap in `lottie-ios` if exact-parity animation is required.
|
dependency); swap in `lottie-ios` if exact-parity animation is required.
|
||||||
- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind
|
- Bug reporting is stubbed behind `BugReportService` (`NoopBugReportService`) and
|
||||||
`BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI
|
a real transport lands in a later phase. There is no telemetry or crash
|
||||||
hides the diagnostics toggle when not compiled in.
|
auto-reporting.
|
||||||
```
|
```
|
||||||
|
|||||||
229
apple/RELEASE-TESTFLIGHT.fr.md
Normal file
229
apple/RELEASE-TESTFLIGHT.fr.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Publier VniDrop sur TestFlight interne — guide complet
|
||||||
|
|
||||||
|
Ce guide décrit **toutes les étapes** pour compiler l'application et l'envoyer sur
|
||||||
|
**TestFlight interne** (aucune revue Apple n'est nécessaire pour les testeurs internes).
|
||||||
|
|
||||||
|
- **Bundle ID** : `com.vnidrop.app`
|
||||||
|
- **Team ID Apple** : `A8A4JSMV5D`
|
||||||
|
- **Branche à utiliser** : `feat/release-test-flight`
|
||||||
|
|
||||||
|
> ⚠️ **Point crucial** : il faut compiler avec un **Xcode de version finale (release)**,
|
||||||
|
> par exemple **Xcode 26** — **pas** une version bêta. Un envoi construit avec un Xcode
|
||||||
|
> bêta est **refusé** par App Store Connect (« Unsupported SDK or Xcode version »).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Prérequis
|
||||||
|
|
||||||
|
- Un Mac sous **macOS stable** (pas une bêta) avec **Xcode 26** installé.
|
||||||
|
- Un **identifiant Apple** (gratuit) — **aucun abonnement développeur payant n'est
|
||||||
|
nécessaire de votre côté**. Le propriétaire du compte vous invitera sur le sien.
|
||||||
|
- Une connexion internet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Obtenir l'accès au compte développeur
|
||||||
|
|
||||||
|
Le propriétaire du compte doit vous inviter (une seule fois) :
|
||||||
|
|
||||||
|
1. Sur **App Store Connect** → **Utilisateurs et accès** → **Ajouter un utilisateur**.
|
||||||
|
2. Il saisit **votre identifiant Apple** et vous attribue le rôle **Admin**
|
||||||
|
(nécessaire pour gérer la signature) ou au minimum **App Manager**.
|
||||||
|
3. Vous recevez un e-mail d'invitation — **acceptez-le**.
|
||||||
|
|
||||||
|
Ensuite, dans **Xcode** → menu **Xcode → Settings → Accounts** → **+** →
|
||||||
|
connectez-vous avec **votre** identifiant Apple. L'équipe **VniDrop (A8A4JSMV5D)**
|
||||||
|
doit apparaître.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Installer les outils
|
||||||
|
|
||||||
|
Dans le Terminal :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Homebrew (si absent) : voir https://brew.sh
|
||||||
|
# Outils de génération de projet et de qualité de code
|
||||||
|
brew install xcodegen swiftlint
|
||||||
|
|
||||||
|
# Rust (pour compiler le cœur natif)
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Installez aussi les **Command Line Tools** de Xcode si demandé :
|
||||||
|
```bash
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Récupérer le projet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <URL_DU_DEPOT> vnidrop
|
||||||
|
cd vnidrop
|
||||||
|
git checkout feat/release-test-flight
|
||||||
|
```
|
||||||
|
|
||||||
|
> Le fichier `.xcodeproj`, le `Local.xcconfig` et le framework compilé ne sont **pas**
|
||||||
|
> versionnés : ils seront (re)générés localement aux étapes suivantes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Configurer la signature
|
||||||
|
|
||||||
|
Créez le fichier **`apple/Local.xcconfig`** (ignoré par git) avec ce contenu :
|
||||||
|
|
||||||
|
```
|
||||||
|
DEVELOPMENT_TEAM = A8A4JSMV5D
|
||||||
|
CODE_SIGN_STYLE = Automatic
|
||||||
|
CODE_SIGNING_ALLOWED = YES
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela active la signature sur cette machine sans modifier la configuration partagée
|
||||||
|
(qui reste non signée pour l'intégration continue).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Compiler le cœur Rust
|
||||||
|
|
||||||
|
Depuis la racine du dépôt :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apple/scripts/build-core.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela produit `apple/VnidropCore/vnidrop.xcframework` (avec la tranche **arm64 device**
|
||||||
|
requise pour TestFlight) et les liaisons Swift.
|
||||||
|
|
||||||
|
- La compilation **debug** (par défaut) convient parfaitement pour TestFlight.
|
||||||
|
- Si vous voulez une compilation **release** : `apple/scripts/build-core.sh release`.
|
||||||
|
Sur macOS stable, cela devrait fonctionner. En cas d'erreur `can't find crate`
|
||||||
|
(dylibs de macros corrompus), nettoyez et repassez en debug :
|
||||||
|
```bash
|
||||||
|
cargo clean
|
||||||
|
apple/scripts/build-core.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Incrémenter le numéro de build
|
||||||
|
|
||||||
|
Chaque envoi doit avoir un **numéro de build unique et supérieur** au précédent.
|
||||||
|
Dans **`apple/project.yml`**, cherchez `CURRENT_PROJECT_VERSION` et mettez **`4`**
|
||||||
|
(les numéros 1 à 3 ont déjà été utilisés) :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
CURRENT_PROJECT_VERSION: "4"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Pour tout envoi ultérieur, augmentez encore ce nombre (5, 6, …).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Générer le projet Xcode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apple
|
||||||
|
xcodegen generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela crée `apple/VniDrop.xcodeproj` à partir de `project.yml` et du `Local.xcconfig`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Archiver dans Xcode
|
||||||
|
|
||||||
|
1. Ouvrez **`apple/VniDrop.xcodeproj`** dans **Xcode 26**.
|
||||||
|
2. En haut, sélectionnez le schéma **VniDrop** et la destination
|
||||||
|
**Any iOS Device (arm64)** (surtout **pas** un simulateur).
|
||||||
|
3. Menu **Product → Archive**.
|
||||||
|
4. À la fin, la fenêtre **Organizer** s'ouvre avec votre archive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Envoyer sur App Store Connect
|
||||||
|
|
||||||
|
1. Dans l'**Organizer**, sélectionnez l'archive → **Distribute App**.
|
||||||
|
2. Choisissez **App Store Connect** → **Upload**.
|
||||||
|
3. Laissez les options par défaut (**signature automatique**) → **Upload**.
|
||||||
|
4. La question sur le chiffrement **ne sera pas posée** (déjà réglée dans l'Info.plist).
|
||||||
|
|
||||||
|
Patientez quelques minutes : le build apparaît ensuite dans App Store Connect avec le
|
||||||
|
statut **« En cours de traitement »**, puis devient disponible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Publier sur TestFlight interne (sans revue)
|
||||||
|
|
||||||
|
1. Sur **App Store Connect** → l'app **VniDrop** → onglet **TestFlight**.
|
||||||
|
2. Attendez que le build passe de **« En cours de traitement »** à disponible.
|
||||||
|
3. Section **Tests internes** → créez un groupe (ou utilisez celui par défaut) →
|
||||||
|
ajoutez les **testeurs internes** (ce sont des utilisateurs de l'équipe App Store
|
||||||
|
Connect ; le propriétaire les ajoute via **Utilisateurs et accès** si besoin).
|
||||||
|
4. Activez le build pour le groupe.
|
||||||
|
5. Les testeurs reçoivent un e-mail, installent l'app **TestFlight**, acceptent, puis
|
||||||
|
installent VniDrop. **Aucune revue Apple** n'est requise pour les tests internes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Solution de repli pour la signature
|
||||||
|
|
||||||
|
Si, à l'étape 9/10, Xcode **refuse de créer un certificat de distribution**
|
||||||
|
automatiquement (limitation possible des comptes individuels), le **propriétaire du
|
||||||
|
compte** doit fournir les éléments de signature :
|
||||||
|
|
||||||
|
1. Portail développeur → **Certificates** → créer un certificat **Apple Distribution**,
|
||||||
|
puis l'**exporter en `.p12`** (avec la clé privée) depuis le Trousseau (Keychain).
|
||||||
|
2. **Profiles** → créer un profil de provisioning **App Store** pour `com.vnidrop.app`.
|
||||||
|
3. Vous transmet le `.p12` (+ son mot de passe) et le profil.
|
||||||
|
|
||||||
|
De votre côté :
|
||||||
|
- Importez le `.p12` dans le **Trousseau** (double-clic).
|
||||||
|
- Dans Xcode, désactivez la signature automatique et sélectionnez la signature
|
||||||
|
**manuelle** avec ce profil, puis reprenez l'archivage (étape 9).
|
||||||
|
|
||||||
|
> 🔒 Un `.p12` de distribution permet de signer des apps au nom du propriétaire :
|
||||||
|
> à n'utiliser qu'entre personnes de confiance. Le certificat peut être révoqué ensuite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Dépannage
|
||||||
|
|
||||||
|
- **« Unsupported SDK or Xcode version »** → vous compilez avec un Xcode **bêta**.
|
||||||
|
Utilisez **Xcode 26 (release)**.
|
||||||
|
- **Échec de la phase SwiftLint** → `brew install swiftlint` (obligatoire, la build
|
||||||
|
échoue sinon).
|
||||||
|
- **Numéro de build déjà utilisé** → augmentez `CURRENT_PROJECT_VERSION` puis
|
||||||
|
`xcodegen generate` à nouveau.
|
||||||
|
- **L'app n'apparaît pas dans TestFlight** → attendez la fin du « traitement » ; la
|
||||||
|
conformité export est déjà déclarée, aucune action supplémentaire.
|
||||||
|
- **Le même Xcode pour le cœur Rust et l'archivage n'est pas obligatoire** (le cœur est
|
||||||
|
du Rust), mais l'**archivage** doit impérativement se faire avec **Xcode 26 release**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Récapitulatif express
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Outils
|
||||||
|
brew install xcodegen swiftlint
|
||||||
|
|
||||||
|
# 2. Projet
|
||||||
|
git checkout feat/release-test-flight
|
||||||
|
|
||||||
|
# 3. Signature : créer apple/Local.xcconfig (voir §5)
|
||||||
|
|
||||||
|
# 4. Cœur natif
|
||||||
|
apple/scripts/build-core.sh
|
||||||
|
|
||||||
|
# 5. Numéro de build : CURRENT_PROJECT_VERSION -> 4 dans apple/project.yml
|
||||||
|
|
||||||
|
# 6. Projet Xcode
|
||||||
|
cd apple && xcodegen generate
|
||||||
|
|
||||||
|
# 7. Xcode 26 : schéma VniDrop, destination « Any iOS Device (arm64) »,
|
||||||
|
# Product → Archive → Distribute App → App Store Connect → Upload
|
||||||
|
|
||||||
|
# 8. App Store Connect → TestFlight → Tests internes → ajouter les testeurs
|
||||||
|
```
|
||||||
39
apple/Tests/AppConfigTests.swift
Normal file
39
apple/Tests/AppConfigTests.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Verifies the build-time `AppConfig` (generated from the shared `app.properties`)
|
||||||
|
/// exposes the expected, well-formed values to the app.
|
||||||
|
final class AppConfigTests: XCTestCase {
|
||||||
|
func testPrivacyPolicyURLIsTheExpectedHTTPSEndpoint() {
|
||||||
|
let url = AppConfig.privacyPolicyURL
|
||||||
|
XCTAssertEqual(url.scheme, "https", "Privacy policy URL must be https")
|
||||||
|
XCTAssertEqual(url.absoluteString, "https://vnidrop.sudosy.fr/privacy/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrivacyPolicyURLMatchesTheSharedConfigFile() throws {
|
||||||
|
// Cross-check the generated constant against the single source of truth so a
|
||||||
|
// broken generator (or drift) is caught, not just a hardcoded copy.
|
||||||
|
let expected = try Self.privacyURLFromAppProperties()
|
||||||
|
XCTAssertEqual(AppConfig.privacyPolicyURL.absoluteString, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `PRIVACY_POLICY_URL` from the repo's `app.properties` by walking up
|
||||||
|
/// from this source file's location to the repository root.
|
||||||
|
private static func privacyURLFromAppProperties() throws -> String {
|
||||||
|
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||||
|
for _ in 0..<8 {
|
||||||
|
let candidate = dir.appendingPathComponent("app.properties")
|
||||||
|
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
let contents = try String(contentsOf: candidate, encoding: .utf8)
|
||||||
|
for line in contents.split(whereSeparator: \.isNewline) {
|
||||||
|
if line.hasPrefix("PRIVACY_POLICY_URL=") {
|
||||||
|
return String(line.dropFirst("PRIVACY_POLICY_URL=".count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw XCTSkip("PRIVACY_POLICY_URL missing in \(candidate.path)")
|
||||||
|
}
|
||||||
|
dir.deleteLastPathComponent()
|
||||||
|
}
|
||||||
|
throw XCTSkip("app.properties not found from \(#filePath)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase {
|
|||||||
preferences: preferences,
|
preferences: preferences,
|
||||||
notifications: LocalNotificationService(),
|
notifications: LocalNotificationService(),
|
||||||
messages: UiMessageController(),
|
messages: UiMessageController(),
|
||||||
bugReports: NoopBugReportService(),
|
bugReports: NoopBugReportService()
|
||||||
diagnosticsIncluded: false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
apple/UITests/ScreenshotTests.swift
Normal file
44
apple/UITests/ScreenshotTests.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Captures App Store screenshots by driving the running app and grabbing the
|
||||||
|
/// full-screen image on each tab. Run via `apple/scripts/appstore-screenshots.sh`,
|
||||||
|
/// which boots the target simulator, runs this test, and extracts the attachments
|
||||||
|
/// at the device's native resolution (e.g. 2064×2752 on the 13-inch iPad).
|
||||||
|
final class ScreenshotTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaptureAppStoreScreenshots() {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// The top pill exposes the three primary tabs. Tap by accessibility label
|
||||||
|
// when available, falling back to a normalized coordinate on the pill.
|
||||||
|
let tabs: [(name: String, dx: CGFloat)] = [
|
||||||
|
("01-Send", 0.407),
|
||||||
|
("02-Receive", 0.487),
|
||||||
|
("03-Settings", 0.579),
|
||||||
|
]
|
||||||
|
|
||||||
|
for tab in tabs {
|
||||||
|
let label = String(tab.name.dropFirst(3)) // "Send" / "Receive" / "Settings"
|
||||||
|
let button = app.buttons[label]
|
||||||
|
if button.waitForExistence(timeout: 10), button.isHittable {
|
||||||
|
button.tap()
|
||||||
|
} else {
|
||||||
|
app.coordinate(withNormalizedOffset: CGVector(dx: tab.dx, dy: 0.039)).tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the tab transition and any content settle before capturing.
|
||||||
|
Thread.sleep(forTimeInterval: 1.5)
|
||||||
|
|
||||||
|
let screenshot = XCUIScreen.main.screenshot()
|
||||||
|
let attachment = XCTAttachment(screenshot: screenshot)
|
||||||
|
attachment.name = tab.name
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@ import Combine
|
|||||||
final class AppGraph: ObservableObject {
|
final class AppGraph: ObservableObject {
|
||||||
let dependencies: AppDependencies
|
let dependencies: AppDependencies
|
||||||
let coreRepository: CoreRepository
|
let coreRepository: CoreRepository
|
||||||
|
/// The gateway the feature models and coordinators observe. Normally the real
|
||||||
|
/// `coreRepository`; in screenshot builds a fixture is injected so the UI shows
|
||||||
|
/// deterministic content without the Rust core (see `ScreenshotSupport`).
|
||||||
|
let gateway: CoreGateway
|
||||||
let visibility = AppVisibility()
|
let visibility = AppVisibility()
|
||||||
let messages = UiMessageController()
|
let messages = UiMessageController()
|
||||||
let preferencesRepository: AppPreferencesRepository
|
let preferencesRepository: AppPreferencesRepository
|
||||||
@@ -15,27 +19,28 @@ final class AppGraph: ObservableObject {
|
|||||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||||
let backgroundActivity: BackgroundActivityController
|
let backgroundActivity: BackgroundActivityController
|
||||||
|
|
||||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil, coreGateway: CoreGateway? = nil) {
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
let coreRepository = coreRepository ?? CoreRepository()
|
let coreRepository = coreRepository ?? CoreRepository()
|
||||||
self.coreRepository = coreRepository
|
self.coreRepository = coreRepository
|
||||||
|
let gateway = coreGateway ?? coreRepository
|
||||||
|
self.gateway = gateway
|
||||||
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
|
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
|
||||||
self.preferencesRepository = AppPreferencesRepository(
|
self.preferencesRepository = AppPreferencesRepository(
|
||||||
fallback: AppPreferencesDefaults(
|
fallback: AppPreferencesDefaults(
|
||||||
username: dependencies.environment.defaultUsername,
|
username: dependencies.environment.defaultUsername,
|
||||||
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
||||||
themeMode: .system,
|
themeMode: .system
|
||||||
diagnosticsEnabled: false
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.approvalCoordinator = ApprovalCoordinator(
|
self.approvalCoordinator = ApprovalCoordinator(
|
||||||
repository: coreRepository,
|
repository: gateway,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
messages: messages
|
messages: messages
|
||||||
)
|
)
|
||||||
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||||
repository: coreRepository,
|
repository: gateway,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
messages: messages
|
messages: messages
|
||||||
|
|||||||
@@ -9,33 +9,42 @@ struct RootView: View {
|
|||||||
@StateObject private var sendModel: SendModel
|
@StateObject private var sendModel: SendModel
|
||||||
@StateObject private var receiveModel: ReceiveModel
|
@StateObject private var receiveModel: ReceiveModel
|
||||||
@StateObject private var settingsModel: SettingsModel
|
@StateObject private var settingsModel: SettingsModel
|
||||||
@ObservedObject private var messages: UiMessageController
|
|
||||||
@ObservedObject private var approvals: ApprovalCoordinator
|
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
|
#if DEBUG
|
||||||
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
|
@State private var screenshotScenario: ScreenshotScenario?
|
||||||
@State private var showApproval = false
|
#endif
|
||||||
|
|
||||||
init(dependencies: AppDependencies) {
|
init(dependencies: AppDependencies) {
|
||||||
|
#if DEBUG
|
||||||
|
let scenario = ScreenshotScenario.current
|
||||||
|
let graph = AppGraph(
|
||||||
|
dependencies: dependencies,
|
||||||
|
coreGateway: scenario.map { ScreenshotCoreGateway(scenario: $0) }
|
||||||
|
)
|
||||||
|
_screenshotScenario = State(initialValue: scenario)
|
||||||
|
#else
|
||||||
let graph = AppGraph(dependencies: dependencies)
|
let graph = AppGraph(dependencies: dependencies)
|
||||||
|
#endif
|
||||||
_graph = StateObject(wrappedValue: graph)
|
_graph = StateObject(wrappedValue: graph)
|
||||||
|
// The feature models observe `graph.gateway` — the real core normally, or the
|
||||||
|
// injected screenshot fixture — so a screenshot build never starts the core.
|
||||||
_appModel = StateObject(wrappedValue: AppModel(
|
_appModel = StateObject(wrappedValue: AppModel(
|
||||||
environment: dependencies.environment,
|
environment: dependencies.environment,
|
||||||
repository: graph.coreRepository,
|
repository: graph.gateway,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
messages: graph.messages
|
messages: graph.messages
|
||||||
))
|
))
|
||||||
_sendModel = StateObject(wrappedValue: SendModel(
|
_sendModel = StateObject(wrappedValue: SendModel(
|
||||||
repository: graph.coreRepository,
|
repository: graph.gateway,
|
||||||
fileSystemService: dependencies.fileSystemService,
|
fileSystemService: dependencies.fileSystemService,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
filePreviewRepository: graph.filePreviewRepository,
|
filePreviewRepository: graph.filePreviewRepository,
|
||||||
messages: graph.messages
|
messages: graph.messages
|
||||||
))
|
))
|
||||||
_receiveModel = StateObject(wrappedValue: ReceiveModel(
|
_receiveModel = StateObject(wrappedValue: ReceiveModel(
|
||||||
repository: graph.coreRepository,
|
repository: graph.gateway,
|
||||||
fileSystemService: dependencies.fileSystemService,
|
fileSystemService: dependencies.fileSystemService,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
messages: graph.messages
|
messages: graph.messages
|
||||||
@@ -44,14 +53,12 @@ struct RootView: View {
|
|||||||
environment: dependencies.environment,
|
environment: dependencies.environment,
|
||||||
deviceInfoProvider: dependencies.deviceInfoProvider,
|
deviceInfoProvider: dependencies.deviceInfoProvider,
|
||||||
fileSystemService: dependencies.fileSystemService,
|
fileSystemService: dependencies.fileSystemService,
|
||||||
repository: graph.coreRepository,
|
repository: graph.gateway,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
messages: graph.messages,
|
messages: graph.messages,
|
||||||
bugReports: NoopBugReportService()
|
bugReports: NoopBugReportService()
|
||||||
))
|
))
|
||||||
messages = graph.messages
|
|
||||||
approvals = graph.approvalCoordinator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -60,13 +67,17 @@ struct RootView: View {
|
|||||||
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
||||||
ZStack {
|
ZStack {
|
||||||
navigation(windowClass: windowClass)
|
navigation(windowClass: windowClass)
|
||||||
SnackbarHost(controller: messages)
|
// Observe the coordinator/messages from the *persisted* `graph`
|
||||||
ApprovalModalHost(
|
// StateObject. Deriving them in `init` bound the view to a throwaway
|
||||||
isPresented: $showApproval,
|
// AppGraph rebuilt on every re-init, whose coordinator never receives
|
||||||
state: approvals.state,
|
// core events — so the approval modal never appeared.
|
||||||
onAccept: approvals.accept,
|
ApprovalLayer(
|
||||||
onRefuse: approvals.refuse
|
approvals: graph.approvalCoordinator,
|
||||||
|
sendModel: sendModel
|
||||||
)
|
)
|
||||||
|
// Top-most so the toast is never covered by the approval overlay's
|
||||||
|
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
||||||
|
SnackbarHost(controller: graph.messages)
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
// A small, unobtrusive indicator while the core finishes its async
|
// A small, unobtrusive indicator while the core finishes its async
|
||||||
@@ -82,6 +93,15 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
.platformPickers(settingsModel: settingsModel)
|
.platformPickers(settingsModel: settingsModel)
|
||||||
.task { await consumeExternalInvitations() }
|
.task { await consumeExternalInvitations() }
|
||||||
|
#if DEBUG
|
||||||
|
// Once the (fixture) core reports ready, drive the app into the target screen.
|
||||||
|
.task(id: sendModel.coreState.isInitialized) {
|
||||||
|
guard let scenario = screenshotScenario, sendModel.coreState.isInitialized else { return }
|
||||||
|
appModel.selectDestination(.send)
|
||||||
|
sendModel.openTransfer(ScreenshotCoreGateway.transferId)
|
||||||
|
if scenario == .share { sendModel.openShare() }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .active:
|
case .active:
|
||||||
@@ -103,27 +123,6 @@ struct RootView: View {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A pending approval is a blocking modal. Close the sender's detail panel
|
|
||||||
// (e.g. the Share/QR sheet) first, then present the approval sheet — but on
|
|
||||||
// macOS a sheet presented while another is still dismissing is silently
|
|
||||||
// dropped, so defer the presentation until that dismissal finishes.
|
|
||||||
.onChange(of: approvals.state.current?.id) { _, id in
|
|
||||||
guard id != nil else { showApproval = false; return }
|
|
||||||
let wasShowingSheet = sendModel.state.detailPanel != nil
|
|
||||||
sendModel.closeDetailPanel()
|
|
||||||
#if os(macOS)
|
|
||||||
if wasShowingSheet {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
|
||||||
if approvals.state.current != nil { showApproval = true }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showApproval = true
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
_ = wasShowingSheet
|
|
||||||
showApproval = true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
||||||
// drive foreground/background off NSApplication's active state instead —
|
// drive foreground/background off NSApplication's active state instead —
|
||||||
@@ -217,6 +216,66 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hosts the approval modal, observing the coordinator passed in from the persisted
|
||||||
|
/// `AppGraph`. Kept as a child view so the `@ObservedObject` subscription is
|
||||||
|
/// established here (in `body`) against the live instance, rather than in
|
||||||
|
/// `RootView.init` against a throwaway graph.
|
||||||
|
private struct ApprovalLayer: View {
|
||||||
|
@ObservedObject var approvals: ApprovalCoordinator
|
||||||
|
let sendModel: SendModel
|
||||||
|
|
||||||
|
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
|
||||||
|
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
|
||||||
|
@State private var showApproval = false
|
||||||
|
|
||||||
|
/// macOS-only: an approval arrived while a share/QR sheet was still up. We close
|
||||||
|
/// that sheet and present the approval once its dismissal completes (see
|
||||||
|
/// `sendModel.shareSheetsDismissed`), since macOS drops a sheet shown mid-dismissal.
|
||||||
|
@State private var approvalAwaitingSheetDismiss = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ApprovalModalHost(
|
||||||
|
isPresented: $showApproval,
|
||||||
|
state: approvals.state,
|
||||||
|
onAccept: approvals.accept,
|
||||||
|
onRefuse: approvals.refuse
|
||||||
|
)
|
||||||
|
// A pending approval is a blocking modal. Close any open share/QR sheet first
|
||||||
|
// (the detail-view panel *or* the list-level share sheet), then present the
|
||||||
|
// approval sheet: the approval is presented from the app root and neither
|
||||||
|
// platform reliably stacks it over a sheet owned by the Send screen.
|
||||||
|
.onChange(of: approvals.state.current?.id) { _, id in
|
||||||
|
guard id != nil else {
|
||||||
|
showApproval = false
|
||||||
|
approvalAwaitingSheetDismiss = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let wasShowingSheet = sendModel.state.detailPanel != nil
|
||||||
|
|| sendModel.state.shareTargetId != nil
|
||||||
|
sendModel.dismissShareSheets()
|
||||||
|
#if os(macOS)
|
||||||
|
// macOS silently drops a sheet presented while another is still dismissing,
|
||||||
|
// so wait for that sheet's real dismissal completion before presenting.
|
||||||
|
if wasShowingSheet {
|
||||||
|
approvalAwaitingSheetDismiss = true
|
||||||
|
} else {
|
||||||
|
showApproval = true
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
_ = wasShowingSheet
|
||||||
|
showApproval = true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.onReceive(sendModel.shareSheetsDismissed) { _ in
|
||||||
|
guard approvalAwaitingSheetDismiss else { return }
|
||||||
|
approvalAwaitingSheetDismiss = false
|
||||||
|
if approvals.state.current != nil { showApproval = true }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A full-window cover with a centered spinner shown while the core is starting.
|
/// A full-window cover with a centered spinner shown while the core is starting.
|
||||||
private struct CoreStartingOverlay: View {
|
private struct CoreStartingOverlay: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
109
apple/VniDrop/App/ScreenshotSupport.swift
Normal file
109
apple/VniDrop/App/ScreenshotSupport.swift
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
#if DEBUG
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import VnidropCore
|
||||||
|
|
||||||
|
/// Which marketing screen to stage. Selected via the `-VniScreenshot <name>` launch
|
||||||
|
/// argument (read from `NSArgumentDomain`), set by the App Store screenshot UI test.
|
||||||
|
enum ScreenshotScenario: String {
|
||||||
|
case transferDetails = "transfer-details" // Send Anywhere: the detail view
|
||||||
|
case share // Share Securely: the QR / share panel
|
||||||
|
case approval // Choose Receivers: the receive-request modal
|
||||||
|
|
||||||
|
/// The active scenario for this launch, or `nil` in a normal run.
|
||||||
|
static var current: ScreenshotScenario? {
|
||||||
|
guard let raw = UserDefaults.standard.string(forKey: "VniScreenshot") else { return nil }
|
||||||
|
return ScreenshotScenario(rawValue: raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fixture `CoreGateway` that publishes deterministic content instead of driving
|
||||||
|
/// the Rust core, so App Store screenshots are stable and localized. Only the reads
|
||||||
|
/// the screenshot screens need are meaningful; mutations are inert.
|
||||||
|
@MainActor
|
||||||
|
final class ScreenshotCoreGateway: CoreGateway {
|
||||||
|
private let scenario: ScreenshotScenario
|
||||||
|
private let subject: CurrentValueSubject<CoreState, Never>
|
||||||
|
private let signalSubject = PassthroughSubject<CoreSignal, Never>()
|
||||||
|
|
||||||
|
/// Deterministic fixture transfer shown across every scenario.
|
||||||
|
static let transferId: UInt64 = 1
|
||||||
|
private let fixtureTransfer = Transfer(
|
||||||
|
localId: "screenshot-1",
|
||||||
|
transferId: ScreenshotCoreGateway.transferId,
|
||||||
|
direction: .send,
|
||||||
|
status: .sharing,
|
||||||
|
peerId: nil,
|
||||||
|
transferName: "Transfer.MOV",
|
||||||
|
contentHash: "b1946ac92492d2347c6235b4d2611184",
|
||||||
|
fileCount: 1,
|
||||||
|
totalSize: 9_100_000,
|
||||||
|
ticket: "vnd://screenshot-demo-ticket-abcdefghijklmnopqrstuvwxyz0123456789",
|
||||||
|
accessPolicy: .requireApproval,
|
||||||
|
createdAt: 1_722_000_000,
|
||||||
|
updatedAt: 1_722_000_000
|
||||||
|
)
|
||||||
|
|
||||||
|
init(scenario: ScreenshotScenario) {
|
||||||
|
self.scenario = scenario
|
||||||
|
self.subject = CurrentValueSubject(CoreState())
|
||||||
|
}
|
||||||
|
|
||||||
|
var state: CoreState { subject.value }
|
||||||
|
var statePublisher: AnyPublisher<CoreState, Never> { subject.eraseToAnyPublisher() }
|
||||||
|
var signals: AnyPublisher<CoreSignal, Never> { signalSubject.eraseToAnyPublisher() }
|
||||||
|
|
||||||
|
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error> {
|
||||||
|
subject.value = CoreState(
|
||||||
|
isInitialized: true,
|
||||||
|
status: CoreStatus(endpointId: "screenshot-endpoint", activeTransfers: 1, activeShares: 1),
|
||||||
|
events: [],
|
||||||
|
transfers: [fixtureTransfer],
|
||||||
|
lastShare: nil,
|
||||||
|
lastInspection: nil
|
||||||
|
)
|
||||||
|
// Nudge the approval coordinator to (re)read requests for the sharing transfer.
|
||||||
|
signalSubject.send(.approvalChanged(transferId: Self.transferId))
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
|
||||||
|
guard scenario == .approval else { return .success([]) }
|
||||||
|
return .success([
|
||||||
|
ReceiverRequestModel(
|
||||||
|
id: "screenshot-request-1",
|
||||||
|
transferId: Self.transferId,
|
||||||
|
remoteEndpointId: "k51qzi5uqu5d-screenshot-peer-endpoint-identity",
|
||||||
|
transferName: "Transfer.MOV",
|
||||||
|
receiverName: nil,
|
||||||
|
receiverDeviceName: "Mac mini",
|
||||||
|
appVersion: "1.0",
|
||||||
|
status: .requested,
|
||||||
|
reason: nil,
|
||||||
|
requestedAt: 1_722_000_000,
|
||||||
|
respondedAt: nil,
|
||||||
|
completedAt: nil
|
||||||
|
)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inert mutations (screenshots never exercise these)
|
||||||
|
|
||||||
|
func shutdown() {}
|
||||||
|
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
||||||
|
.failure(ScreenshotGatewayError.unsupported)
|
||||||
|
}
|
||||||
|
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> { .failure(ScreenshotGatewayError.unsupported) }
|
||||||
|
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> { .success(()) }
|
||||||
|
func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result<Void, Error> { .success(()) }
|
||||||
|
func cancel(transferId: UInt64) async -> Result<Void, Error> { .success(()) }
|
||||||
|
func delete(transferId: UInt64) async -> Result<Void, Error> { .success(()) }
|
||||||
|
func clearReceiveHistory() async -> Result<UInt64, Error> { .success(0) }
|
||||||
|
func storageUsage() async -> Result<CoreStorageUsageModel, Error> { .success(CoreStorageUsageModel(blobStoreBytes: 0, appDataBytes: 0)) }
|
||||||
|
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { .success([]) }
|
||||||
|
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> { .success(()) }
|
||||||
|
func refresh() async -> Result<Void, Error> { .success(()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ScreenshotGatewayError: Error { case unsupported }
|
||||||
|
#endif
|
||||||
@@ -120,7 +120,6 @@ struct AppPreferences: Equatable {
|
|||||||
var username: String
|
var username: String
|
||||||
var receiveFolder: ReceiveFolder
|
var receiveFolder: ReceiveFolder
|
||||||
var themeMode: ThemeMode
|
var themeMode: ThemeMode
|
||||||
var diagnosticsEnabled: Bool
|
|
||||||
var diagnosticsInstallId: String
|
var diagnosticsInstallId: String
|
||||||
var relayConfiguration: RelayConfiguration
|
var relayConfiguration: RelayConfiguration
|
||||||
}
|
}
|
||||||
@@ -129,7 +128,6 @@ struct AppPreferencesDefaults {
|
|||||||
let username: String
|
let username: String
|
||||||
let receiveFolder: ReceiveFolder
|
let receiveFolder: ReceiveFolder
|
||||||
let themeMode: ThemeMode
|
let themeMode: ThemeMode
|
||||||
var diagnosticsEnabled: Bool = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -145,7 +143,6 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
static let receiveFolderValue = "receive_folder_value"
|
static let receiveFolderValue = "receive_folder_value"
|
||||||
static let receiveFolderDisplayName = "receive_folder_display_name"
|
static let receiveFolderDisplayName = "receive_folder_display_name"
|
||||||
static let themeMode = "theme_mode"
|
static let themeMode = "theme_mode"
|
||||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
|
||||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||||
static let relayConfiguration = "relay_configuration"
|
static let relayConfiguration = "relay_configuration"
|
||||||
}
|
}
|
||||||
@@ -160,13 +157,11 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
||||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
||||||
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
|
|
||||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||||
return AppPreferences(
|
return AppPreferences(
|
||||||
username: username,
|
username: username,
|
||||||
receiveFolder: folder,
|
receiveFolder: folder,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
diagnosticsEnabled: diagnostics,
|
|
||||||
diagnosticsInstallId: installId,
|
diagnosticsInstallId: installId,
|
||||||
relayConfiguration: resolveRelayConfiguration(defaults)
|
relayConfiguration: resolveRelayConfiguration(defaults)
|
||||||
)
|
)
|
||||||
@@ -219,11 +214,6 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
|
||||||
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRelayConfiguration(_ configuration: RelayConfiguration) {
|
func setRelayConfiguration(_ configuration: RelayConfiguration) {
|
||||||
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
|
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
|
||||||
defaults.set(encoded, forKey: Key.relayConfiguration)
|
defaults.set(encoded, forKey: Key.relayConfiguration)
|
||||||
|
|||||||
@@ -19,7 +19,19 @@ struct LocalNotification {
|
|||||||
/// Presents notifications even while the app is active. Without a delegate the
|
/// Presents notifications even while the app is active. Without a delegate the
|
||||||
/// system drops the banner when the app is frontmost — very visible on macOS,
|
/// system drops the banner when the app is frontmost — very visible on macOS,
|
||||||
/// where the app window is usually open when a transfer completes.
|
/// where the app window is usually open when a transfer completes.
|
||||||
private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
|
///
|
||||||
|
/// `@MainActor` is required, not just convenient: these delegate methods are
|
||||||
|
/// `async`, so their continuation resumes at the return point on whatever executor
|
||||||
|
/// they ran on. When the system hands a notification-tap back to UIKit it performs
|
||||||
|
/// state-restoration/snapshot work synchronously on that thread — which asserts
|
||||||
|
/// "Call must be made on main thread" and crashes if the method returned off-main.
|
||||||
|
/// Main-actor isolation guarantees the return happens on the main thread.
|
||||||
|
// `@preconcurrency` on the conformance: these delegate requirements are nonisolated
|
||||||
|
// with non-Sendable UN* parameters, which strict concurrency won't otherwise let a
|
||||||
|
// main actor-isolated type witness. The main-actor isolation is what fixes the
|
||||||
|
// crash (see the type doc above); the attribute inserts the runtime hop.
|
||||||
|
@MainActor
|
||||||
|
private final class NotificationPresenter: NSObject, @preconcurrency UNUserNotificationCenterDelegate {
|
||||||
func userNotificationCenter(
|
func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification
|
willPresent notification: UNNotification
|
||||||
@@ -36,14 +48,12 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel
|
|||||||
didReceive response: UNNotificationResponse
|
didReceive response: UNNotificationResponse
|
||||||
) async {
|
) async {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
await MainActor.run {
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
// Reopen/focus the single main window (activation triggers SwiftUI's
|
||||||
// Reopen/focus the single main window (activation triggers SwiftUI's
|
// reopen handling when it was closed).
|
||||||
// reopen handling when it was closed).
|
for window in NSApp.windows where window.canBecomeMain {
|
||||||
for window in NSApp.windows where window.canBecomeMain {
|
window.makeKeyAndOrderFront(nil)
|
||||||
window.makeKeyAndOrderFront(nil)
|
break
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ struct SendState: Equatable {
|
|||||||
var selectedTransferId: UInt64?
|
var selectedTransferId: UInt64?
|
||||||
var transferThumbnails: [UInt64: Data] = [:]
|
var transferThumbnails: [UInt64: Data] = [:]
|
||||||
var detailPanel: TransferDetailPanel?
|
var detailPanel: TransferDetailPanel?
|
||||||
|
/// Transfer whose share panel is presented inline from the list context menu
|
||||||
|
/// (distinct from `detailPanel == .share`, which shows it from the detail view).
|
||||||
|
/// Held in the model — not `SendScreen` @State — so the approval flow can dismiss
|
||||||
|
/// it centrally before presenting its modal.
|
||||||
|
var shareTargetId: UInt64?
|
||||||
var receiverHistory: [ReceiverRequestModel] = []
|
var receiverHistory: [ReceiverRequestModel] = []
|
||||||
var isLoadingReceivers = false
|
var isLoadingReceivers = false
|
||||||
var isDeleteConfirmationOpen = false
|
var isDeleteConfirmationOpen = false
|
||||||
@@ -56,6 +61,18 @@ final class SendModel: ObservableObject {
|
|||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
/// Fires *after* a share/QR sheet (the detail-view panel or the list-level share
|
||||||
|
/// sheet) has finished animating out. The approval flow waits on this to present
|
||||||
|
/// its modal on macOS, where a sheet shown while another is still dismissing is
|
||||||
|
/// dropped — using the real completion instead of a guessed delay.
|
||||||
|
private let shareSheetsDismissedSubject = PassthroughSubject<Void, Never>()
|
||||||
|
var shareSheetsDismissed: AnyPublisher<Void, Never> {
|
||||||
|
shareSheetsDismissedSubject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoked by a share sheet's `onDismiss` completion.
|
||||||
|
func shareSheetDidDismiss() { shareSheetsDismissedSubject.send(()) }
|
||||||
|
|
||||||
init(
|
init(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
fileSystemService: FileSystemService,
|
fileSystemService: FileSystemService,
|
||||||
@@ -201,6 +218,17 @@ final class SendModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
func closeDetailPanel() { state.detailPanel = nil }
|
func closeDetailPanel() { state.detailPanel = nil }
|
||||||
|
|
||||||
|
func openShareTarget(_ transferId: UInt64) { state.shareTargetId = transferId }
|
||||||
|
func closeShareTarget() { state.shareTargetId = nil }
|
||||||
|
|
||||||
|
/// Dismisses every share/QR surface at once — the detail-view share panel and the
|
||||||
|
/// list-level share sheet. Used before presenting the receiver-approval modal, so
|
||||||
|
/// no competing sheet is left open (macOS drops a sheet shown over another).
|
||||||
|
func dismissShareSheets() {
|
||||||
|
state.detailPanel = nil
|
||||||
|
state.shareTargetId = nil
|
||||||
|
}
|
||||||
|
|
||||||
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
||||||
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
||||||
|
|
||||||
@@ -257,8 +285,19 @@ final class SendModel: ObservableObject {
|
|||||||
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
||||||
/// receivers that are still pending or accepted.
|
/// receivers that are still pending or accepted.
|
||||||
func cancelReceiver(requestId: String) {
|
func cancelReceiver(requestId: String) {
|
||||||
|
respondToReceiver(requestId: requestId, accepted: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approves a single pending receiver by responding to its request positively.
|
||||||
|
/// A fallback for when the approval modal didn't surface — the pending receiver
|
||||||
|
/// can still be accepted from its row in the transfer's receivers panel.
|
||||||
|
func acceptReceiver(requestId: String) {
|
||||||
|
respondToReceiver(requestId: requestId, accepted: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respondToReceiver(requestId: String, accepted: Bool) {
|
||||||
Task {
|
Task {
|
||||||
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
|
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil)
|
||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success:
|
||||||
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ struct SendScreen: View {
|
|||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
|
|
||||||
/// Transfer whose share panel is presented inline from the list context menu.
|
|
||||||
@State private var shareTarget: Transfer?
|
|
||||||
/// Transfer pending an inline (list-level) delete confirmation.
|
/// Transfer pending an inline (list-level) delete confirmation.
|
||||||
@State private var deleteTarget: Transfer?
|
@State private var deleteTarget: Transfer?
|
||||||
|
|
||||||
private var outgoing: [Transfer] {
|
private var outgoing: [Transfer] {
|
||||||
model.coreState.transfers.filter { $0.direction == .send }
|
model.coreState.transfers.filter { $0.direction == .send }
|
||||||
}
|
}
|
||||||
|
/// The transfer whose list-level share sheet is open, resolved from the model's
|
||||||
|
/// `shareTargetId` (kept in the model so the approval flow can dismiss it).
|
||||||
|
private var shareTarget: Transfer? {
|
||||||
|
guard let id = model.state.shareTargetId else { return nil }
|
||||||
|
return outgoing.first { $0.transferId == id }
|
||||||
|
}
|
||||||
private var selectedTransfer: Transfer? {
|
private var selectedTransfer: Transfer? {
|
||||||
guard let id = model.state.selectedTransferId else { return nil }
|
guard let id = model.state.selectedTransferId else { return nil }
|
||||||
return outgoing.first { $0.transferId == id }
|
return outgoing.first { $0.transferId == id }
|
||||||
@@ -50,9 +54,10 @@ struct SendScreen: View {
|
|||||||
// composer drawer on the outer body, so the two don't clash). Opens the
|
// composer drawer on the outer body, so the two don't clash). Opens the
|
||||||
// share panel over the list without navigating into the transfer detail.
|
// share panel over the list without navigating into the transfer detail.
|
||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }),
|
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }),
|
||||||
windowClass: windowClass,
|
windowClass: windowClass,
|
||||||
onDismiss: { shareTarget = nil }
|
onDismiss: model.closeShareTarget,
|
||||||
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let shareTarget {
|
if let shareTarget {
|
||||||
TransferSharePanel(model: model, transfer: shareTarget)
|
TransferSharePanel(model: model, transfer: shareTarget)
|
||||||
@@ -92,7 +97,8 @@ struct SendScreen: View {
|
|||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||||
windowClass: windowClass,
|
windowClass: windowClass,
|
||||||
onDismiss: model.closeDetailPanel
|
onDismiss: model.closeDetailPanel,
|
||||||
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let panel = model.state.detailPanel {
|
if let panel = model.state.detailPanel {
|
||||||
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
||||||
@@ -127,7 +133,7 @@ struct SendScreen: View {
|
|||||||
.contextMenu {
|
.contextMenu {
|
||||||
if transfer.ticket != nil {
|
if transfer.ticket != nil {
|
||||||
Button {
|
Button {
|
||||||
shareTarget = transfer
|
model.openShareTarget(transfer.transferId)
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ struct DetailPanelContent: View {
|
|||||||
loading: model.state.isLoadingReceivers,
|
loading: model.state.isLoadingReceivers,
|
||||||
events: model.coreState.events,
|
events: model.coreState.events,
|
||||||
transferTotalSize: transfer.totalSize,
|
transferTotalSize: transfer.totalSize,
|
||||||
onCancel: model.cancelReceiver
|
onCancel: model.cancelReceiver,
|
||||||
|
onAccept: model.acceptReceiver
|
||||||
)
|
)
|
||||||
case .share:
|
case .share:
|
||||||
TransferSharePanel(model: model, transfer: transfer)
|
TransferSharePanel(model: model, transfer: transfer)
|
||||||
@@ -193,6 +194,7 @@ struct ReceiverHistoryPanel: View {
|
|||||||
let events: [CoreEventModel]
|
let events: [CoreEventModel]
|
||||||
let transferTotalSize: UInt64
|
let transferTotalSize: UInt64
|
||||||
let onCancel: (String) -> Void
|
let onCancel: (String) -> Void
|
||||||
|
let onAccept: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
||||||
@@ -203,7 +205,12 @@ struct ReceiverHistoryPanel: View {
|
|||||||
} else {
|
} else {
|
||||||
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
||||||
if index > 0 { Divider().overlay(colors.borderDefault) }
|
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||||
ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver), onCancel: onCancel)
|
ReceiverRow(
|
||||||
|
receiver: receiver,
|
||||||
|
sendProgress: sendProgress(for: receiver),
|
||||||
|
onCancel: onCancel,
|
||||||
|
onAccept: onAccept
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +231,7 @@ private struct ReceiverRow: View {
|
|||||||
let receiver: ReceiverRequestModel
|
let receiver: ReceiverRequestModel
|
||||||
let sendProgress: TransferProgress?
|
let sendProgress: TransferProgress?
|
||||||
let onCancel: (String) -> Void
|
let onCancel: (String) -> Void
|
||||||
|
let onAccept: (String) -> Void
|
||||||
|
|
||||||
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
||||||
/// negative response to an already-accepted request ("...not approved, or it
|
/// negative response to an already-accepted request ("...not approved, or it
|
||||||
@@ -257,14 +265,27 @@ private struct ReceiverRow: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
if isCancelable {
|
if isCancelable {
|
||||||
Button(role: .destructive) {
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
onCancel(receiver.id)
|
Button(role: .destructive) {
|
||||||
} label: {
|
onCancel(receiver.id)
|
||||||
Text(String(localized: L10n.Button.refuse))
|
} label: {
|
||||||
.font(VniType.bodySmall)
|
Text(String(localized: L10n.Button.refuse))
|
||||||
|
.font(VniType.bodySmall)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.tint(.red)
|
||||||
|
// Fallback approve action, in case the approval modal didn't surface.
|
||||||
|
Button {
|
||||||
|
onAccept(receiver.id)
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Button.approve))
|
||||||
|
.font(VniType.bodySmall).fontWeight(.medium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 7)
|
||||||
|
.background(Color.green, in: Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.tint(.red)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -24,8 +24,3 @@ struct NoopBugReportService: BugReportService {
|
|||||||
}
|
}
|
||||||
func previewLogBytes() async -> Int { 0 }
|
func previewLogBytes() async -> Int { 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
|
|
||||||
enum DiagnosticsBuildConfig {
|
|
||||||
static let included = false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ struct SettingsState: Equatable {
|
|||||||
var supportsCustomReceiveFolders = true
|
var supportsCustomReceiveFolders = true
|
||||||
var themeMode: ThemeMode = .system
|
var themeMode: ThemeMode = .system
|
||||||
var notificationPermission: NotificationPermission = .notDetermined
|
var notificationPermission: NotificationPermission = .notDetermined
|
||||||
var diagnosticsEnabled = false
|
|
||||||
var relayMode: RelayPreferenceMode = .automatic
|
var relayMode: RelayPreferenceMode = .automatic
|
||||||
var relayURLs: [String] = []
|
var relayURLs: [String] = []
|
||||||
var relayValidationError: RelayConfigurationValidationError?
|
var relayValidationError: RelayConfigurationValidationError?
|
||||||
@@ -76,7 +75,7 @@ struct SettingsState: Equatable {
|
|||||||
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
||||||
&& lhs.themeMode == rhs.themeMode
|
&& lhs.themeMode == rhs.themeMode
|
||||||
&& lhs.notificationPermission == rhs.notificationPermission
|
&& lhs.notificationPermission == rhs.notificationPermission
|
||||||
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
&& lhs.appVersion == rhs.appVersion
|
||||||
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
||||||
&& lhs.relayValidationError == rhs.relayValidationError
|
&& lhs.relayValidationError == rhs.relayValidationError
|
||||||
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
|
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
|
||||||
@@ -111,7 +110,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
private let notifications: LocalNotificationService
|
private let notifications: LocalNotificationService
|
||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
private let bugReports: BugReportService
|
private let bugReports: BugReportService
|
||||||
private let diagnosticsIncluded: Bool
|
|
||||||
|
|
||||||
private var usernamePersistTask: Task<Void, Never>?
|
private var usernamePersistTask: Task<Void, Never>?
|
||||||
private var hasLocalUsernameDraft = false
|
private var hasLocalUsernameDraft = false
|
||||||
@@ -126,8 +124,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
preferences: AppPreferencesRepository,
|
preferences: AppPreferencesRepository,
|
||||||
notifications: LocalNotificationService,
|
notifications: LocalNotificationService,
|
||||||
messages: UiMessageController,
|
messages: UiMessageController,
|
||||||
bugReports: BugReportService,
|
bugReports: BugReportService
|
||||||
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
|
|
||||||
) {
|
) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.deviceInfoProvider = deviceInfoProvider
|
self.deviceInfoProvider = deviceInfoProvider
|
||||||
@@ -137,7 +134,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
self.notifications = notifications
|
self.notifications = notifications
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
self.bugReports = bugReports
|
self.bugReports = bugReports
|
||||||
self.diagnosticsIncluded = diagnosticsIncluded
|
|
||||||
self.state = SettingsState(
|
self.state = SettingsState(
|
||||||
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
|
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
|
||||||
appVersion: environment.appVersion
|
appVersion: environment.appVersion
|
||||||
@@ -151,7 +147,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
||||||
self.state.receiveFolder = folder
|
self.state.receiveFolder = folder
|
||||||
self.state.themeMode = prefs.themeMode
|
self.state.themeMode = prefs.themeMode
|
||||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
|
||||||
if !self.hasRelayConfigurationDraft {
|
if !self.hasRelayConfigurationDraft {
|
||||||
self.state.relayMode = prefs.relayConfiguration.mode
|
self.state.relayMode = prefs.relayConfiguration.mode
|
||||||
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
||||||
@@ -230,17 +225,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
|
||||||
if !diagnosticsIncluded { return }
|
|
||||||
Task {
|
|
||||||
preferences.setDiagnosticsEnabled(enabled)
|
|
||||||
messages.show(UiMessage(
|
|
||||||
text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage),
|
|
||||||
tone: .success
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Network
|
// MARK: - Network
|
||||||
|
|
||||||
func setRelayMode(_ mode: RelayPreferenceMode) {
|
func setRelayMode(_ mode: RelayPreferenceMode) {
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ struct StorageSettings: View {
|
|||||||
struct AboutSettings: View {
|
struct AboutSettings: View {
|
||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")!
|
private static let privacyPolicyURL = AppConfig.privacyPolicyURL
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
@@ -415,17 +415,6 @@ struct AboutSettings: View {
|
|||||||
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
|
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if DiagnosticsBuildConfig.included {
|
|
||||||
Section {
|
|
||||||
Toggle(isOn: Binding(
|
|
||||||
get: { model.state.diagnosticsEnabled },
|
|
||||||
set: { model.setDiagnosticsEnabled($0) }
|
|
||||||
)) {
|
|
||||||
Text(String(localized: L10n.Diagnostics.title))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +1,57 @@
|
|||||||
{
|
{
|
||||||
"fill": {
|
"fill" : {
|
||||||
"linear-gradient": [
|
"linear-gradient" : [
|
||||||
"extended-gray:1.00000,1.00000",
|
"extended-gray:1.00000,1.00000",
|
||||||
"display-p3:0.55433,0.59923,0.92884,1.00000"
|
"srgb:0.84942,0.81480,0.95401,1.00000"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"groups": [
|
"groups" : [
|
||||||
{
|
{
|
||||||
"blend-mode": "normal",
|
"blend-mode" : "normal",
|
||||||
"blur-material": null,
|
"blur-material" : null,
|
||||||
"layers": [
|
"layers" : [
|
||||||
{
|
{
|
||||||
"image-name": "Mask.svg",
|
"image-name" : "Mask.svg",
|
||||||
"name": "Mask"
|
"name" : "Mask"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lighting": "individual",
|
"lighting" : "individual",
|
||||||
"refractivity": {
|
"shadow" : {
|
||||||
"depth": 0.5,
|
"kind" : "neutral",
|
||||||
"enabled": true,
|
"opacity" : 0.6
|
||||||
"strength": 0
|
},
|
||||||
},
|
"specular" : true,
|
||||||
"shadow": {
|
"translucency" : {
|
||||||
"kind": "neutral",
|
"enabled" : true,
|
||||||
"opacity": 0.6
|
"value" : 0.8
|
||||||
},
|
}
|
||||||
"specular": true,
|
},
|
||||||
"translucency": {
|
{
|
||||||
"enabled": true,
|
"layers" : [
|
||||||
"value": 0.8
|
{
|
||||||
}
|
"image-name" : "Drop.svg",
|
||||||
},
|
"name" : "Drop"
|
||||||
{
|
},
|
||||||
"layers": [
|
{
|
||||||
{
|
"image-name" : "U.svg",
|
||||||
"image-name": "Drop.svg",
|
"name" : "U"
|
||||||
"name": "Drop"
|
}
|
||||||
},
|
],
|
||||||
{
|
"lighting" : "combined",
|
||||||
"image-name": "U.svg",
|
"shadow" : {
|
||||||
"name": "U"
|
"kind" : "layer-color",
|
||||||
}
|
"opacity" : 0.8
|
||||||
],
|
},
|
||||||
"lighting": "combined",
|
"translucency" : {
|
||||||
"shadow": {
|
"enabled" : true,
|
||||||
"kind": "neutral",
|
"value" : 0.4
|
||||||
"opacity": 0.6
|
}
|
||||||
},
|
}
|
||||||
"translucency": {
|
],
|
||||||
"enabled": true,
|
"supported-platforms" : {
|
||||||
"value": 0.4
|
"circles" : [
|
||||||
}
|
"watchOS"
|
||||||
}
|
],
|
||||||
],
|
"squares" : "shared"
|
||||||
"supported-platforms": {
|
}
|
||||||
"circles": [
|
}
|
||||||
"watchOS"
|
|
||||||
],
|
|
||||||
"squares": "shared"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,12 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- Required, not optional: VniDrop's iroh (QUIC/UDP) endpoint listens for
|
||||||
|
and ACCEPTS inbound connections from peers — either device can initiate
|
||||||
|
a transfer. See HandshakeService::accept in crates/vnidrop/src/handshake.rs.
|
||||||
|
Removing this breaks the receive/serve half of every transfer under the
|
||||||
|
sandbox. App Store automated review may flag it (no classic TcpListener);
|
||||||
|
justify via App Review Information rather than removing. -->
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
/// Fired after the sheet's dismissal animation completes (as opposed to
|
||||||
|
/// `onDismiss`, which requests the close). Lets callers serialize a follow-up
|
||||||
|
/// sheet against this one's actual teardown instead of guessing a delay.
|
||||||
|
let onDismissed: (() -> Void)?
|
||||||
@ViewBuilder let drawerContent: () -> DrawerContent
|
@ViewBuilder let drawerContent: () -> DrawerContent
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content.sheet(
|
content.sheet(
|
||||||
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
|
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }),
|
||||||
|
onDismiss: onDismissed
|
||||||
) {
|
) {
|
||||||
SheetChrome(onClose: onDismiss) { drawerContent() }
|
SheetChrome(onClose: onDismiss) { drawerContent() }
|
||||||
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
||||||
@@ -56,11 +61,12 @@ extension View {
|
|||||||
isPresented: Binding<Bool>,
|
isPresented: Binding<Bool>,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onDismiss: @escaping () -> Void,
|
onDismiss: @escaping () -> Void,
|
||||||
|
onDismissed: (() -> Void)? = nil,
|
||||||
@ViewBuilder content: @escaping () -> DrawerContent
|
@ViewBuilder content: @escaping () -> DrawerContent
|
||||||
) -> some View {
|
) -> some View {
|
||||||
modifier(AdaptiveDrawer(
|
modifier(AdaptiveDrawer(
|
||||||
isPresented: isPresented, windowClass: windowClass,
|
isPresented: isPresented, windowClass: windowClass,
|
||||||
onDismiss: onDismiss, drawerContent: content
|
onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ configs:
|
|||||||
# Project-wide build settings (applied to every target/config).
|
# Project-wide build settings (applied to every target/config).
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
|
# Apple Silicon only. Intel Macs are unsupported (going EOL with macOS 28), and
|
||||||
|
# the Rust core's macOS slice (vnidrop.xcframework) is built arm64-only, so a
|
||||||
|
# universal link would fail looking for x86_64 symbols anyway.
|
||||||
|
ARCHS: arm64
|
||||||
# Strip unreachable code from release binaries.
|
# Strip unreachable code from release binaries.
|
||||||
DEAD_CODE_STRIPPING: YES
|
DEAD_CODE_STRIPPING: YES
|
||||||
# Flag user-facing strings that aren't localized (the app ships 9 languages).
|
# Flag user-facing strings that aren't localized (the app ships 9 languages).
|
||||||
@@ -135,11 +139,6 @@ targets:
|
|||||||
# provisioning profile, which direct distribution avoids. (App Store target
|
# provisioning profile, which direct distribution avoids. (App Store target
|
||||||
# keeps VniDrop.entitlements with the sandbox.)
|
# keeps VniDrop.entitlements with the sandbox.)
|
||||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
|
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
|
||||||
# The Rust core's macOS slice (vnidrop.xcframework) is arm64-only
|
|
||||||
# (build-core.sh builds aarch64-apple-darwin only), so the direct build is
|
|
||||||
# Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default)
|
|
||||||
# link doesn't fail looking for x86_64 symbols.
|
|
||||||
ARCHS: arm64
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: Sparkle
|
- package: Sparkle
|
||||||
|
|
||||||
@@ -160,6 +159,25 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- target: VniDrop
|
- target: VniDrop
|
||||||
|
|
||||||
|
# UI test bundle used only to capture App Store screenshots (iOS/iPad).
|
||||||
|
# Driven by apple/scripts/appstore-screenshots.sh.
|
||||||
|
VniDropUITests:
|
||||||
|
type: bundle.ui-testing
|
||||||
|
supportedDestinations: [iOS]
|
||||||
|
configFiles:
|
||||||
|
Debug: Signing.xcconfig
|
||||||
|
Release: Signing.xcconfig
|
||||||
|
Release-Direct: Signing.xcconfig
|
||||||
|
sources:
|
||||||
|
- path: UITests
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
TEST_TARGET_NAME: VniDrop
|
||||||
|
dependencies:
|
||||||
|
- target: VniDrop
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
VniDrop:
|
VniDrop:
|
||||||
build:
|
build:
|
||||||
@@ -189,3 +207,13 @@ schemes:
|
|||||||
config: Release-Direct
|
config: Release-Direct
|
||||||
archive:
|
archive:
|
||||||
config: Release-Direct
|
config: Release-Direct
|
||||||
|
|
||||||
|
# App Store screenshot capture (see apple/scripts/appstore-screenshots.sh).
|
||||||
|
VniDropScreenshots:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
VniDrop: all
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
targets:
|
||||||
|
- VniDropUITests
|
||||||
|
|||||||
105
apple/scripts/appstore-screenshots.sh
Executable file
105
apple/scripts/appstore-screenshots.sh
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Captures App Store screenshots for the VniDrop iOS/iPad app by running the
|
||||||
|
# VniDropUITests screenshot test on a simulator and extracting the attachments
|
||||||
|
# at the device's native resolution.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# apple/scripts/appstore-screenshots.sh [output-dir]
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# SCREENSHOT_DEVICE Simulator device name (default: "iPad Pro 13-inch (M5)")
|
||||||
|
# 13-inch iPad → 2064×2752, accepted by App Store Connect.
|
||||||
|
#
|
||||||
|
# The output directory receives one PNG per tab (01-Send.png, 02-Receive.png, …).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
APPLE_DIR="$REPO_ROOT/apple"
|
||||||
|
|
||||||
|
DEVICE="${SCREENSHOT_DEVICE:-iPad Pro 13-inch (M5)}"
|
||||||
|
OUT_DIR="${1:-$HOME/Desktop/vnidrop-appstore-screenshots}"
|
||||||
|
|
||||||
|
echo "==> Regenerating Xcode project (picks up the screenshot target)"
|
||||||
|
(cd "$APPLE_DIR" && xcodegen generate >/dev/null)
|
||||||
|
|
||||||
|
echo "==> Resolving simulator: $DEVICE"
|
||||||
|
# Match the device name literally (it contains parentheses, e.g. "(M5)"), then
|
||||||
|
# pull the UUID from the same line.
|
||||||
|
DEVICE_LINE="$(xcrun simctl list devices available | grep -F "$DEVICE (" | head -1)"
|
||||||
|
UDID="$(printf '%s' "$DEVICE_LINE" \
|
||||||
|
| grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)"
|
||||||
|
if [ -z "${UDID:-}" ]; then
|
||||||
|
echo "error: no available simulator named '$DEVICE'." >&2
|
||||||
|
echo " list options with: xcrun simctl list devices available" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " udid: $UDID"
|
||||||
|
|
||||||
|
echo "==> Booting simulator"
|
||||||
|
xcrun simctl boot "$UDID" 2>/dev/null || true
|
||||||
|
xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Clean marketing status bar (Apple's 9:41, full signal/battery).
|
||||||
|
xcrun simctl status_bar "$UDID" override \
|
||||||
|
--time "9:41" \
|
||||||
|
--batteryState charged --batteryLevel 100 \
|
||||||
|
--cellularMode active --cellularBars 4 \
|
||||||
|
--wifiMode active --wifiBars 3 >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
RESULT_DIR="$(mktemp -d)"
|
||||||
|
RESULT="$RESULT_DIR/screenshots.xcresult"
|
||||||
|
ATT_DIR="$RESULT_DIR/attachments"
|
||||||
|
trap 'rm -rf "$RESULT_DIR"' EXIT
|
||||||
|
|
||||||
|
echo "==> Running UI screenshot test (this builds and launches the app)"
|
||||||
|
xcodebuild test \
|
||||||
|
-project "$APPLE_DIR/VniDrop.xcodeproj" \
|
||||||
|
-scheme VniDropScreenshots \
|
||||||
|
-destination "platform=iOS Simulator,id=$UDID" \
|
||||||
|
-resultBundlePath "$RESULT" \
|
||||||
|
-only-testing:VniDropUITests \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
| tail -12
|
||||||
|
|
||||||
|
echo "==> Extracting screenshots"
|
||||||
|
xcrun xcresulttool export attachments --path "$RESULT" --output-path "$ATT_DIR"
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
python3 - "$ATT_DIR" "$OUT_DIR" <<'PY'
|
||||||
|
import json, os, re, shutil, sys
|
||||||
|
|
||||||
|
att_dir, out_dir = sys.argv[1], sys.argv[2]
|
||||||
|
manifest = os.path.join(att_dir, "manifest.json")
|
||||||
|
with open(manifest) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Xcode suffixes attachment names with "_<n>_<uuid>.png"; keep only "NN-Name".
|
||||||
|
pattern = re.compile(r"^(\d\d-[A-Za-z]+)")
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def walk(node):
|
||||||
|
global count
|
||||||
|
if isinstance(node, dict):
|
||||||
|
name = node.get("suggestedHumanReadableName")
|
||||||
|
src = node.get("exportedFileName")
|
||||||
|
m = pattern.match(name) if name else None
|
||||||
|
if m and src:
|
||||||
|
base = f"{m.group(1)}.png"
|
||||||
|
shutil.copyfile(os.path.join(att_dir, src), os.path.join(out_dir, base))
|
||||||
|
print(f" {base}")
|
||||||
|
count += 1
|
||||||
|
for v in node.values():
|
||||||
|
walk(v)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for v in node:
|
||||||
|
walk(v)
|
||||||
|
|
||||||
|
walk(data)
|
||||||
|
if count == 0:
|
||||||
|
sys.exit("error: no named screenshots found in the result bundle")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "==> Done. Screenshots in: $OUT_DIR"
|
||||||
|
ls -1 "$OUT_DIR"
|
||||||
@@ -44,6 +44,13 @@ export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}"
|
|||||||
# This never touches the Rust crate — it only changes how the build is invoked.
|
# This never touches the Rust crate — it only changes how the build is invoked.
|
||||||
export CARGO_PROFILE_DEV_STRIP=none
|
export CARGO_PROFILE_DEV_STRIP=none
|
||||||
|
|
||||||
|
# The workspace `[profile.release] lto = "thin"` corrupts host proc-macro / build
|
||||||
|
# script dylibs when cross-compiling ("mis-aligned LINKEDIT string pool"). Cargo
|
||||||
|
# forbids overriding `lto` per build-override, so disable thin LTO for the whole
|
||||||
|
# release build here — the crate is still fully optimized (opt-level 3, debuginfo
|
||||||
|
# stripped), which is what shrinks the static lib. This never edits the Cargo crate.
|
||||||
|
export CARGO_PROFILE_RELEASE_LTO=false
|
||||||
|
|
||||||
IOS_TARGET="aarch64-apple-ios"
|
IOS_TARGET="aarch64-apple-ios"
|
||||||
SIM_ARM_TARGET="aarch64-apple-ios-sim"
|
SIM_ARM_TARGET="aarch64-apple-ios-sim"
|
||||||
SIM_X64_TARGET="x86_64-apple-ios"
|
SIM_X64_TARGET="x86_64-apple-ios"
|
||||||
|
|||||||
44
apple/scripts/generate-appconfig.sh
Executable file
44
apple/scripts/generate-appconfig.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Generates apple/VniDrop/Generated/AppConfig.swift from the shared app.properties
|
||||||
|
# so app-wide constants (privacy policy URL, …) have a single source of truth
|
||||||
|
# across Apple and KMP. Regenerate instead of editing the output.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||||
|
config_file="${VNIDROP_APP_PROPERTIES:-$repo_root/app.properties}"
|
||||||
|
output_dir="${VNIDROP_APPLE_GENERATED_DIR:-$repo_root/apple/VniDrop/Generated}"
|
||||||
|
|
||||||
|
read_property() {
|
||||||
|
local key=$1
|
||||||
|
local value
|
||||||
|
value="$(sed -n "s/^${key}=//p" "$config_file")"
|
||||||
|
[[ -n "$value" ]] || { printf 'Missing %s in %s\n' "$key" "$config_file" >&2; exit 1; }
|
||||||
|
[[ $(printf '%s\n' "$value" | wc -l | tr -d ' ') == 1 ]] ||
|
||||||
|
{ printf 'Duplicate %s in %s\n' "$key" "$config_file" >&2; exit 1; }
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Escape for a Swift string literal.
|
||||||
|
swift_escape() {
|
||||||
|
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
privacy_url="$(read_property PRIVACY_POLICY_URL)"
|
||||||
|
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
tmp="$(mktemp "$output_dir/.AppConfig.swift.XXXXXX")"
|
||||||
|
cat > "$tmp" <<EOF
|
||||||
|
// Generated by apple/scripts/generate-appconfig.sh from app.properties.
|
||||||
|
// Regenerate this file instead of editing it.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// App-wide constants injected at build time from the shared \`app.properties\`.
|
||||||
|
enum AppConfig {
|
||||||
|
static let privacyPolicyURL = URL(string: "$(swift_escape "$privacy_url")")!
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
mv "$tmp" "$output_dir/AppConfig.swift"
|
||||||
72
apple/scripts/package-core.sh
Executable file
72
apple/scripts/package-core.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Packages the prebuilt Apple core into a single zip + checksum, for attaching to
|
||||||
|
# the GitHub Release. Lets a consumer (e.g. Xcode Cloud) use the compiled core
|
||||||
|
# instead of installing Rust and running build-core.sh. Run AFTER the core exists
|
||||||
|
# (apple/scripts/build-core.sh, or `make apple-core` / `make build-apple-dmg`).
|
||||||
|
#
|
||||||
|
# The bundle carries both build outputs of build-core.sh:
|
||||||
|
# - vnidrop.xcframework (static libs for device/sim/macOS + the FFI module)
|
||||||
|
# - Vnidrop.swift (generated UniFFI bindings — a plain source file, not
|
||||||
|
# part of the xcframework, so it must ship alongside)
|
||||||
|
#
|
||||||
|
# Produces (under apple/dist):
|
||||||
|
# VnidropCore-<version>.zip
|
||||||
|
# VnidropCore-<version>.zip.sha256 (sha256sum(1)/shasum-compatible format)
|
||||||
|
#
|
||||||
|
# Zip layout (root):
|
||||||
|
# vnidrop.xcframework/
|
||||||
|
# Vnidrop.swift
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
APPLE_DIR="$REPO_ROOT/apple"
|
||||||
|
PKG_DIR="$APPLE_DIR/VnidropCore"
|
||||||
|
XCFRAMEWORK="$PKG_DIR/vnidrop.xcframework"
|
||||||
|
BINDINGS="$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
|
||||||
|
DIST_DIR="$APPLE_DIR/dist"
|
||||||
|
|
||||||
|
VERSION="$("$REPO_ROOT/packaging/version/resolve-version.sh" product)"
|
||||||
|
NAME="VnidropCore-$VERSION"
|
||||||
|
ZIP="$DIST_DIR/$NAME.zip"
|
||||||
|
CHECKSUM="$ZIP.sha256"
|
||||||
|
|
||||||
|
[ -d "$XCFRAMEWORK" ] || {
|
||||||
|
echo "error: missing xcframework: $XCFRAMEWORK" >&2
|
||||||
|
echo " build the core first (apple/scripts/build-core.sh)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[ -f "$BINDINGS" ] || {
|
||||||
|
echo "error: missing generated bindings: $BINDINGS" >&2
|
||||||
|
echo " build the core first (apple/scripts/build-core.sh)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
rm -f "$ZIP" "$CHECKSUM"
|
||||||
|
|
||||||
|
# Stage a clean tree so the zip root holds exactly the two payloads (no absolute
|
||||||
|
# paths or stray parent directories leak into the archive).
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
cp -R "$XCFRAMEWORK" "$STAGE/vnidrop.xcframework"
|
||||||
|
cp "$BINDINGS" "$STAGE/Vnidrop.swift"
|
||||||
|
|
||||||
|
# -X drops extra file attributes for a stabler archive across machines.
|
||||||
|
( cd "$STAGE" && zip -q -r -X "$ZIP" vnidrop.xcframework Vnidrop.swift )
|
||||||
|
|
||||||
|
# sha256sum on Linux; shasum -a 256 on macOS. Both emit "<hash> <name>", which
|
||||||
|
# `sha256sum --check` (used by assemble-release.sh) accepts.
|
||||||
|
(
|
||||||
|
cd "$DIST_DIR"
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$NAME.zip" > "$NAME.zip.sha256"
|
||||||
|
else
|
||||||
|
shasum -a 256 "$NAME.zip" > "$NAME.zip.sha256"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "==> Packaged prebuilt core"
|
||||||
|
echo " zip: $ZIP"
|
||||||
|
echo " checksum: $CHECKSUM"
|
||||||
59
apple/scripts/tests/test-generate-appconfig.sh
Executable file
59
apple/scripts/tests/test-generate-appconfig.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Tests apple/scripts/generate-appconfig.sh: the shared app.properties is read
|
||||||
|
# correctly, values are emitted as valid escaped Swift, and a missing key fails.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
generator="$script_dir/../generate-appconfig.sh"
|
||||||
|
repo_root="$(cd "$script_dir/../../.." && pwd)"
|
||||||
|
scratch="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$scratch"' EXIT
|
||||||
|
|
||||||
|
# Run the generator against a fixture app.properties, emitting into a temp dir.
|
||||||
|
generate() {
|
||||||
|
VNIDROP_APP_PROPERTIES="$scratch/app.properties" \
|
||||||
|
VNIDROP_APPLE_GENERATED_DIR="$scratch/out" \
|
||||||
|
"$generator"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_failure() {
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
printf 'Expected command to fail: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local file=$1 needle=$2
|
||||||
|
grep -qF "$needle" "$file" ||
|
||||||
|
{ printf 'Expected %s to contain: %s\n' "$file" "$needle" >&2; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
out="$scratch/out/AppConfig.swift"
|
||||||
|
|
||||||
|
# 1. Nominal value is emitted verbatim as a Swift URL literal.
|
||||||
|
printf 'PRIVACY_POLICY_URL=%s\n' 'https://example.test/privacy/' > "$scratch/app.properties"
|
||||||
|
generate
|
||||||
|
assert_contains "$out" 'URL(string: "https://example.test/privacy/")!'
|
||||||
|
assert_contains "$out" 'enum AppConfig'
|
||||||
|
|
||||||
|
# 2. Characters special to a Swift string literal are escaped.
|
||||||
|
printf 'PRIVACY_POLICY_URL=%s\n' 'https://a.test/"q"\z' > "$scratch/app.properties"
|
||||||
|
generate
|
||||||
|
assert_contains "$out" 'URL(string: "https://a.test/\"q\"\\z")!'
|
||||||
|
|
||||||
|
# 3. A missing key fails instead of emitting an empty value.
|
||||||
|
printf 'OTHER_KEY=value\n' > "$scratch/app.properties"
|
||||||
|
expect_failure generate
|
||||||
|
|
||||||
|
# 4. A duplicated key fails.
|
||||||
|
printf 'PRIVACY_POLICY_URL=a\nPRIVACY_POLICY_URL=b\n' > "$scratch/app.properties"
|
||||||
|
expect_failure generate
|
||||||
|
|
||||||
|
# 5. The real committed app.properties produces an https URL.
|
||||||
|
VNIDROP_APPLE_GENERATED_DIR="$scratch/real" "$generator"
|
||||||
|
assert_contains "$scratch/real/AppConfig.swift" 'URL(string: "https://'
|
||||||
|
|
||||||
|
printf 'generate-appconfig tests passed.\n'
|
||||||
40
ci_scripts/README.md
Normal file
40
ci_scripts/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Xcode Cloud CI scripts
|
||||||
|
|
||||||
|
Xcode Cloud runs the scripts in this directory around each build. Only
|
||||||
|
`ci_post_clone.sh` is used today; add `ci_pre_xcodebuild.sh` /
|
||||||
|
`ci_post_xcodebuild.sh` here if later steps are needed.
|
||||||
|
|
||||||
|
## What `ci_post_clone.sh` does
|
||||||
|
|
||||||
|
The Xcode project (`apple/VniDrop.xcodeproj`) and its generated inputs are **not**
|
||||||
|
committed — they are produced by XcodeGen, localization, and the Rust core build.
|
||||||
|
Since Xcode Cloud only checks out the repository, the post-clone script:
|
||||||
|
|
||||||
|
1. installs `swiftlint`, `xcodegen`, and `bun`;
|
||||||
|
2. **downloads the prebuilt core** (`vnidrop.xcframework` + `Vnidrop.swift`) from
|
||||||
|
the matching GitHub Release asset `VnidropCore-<version>.zip` — Xcode Cloud
|
||||||
|
never builds Rust;
|
||||||
|
3. runs localization + version/app config codegen and `xcodegen generate`
|
||||||
|
(equivalent to `make apple-project` without the `apple-core` step).
|
||||||
|
|
||||||
|
The core asset for version `X.Y.Z` must be published on the `vX.Y.Z` release
|
||||||
|
before an Xcode Cloud build for that version runs (see
|
||||||
|
`apple/scripts/package-core.sh` and `.github/workflows/apple-release.yml`).
|
||||||
|
|
||||||
|
### Overrides (env vars, optional)
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `VNIDROP_CORE_REPO` | `sudosylabs/vnidrop` | Release repository to download the core from |
|
||||||
|
| `VNIDROP_CORE_TAG` | `v<product-version>` | Release tag holding the core asset |
|
||||||
|
|
||||||
|
## Workflow configuration (App Store Connect)
|
||||||
|
|
||||||
|
The workflow itself (product, scheme, triggers, actions) is configured in App
|
||||||
|
Store Connect, not in the repository. Point it at:
|
||||||
|
|
||||||
|
- **Project:** `apple/VniDrop.xcodeproj` (generated by the post-clone script)
|
||||||
|
- **Scheme:** `VniDrop` (App Store / TestFlight target; shared, see `apple/project.yml`)
|
||||||
|
|
||||||
|
Archive actions use the release Rust profile via the published core asset; build
|
||||||
|
and test actions reuse the same prebuilt core.
|
||||||
72
ci_scripts/ci_post_clone.sh
Executable file
72
ci_scripts/ci_post_clone.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Xcode Cloud post-clone step.
|
||||||
|
#
|
||||||
|
# The Apple Xcode project is generated (XcodeGen) and gitignored, and it links a
|
||||||
|
# prebuilt Rust XCFramework plus generated localization/config files. Xcode Cloud
|
||||||
|
# only checks out the repository, so this script:
|
||||||
|
# 1. installs the non-Rust build tooling (swiftlint, xcodegen, bun);
|
||||||
|
# 2. downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift) from the
|
||||||
|
# matching GitHub Release asset — we never build Rust here;
|
||||||
|
# 3. reproduces `make apple-project` minus the Rust `apple-core` step.
|
||||||
|
#
|
||||||
|
# Xcode Cloud runs this from the `ci_scripts` directory; CI_PRIMARY_REPOSITORY_PATH
|
||||||
|
# points at the checked-out repository root.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
echo "==> Installing build tooling (Homebrew)"
|
||||||
|
# swiftlint: enforced by a build phase (fails the build if missing).
|
||||||
|
# xcodegen: generates apple/VniDrop.xcodeproj from apple/project.yml.
|
||||||
|
brew install swiftlint xcodegen
|
||||||
|
|
||||||
|
echo "==> Installing Bun (localization generator)"
|
||||||
|
if ! command -v bun >/dev/null 2>&1; then
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
fi
|
||||||
|
export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
|
||||||
|
export PATH="$BUN_INSTALL/bin:$PATH"
|
||||||
|
|
||||||
|
# --- Prebuilt core: download instead of building Rust -------------------------
|
||||||
|
# The Apple core (xcframework + UniFFI bindings) is published as a release asset
|
||||||
|
# by apple/scripts/package-core.sh. See docs at the top of that script.
|
||||||
|
VERSION="$(packaging/version/resolve-version.sh product)"
|
||||||
|
CORE_REPO="${VNIDROP_CORE_REPO:-sudosylabs/vnidrop}"
|
||||||
|
CORE_TAG="${VNIDROP_CORE_TAG:-v$VERSION}"
|
||||||
|
CORE_ZIP="VnidropCore-$VERSION.zip"
|
||||||
|
CORE_BASE_URL="https://github.com/$CORE_REPO/releases/download/$CORE_TAG"
|
||||||
|
|
||||||
|
PKG_DIR="$REPO_ROOT/apple/VnidropCore"
|
||||||
|
DOWNLOAD_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$DOWNLOAD_DIR"' EXIT
|
||||||
|
|
||||||
|
echo "==> Downloading prebuilt core $CORE_ZIP from $CORE_REPO@$CORE_TAG"
|
||||||
|
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP" -o "$DOWNLOAD_DIR/$CORE_ZIP"
|
||||||
|
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP.sha256" -o "$DOWNLOAD_DIR/$CORE_ZIP.sha256"
|
||||||
|
|
||||||
|
echo "==> Verifying checksum"
|
||||||
|
(
|
||||||
|
cd "$DOWNLOAD_DIR"
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum --check "$CORE_ZIP.sha256"
|
||||||
|
else
|
||||||
|
shasum -a 256 --check "$CORE_ZIP.sha256"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "==> Installing core into apple/VnidropCore"
|
||||||
|
unzip -q -o "$DOWNLOAD_DIR/$CORE_ZIP" -d "$DOWNLOAD_DIR/extracted"
|
||||||
|
# Zip root holds: vnidrop.xcframework/ and Vnidrop.swift (see package-core.sh).
|
||||||
|
rm -rf "$PKG_DIR/vnidrop.xcframework"
|
||||||
|
cp -R "$DOWNLOAD_DIR/extracted/vnidrop.xcframework" "$PKG_DIR/vnidrop.xcframework"
|
||||||
|
mkdir -p "$PKG_DIR/Sources/VnidropCore"
|
||||||
|
cp "$DOWNLOAD_DIR/extracted/Vnidrop.swift" "$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
|
||||||
|
|
||||||
|
# --- Generate the project (everything except the Rust core) -------------------
|
||||||
|
echo "==> Generating localization, version/app config, and the Xcode project"
|
||||||
|
make localization apple-version-config apple-app-config
|
||||||
|
(cd "$REPO_ROOT/apple" && xcodegen generate)
|
||||||
|
|
||||||
|
echo "==> ci_post_clone complete"
|
||||||
@@ -52,6 +52,13 @@ impl HandshakeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProtocolHandler for HandshakeService {
|
impl ProtocolHandler for HandshakeService {
|
||||||
|
// This handler ACCEPTS inbound QUIC connections from peers: iroh binds a
|
||||||
|
// listening endpoint and this device responds to connections initiated by
|
||||||
|
// the remote side. On macOS this is why the App Sandbox requires the
|
||||||
|
// `com.apple.security.network.server` entitlement (see
|
||||||
|
// apple/VniDrop/Resources/VniDrop.entitlements) in addition to
|
||||||
|
// `network.client` — either peer can initiate a transfer, so both the
|
||||||
|
// client and server sides of a connection are used.
|
||||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||||
let remote_endpoint_id = connection.remote_id().to_string();
|
let remote_endpoint_id = connection.remote_id().to_string();
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import type { Metadata } from "next";
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Privacy policy",
|
title: "Privacy policy",
|
||||||
description:
|
description:
|
||||||
"How VniDrop handles transfers, local app data, optional diagnostics, bug reports, and website visits.",
|
"How VniDrop handles transfers, local app data, optional bug reports, and website visits.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
["scope", "Scope"],
|
["scope", "Scope"],
|
||||||
["transfers", "Transfers"],
|
["transfers", "Transfers"],
|
||||||
["local-data", "Local data"],
|
["local-data", "Local data"],
|
||||||
["diagnostics", "Diagnostics"],
|
["bug-reports", "Bug reports"],
|
||||||
["website", "Website"],
|
["website", "Website"],
|
||||||
["permissions", "Permissions"],
|
["permissions", "Permissions"],
|
||||||
["providers", "Service providers"],
|
["providers", "Service providers"],
|
||||||
@@ -29,9 +29,9 @@ export default function PrivacyPage() {
|
|||||||
<h1>Privacy Policy</h1>
|
<h1>Privacy Policy</h1>
|
||||||
<p>
|
<p>
|
||||||
This policy explains what moves between devices, what stays local, and what is sent
|
This policy explains what moves between devices, what stays local, and what is sent
|
||||||
only when you choose to share diagnostics or a bug report.
|
only when you choose to submit a bug report.
|
||||||
</p>
|
</p>
|
||||||
<p className="privacy-meta">Effective July 16, 2026 · Version 1.1</p>
|
<p className="privacy-meta">Effective August 2, 2026 · Version 1.2</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export default function PrivacyPage() {
|
|||||||
<p>
|
<p>
|
||||||
VniDrop has no user accounts and does not upload your transfer to a VniDrop file
|
VniDrop has no user accounts and does not upload your transfer to a VniDrop file
|
||||||
store. Files travel over an authenticated, end-to-end encrypted connection.
|
store. Files travel over an authenticated, end-to-end encrypted connection.
|
||||||
Product diagnostics are opt-in; a bug report is sent only when you submit one.
|
VniDrop has no telemetry or analytics; a bug report is sent only when you submit one.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export default function PrivacyPage() {
|
|||||||
<h2>Scope and who “VniDrop” means</h2>
|
<h2>Scope and who “VniDrop” means</h2>
|
||||||
<p>
|
<p>
|
||||||
This policy covers the official VniDrop website, the VniDrop applications for
|
This policy covers the official VniDrop website, the VniDrop applications for
|
||||||
Android, iOS, macOS, Windows, and Linux, and the diagnostics service configured by
|
Android, iOS, macOS, Windows, and Linux, and the bug-report service configured by
|
||||||
the official project. For an official release, VniDrop’s data controller is the
|
the official project. For an official release, VniDrop’s data controller is the
|
||||||
individual publisher named in the applicable app-store listing. In this policy,
|
individual publisher named in the applicable app-store listing. In this policy,
|
||||||
“VniDrop,” “we,” and “us” also include the maintainers acting on that publisher’s
|
“VniDrop,” “we,” and “us” also include the maintainers acting on that publisher’s
|
||||||
@@ -72,7 +72,7 @@ export default function PrivacyPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
VniDrop is open-source software. A build distributed or operated by someone else
|
VniDrop is open-source software. A build distributed or operated by someone else
|
||||||
may use different networking infrastructure, diagnostics settings, or website
|
may use different networking infrastructure, bug-report settings, or website
|
||||||
hosting. That distributor is responsible for explaining its own practices.
|
hosting. That distributor is responsible for explaining its own practices.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -117,9 +117,9 @@ export default function PrivacyPage() {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>device identity and networking keys used to establish secure connections;</li>
|
<li>device identity and networking keys used to establish secure connections;</li>
|
||||||
<li>active shares, transfer history, receiver requests, progress, and status;</li>
|
<li>active shares, transfer history, receiver requests, progress, and status;</li>
|
||||||
<li>app preferences, including access and diagnostics choices;</li>
|
<li>app preferences, including access choices;</li>
|
||||||
<li>download destinations and locally managed transfer data; and</li>
|
<li>download destinations and locally managed transfer data; and</li>
|
||||||
<li>an anonymous installation identifier used only for diagnostics correlation.</li>
|
<li>an anonymous installation identifier used only for bug-report correlation.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
This information remains until you remove the relevant history, stop or delete a
|
This information remains until you remove the relevant history, stop or delete a
|
||||||
@@ -129,33 +129,27 @@ export default function PrivacyPage() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="diagnostics" className="policy-section">
|
<section id="bug-reports" className="policy-section">
|
||||||
<h2>Optional diagnostics and bug reports</h2>
|
<h2>Optional bug reports</h2>
|
||||||
<h3>Automatic product diagnostics</h3>
|
|
||||||
<p>
|
<p>
|
||||||
Official releases indicate in the app settings whether automatic product
|
VniDrop has no automatic telemetry, usage analytics, or crash auto-reporting.
|
||||||
diagnostics are included. When included, automatic usage events and crash reports
|
Nothing is sent to a bug-report service unless you explicitly submit a report.
|
||||||
are disabled until you enable “Share diagnostics.” If enabled, VniDrop may send an
|
|
||||||
anonymous installation ID, app version, platform, sparse event names and properties,
|
|
||||||
crash type and message, a redacted stack trace, timestamps, and recent in-app
|
|
||||||
breadcrumbs. You can turn this off at any time; doing so also removes pending local
|
|
||||||
crash reports.
|
|
||||||
</p>
|
</p>
|
||||||
<h3>User-submitted bug reports</h3>
|
<h3>User-submitted bug reports</h3>
|
||||||
<p>
|
<p>
|
||||||
A bug report is separate from the diagnostics toggle and is sent only when you press
|
A bug report is sent only when you press submit. It can contain what you say
|
||||||
submit. It can contain what you say happened, what you expected, reproduction steps,
|
happened, what you expected, reproduction steps, an optional contact email, app and
|
||||||
an optional contact email, app and platform versions, an anonymous installation ID,
|
platform versions, an anonymous installation ID, device name and model, operating
|
||||||
device name and model, operating system, network and battery information, recent
|
system, network and battery information, and optional recent logs. You can exclude
|
||||||
breadcrumbs, and optional recent logs. You can exclude logs before submitting.
|
logs before submitting.
|
||||||
</p>
|
</p>
|
||||||
<h3>Data deliberately excluded</h3>
|
<h3>Data deliberately excluded</h3>
|
||||||
<p>
|
<p>
|
||||||
Automatic diagnostics are designed to exclude transfer contents, invitations, and
|
Bug reports are designed to exclude transfer contents, invitations, and file paths.
|
||||||
file paths. Before diagnostic text or optional logs are sent, VniDrop applies rules
|
Before optional logs are sent, VniDrop applies rules intended to redact invitation
|
||||||
intended to redact invitation tokens, endpoint identifiers, absolute paths, file and
|
tokens, endpoint identifiers, absolute paths, file and content URIs, and platform
|
||||||
content URIs, and platform document identifiers. No redaction system is perfect, so
|
document identifiers. No redaction system is perfect, so review anything you type
|
||||||
review anything you type into a bug report and avoid including secrets.
|
into a bug report and avoid including secrets.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -223,7 +217,7 @@ export default function PrivacyPage() {
|
|||||||
<dt>Cloudflare</dt>
|
<dt>Cloudflare</dt>
|
||||||
<dd>
|
<dd>
|
||||||
Proxies website requests and provides DNS, security, and abuse controls. When
|
Proxies website requests and provides DNS, security, and abuse controls. When
|
||||||
the optional diagnostics service is configured, it uses Cloudflare Workers, D1,
|
the optional bug-report service is configured, it uses Cloudflare Workers, D1,
|
||||||
and R2.
|
and R2.
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,11 +294,7 @@ export default function PrivacyPage() {
|
|||||||
<td>Until you delete them, clear app data, or uninstall</td>
|
<td>Until you delete them, clear app data, or uninstall</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Pending local crash reports</th>
|
<th scope="row">Server bug reports</th>
|
||||||
<td>Up to 30 days and 20 reports; deleted when diagnostics is disabled</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">Server diagnostics and bug reports</th>
|
|
||||||
<td>The current project configuration is 90 days, with scheduled deletion</td>
|
<td>The current project configuration is 90 days, with scheduled deletion</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -317,7 +307,7 @@ export default function PrivacyPage() {
|
|||||||
<p>
|
<p>
|
||||||
Operational backups, provider logs, and deletion backlogs may persist briefly beyond
|
Operational backups, provider logs, and deletion backlogs may persist briefly beyond
|
||||||
the stated period where necessary for security, integrity, or legal obligations. If
|
the stated period where necessary for security, integrity, or legal obligations. If
|
||||||
the production diagnostics retention configuration changes, this policy should be
|
the production bug-report retention configuration changes, this policy should be
|
||||||
updated to match it.
|
updated to match it.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -325,7 +315,6 @@ export default function PrivacyPage() {
|
|||||||
<section id="choices" className="policy-section">
|
<section id="choices" className="policy-section">
|
||||||
<h2>Your choices and rights</h2>
|
<h2>Your choices and rights</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Enable or disable “Share diagnostics” in VniDrop settings.</li>
|
|
||||||
<li>
|
<li>
|
||||||
Submit a bug report only when you choose, omit contact information, and exclude
|
Submit a bug report only when you choose, omit contact information, and exclude
|
||||||
logs.
|
logs.
|
||||||
@@ -343,7 +332,7 @@ export default function PrivacyPage() {
|
|||||||
<p>
|
<p>
|
||||||
Depending on where you live, privacy law may provide rights to access, correct,
|
Depending on where you live, privacy law may provide rights to access, correct,
|
||||||
delete, restrict, or object to processing of personal information. Because VniDrop
|
delete, restrict, or object to processing of personal information. Because VniDrop
|
||||||
has no account and automatic diagnostics use an anonymous installation ID, we may
|
has no account and bug reports use an anonymous installation ID, we may
|
||||||
not be able to connect a server record to you without additional information. Use
|
not be able to connect a server record to you without additional information. Use
|
||||||
the contact method below and provide only what is needed to locate your submission.
|
the contact method below and provide only what is needed to locate your submission.
|
||||||
</p>
|
</p>
|
||||||
@@ -353,7 +342,7 @@ export default function PrivacyPage() {
|
|||||||
<h2>Security</h2>
|
<h2>Security</h2>
|
||||||
<p>
|
<p>
|
||||||
VniDrop uses authenticated end-to-end encrypted connections, content verification,
|
VniDrop uses authenticated end-to-end encrypted connections, content verification,
|
||||||
deny-by-default share access, bounded diagnostics payloads, redaction, and safe file
|
deny-by-default share access, bounded bug-report payloads, redaction, and safe file
|
||||||
publishing that avoids silently replacing an existing file. No system can guarantee
|
publishing that avoids silently replacing an existing file. No system can guarantee
|
||||||
absolute security. Keep invitations private, verify receiver names, keep your device
|
absolute security. Keep invitations private, verify receiver names, keep your device
|
||||||
updated, and stop sharing when a transfer is finished.
|
updated, and stop sharing when a transfer is finished.
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ android.nonTransitiveRClass=true
|
|||||||
android.sourceset.disallowProvider=false
|
android.sourceset.disallowProvider=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
|
||||||
# VniDrop: compile-time diagnostics/telemetry product surface.
|
# VniDrop: compile-time bug-report delivery surface.
|
||||||
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack.
|
# false → user-initiated bug reports fall back to a NoOp transport (never sent).
|
||||||
# Bug report UI remains available (user-initiated).
|
|
||||||
# Enable per build only when endpoint and ingest key are configured:
|
# Enable per build only when endpoint and ingest key are configured:
|
||||||
# ./gradlew … -Pvnidrop.diagnostics.included=true
|
# ./gradlew … -Pvnidrop.diagnostics.included=true
|
||||||
vnidrop.diagnostics.included=false
|
vnidrop.diagnostics.included=false
|
||||||
|
|||||||
@@ -1178,62 +1178,6 @@
|
|||||||
"ru": "Имя устройства"
|
"ru": "Имя устройства"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"diagnostics_description": {
|
|
||||||
"context": "Settings > Diagnostics: explanation of what anonymous diagnostics collect.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.",
|
|
||||||
"fr": "Envoyer des rapports de plantage et des événements d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.",
|
|
||||||
"es": "Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen.",
|
|
||||||
"it": "Invia report di arresto anomalo ed eventi d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.",
|
|
||||||
"de": "Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.",
|
|
||||||
"pt": "Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos.",
|
|
||||||
"pl": "Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane.",
|
|
||||||
"nl": "Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.",
|
|
||||||
"ru": "Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics_disabled_message": {
|
|
||||||
"context": "Settings > Diagnostics: confirmation shown when diagnostics are turned off.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Diagnostics sharing is off.",
|
|
||||||
"fr": "Le partage des diagnostics est désactivé.",
|
|
||||||
"es": "El uso compartido de diagnósticos está desactivado.",
|
|
||||||
"it": "La condivisione dei dati diagnostici è disattivata.",
|
|
||||||
"de": "Die Freigabe von Diagnosedaten ist deaktiviert.",
|
|
||||||
"pt": "A partilha de diagnósticos está desativada.",
|
|
||||||
"pl": "Udostępnianie diagnostyki jest wyłączone.",
|
|
||||||
"nl": "Het delen van diagnostische gegevens is uitgeschakeld.",
|
|
||||||
"ru": "Передача диагностики отключена."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics_enabled_message": {
|
|
||||||
"context": "Settings > Diagnostics: confirmation shown when diagnostics are turned on.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Diagnostics sharing is on.",
|
|
||||||
"fr": "Le partage des diagnostics est activé.",
|
|
||||||
"es": "El uso compartido de diagnósticos está activado.",
|
|
||||||
"it": "La condivisione dei dati diagnostici è attivata.",
|
|
||||||
"de": "Die Freigabe von Diagnosedaten ist aktiviert.",
|
|
||||||
"pt": "A partilha de diagnósticos está ativada.",
|
|
||||||
"pl": "Udostępnianie diagnostyki jest włączone.",
|
|
||||||
"nl": "Het delen van diagnostische gegevens is ingeschakeld.",
|
|
||||||
"ru": "Передача диагностики включена."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics_title": {
|
|
||||||
"context": "Settings > Diagnostics: toggle title.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Share diagnostics",
|
|
||||||
"fr": "Partager les diagnostics",
|
|
||||||
"es": "Compartir diagnósticos",
|
|
||||||
"it": "Condividi dati diagnostici",
|
|
||||||
"de": "Diagnosedaten teilen",
|
|
||||||
"pt": "Partilhar diagnósticos",
|
|
||||||
"pl": "Udostępniaj diagnostykę",
|
|
||||||
"nl": "Diagnostische gegevens delen",
|
|
||||||
"ru": "Делиться диагностикой"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error_camera": {
|
"error_camera": {
|
||||||
"context": "Error: camera permission is needed to scan a QR code.",
|
"context": "Error: camera permission is needed to scan a QR code.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -1796,6 +1740,57 @@
|
|||||||
"ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне."
|
"ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications_background_sharing_body": {
|
||||||
|
"context": "Android foreground-service notification: explains why VniDrop stays active.",
|
||||||
|
"targets": [
|
||||||
|
"kmp"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "VniDrop is ready to share your files in the background.",
|
||||||
|
"fr": "VniDrop est prêt à partager vos fichiers en arrière-plan.",
|
||||||
|
"es": "VniDrop está listo para compartir sus archivos en segundo plano.",
|
||||||
|
"it": "VniDrop è pronto a condividere i tuoi file in background.",
|
||||||
|
"de": "VniDrop kann Ihre Dateien im Hintergrund freigeben.",
|
||||||
|
"pt": "O VniDrop está pronto para partilhar os seus ficheiros em segundo plano.",
|
||||||
|
"pl": "VniDrop jest gotowy do udostępniania plików w tle.",
|
||||||
|
"nl": "VniDrop is klaar om uw bestanden op de achtergrond te delen.",
|
||||||
|
"ru": "VniDrop готов отправлять ваши файлы в фоновом режиме."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications_background_sharing_channel": {
|
||||||
|
"context": "Android system notification channel for an active outgoing share.",
|
||||||
|
"targets": [
|
||||||
|
"kmp"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Active transfers",
|
||||||
|
"fr": "Transferts actifs",
|
||||||
|
"es": "Transferencias activas",
|
||||||
|
"it": "Trasferimenti attivi",
|
||||||
|
"de": "Aktive Übertragungen",
|
||||||
|
"pt": "Transferências ativas",
|
||||||
|
"pl": "Aktywne transfery",
|
||||||
|
"nl": "Actieve overdrachten",
|
||||||
|
"ru": "Активные передачи"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications_background_sharing_title": {
|
||||||
|
"context": "Android foreground-service notification title while an outgoing share is available.",
|
||||||
|
"targets": [
|
||||||
|
"kmp"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Sharing in the background",
|
||||||
|
"fr": "Partage en arrière-plan",
|
||||||
|
"es": "Compartiendo en segundo plano",
|
||||||
|
"it": "Condivisione in background",
|
||||||
|
"de": "Freigabe im Hintergrund",
|
||||||
|
"pt": "Partilha em segundo plano",
|
||||||
|
"pl": "Udostępnianie w tle",
|
||||||
|
"nl": "Delen op de achtergrond",
|
||||||
|
"ru": "Отправка в фоне"
|
||||||
|
}
|
||||||
|
},
|
||||||
"notifications_enabled_message": {
|
"notifications_enabled_message": {
|
||||||
"context": "Settings > Notifications: confirmation when notifications are enabled.",
|
"context": "Settings > Notifications: confirmation when notifications are enabled.",
|
||||||
"translations": {
|
"translations": {
|
||||||
|
|||||||
4
packaging/apple/studio/.gitignore
vendored
Normal file
4
packaging/apple/studio/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
generated/
|
||||||
|
out-*.png
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
13
packaging/apple/studio/Package.swift
Normal file
13
packaging/apple/studio/Package.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// swift-tools-version:6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "studio",
|
||||||
|
platforms: [.macOS(.v14)],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "studio",
|
||||||
|
path: "Sources/studio"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
76
packaging/apple/studio/README.md
Normal file
76
packaging/apple/studio/README.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# App Store screenshot studio
|
||||||
|
|
||||||
|
Code-driven, fully local App Store screenshots — composed natively with **SwiftUI +
|
||||||
|
`ImageRenderer`** and **SceneKit** (no Typst, no ImageMagick). Each marketing screen is a
|
||||||
|
SwiftUI view (gradient + 3D device + captions + generated artwork) rendered off-screen to
|
||||||
|
an exact-size PNG. Captions come from `strings.json` (9 locales); the app screenshots on
|
||||||
|
the device screens are captured from the real app.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Xcode / Swift toolchain (macOS 26+). That's it.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
```sh
|
||||||
|
swift run studio # all locales × screens -> generated/<Language>/
|
||||||
|
swift run studio --publish # -> ../<Language>/ (ships to App Store)
|
||||||
|
LOCALES="fr de" SCREENS="share-securely" swift run studio # subset
|
||||||
|
PLATFORM=ipad swift run studio # iPad set -> generated/<Language>/iPad/
|
||||||
|
PLATFORM=mac swift run studio # Mac set -> generated/<Language>/Mac/
|
||||||
|
```
|
||||||
|
`PLATFORM` must match between `capture.sh` and `swift run studio` (they read the same
|
||||||
|
`generated/shots/<platform>/` tree).
|
||||||
|
Run from this directory (it reads `strings.json` and `assets/` relative to cwd).
|
||||||
|
|
||||||
|
## Full pipeline
|
||||||
|
```sh
|
||||||
|
./capture.sh # real localized app screens -> generated/shots/<platform>/<locale>/
|
||||||
|
swift run studio # composite everything -> generated/<Language>/[iPad/]
|
||||||
|
./generate.sh # capture + composite in one shot (forwards PLATFORM / --publish)
|
||||||
|
```
|
||||||
|
`capture.sh` drives the app into each screen via the DEBUG `-VniScreenshot` launch
|
||||||
|
argument (see `apple/VniDrop/App/ScreenshotSupport.swift`), once per locale via
|
||||||
|
`-AppleLanguages`, in dark mode. Screenshots are transient (git-ignored, regenerated per
|
||||||
|
run) — during layout iteration, capture once then re-run `swift run studio` on its own.
|
||||||
|
- **iPhone / iPad** run in the simulator (9:41 status-bar override) via `simctl`.
|
||||||
|
- **Mac** has no simulator: `capture.sh` builds the native app, launches the binary
|
||||||
|
directly, sizes its window with `osascript`, and grabs it with `screencapture`. Grant
|
||||||
|
the terminal **Accessibility + Screen Recording** permission the first time.
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
`PLATFORM=iphone` (default), `ipad`, or `mac`. Each `Platform`
|
||||||
|
(`Sources/studio/Platform.swift`) carries its canvas size, capture target, output
|
||||||
|
subfolder, and a `DeviceModel` — the `.usdz`, its screen material, the orientation fix
|
||||||
|
(yaw so the screen faces the camera), optional front-glass mesh to hide, whether to tint
|
||||||
|
the body graphite, a `fillFraction` (how much of the frame the device fills; lower for
|
||||||
|
wide 3/4 poses that would otherwise clip), and a `screenPad` (black margin baked around
|
||||||
|
the shot so the window clears the display's rounded corners). Adding a device is a new
|
||||||
|
`DeviceModel` + `Platform` case + a layout set. (The studio is Apple-only today; the
|
||||||
|
same SwiftUI/SceneKit approach is intended to extend to Android and Windows later.)
|
||||||
|
|
||||||
|
## Device rendering
|
||||||
|
`SceneKitDeviceRenderer` textures the captured screenshot onto the model's screen mesh and
|
||||||
|
renders it off-screen. Curvature, bezel and body are the model's real geometry; poses
|
||||||
|
(pitch/yaw/roll) and a studio environment (IBL + bloom) are applied in the scene. It
|
||||||
|
auto-generates planar `[0,1]` UVs for screen meshes that ship without them, and mattes the
|
||||||
|
shot onto a slightly larger black canvas (`screenPad`) so the display edge stays black
|
||||||
|
instead of smearing the capture's corner pixels.
|
||||||
|
|
||||||
|
## Assets (`assets/`)
|
||||||
|
- `iphone-17-pro-max.usdz`, `ipad-pro.usdz`, `macbook-air.usdz` — the 3D devices
|
||||||
|
(committed; CC BY 4.0, see `ATTRIBUTION.md`).
|
||||||
|
- `globe.png` — for the send-anywhere hero (committed).
|
||||||
|
- `generated/shots/<platform>/<locale>/*.png` — captured app screens (transient).
|
||||||
|
|
||||||
|
## Layout & tuning
|
||||||
|
- `Sources/studio/ScreenSpec.swift` — per-platform layouts (`iphone` / `ipad` / `mac`):
|
||||||
|
gradient, device pose/position/size, globe + route (send-anywhere), the encryption flow
|
||||||
|
(stay-private: `beams` → `lock` → `stream` + `banners`), caption placement. `beams` and
|
||||||
|
`stream` take a `horizontal` flag for side-by-side devices (used by Mac stay-private).
|
||||||
|
- `Sources/studio/ScreenFrame.swift` — the composition (layer order, caption fitting,
|
||||||
|
generated artwork).
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
`share-securely`, `choose-receivers`, `send-anywhere` (globe + Paris→LA route arc,
|
||||||
|
reuses the share screenshot), `stay-private` (encryption beams → padlock → binary
|
||||||
|
protection stream, with localized CHIFFREMENT/PROTECTION banners; two stacked phones on
|
||||||
|
iPhone/iPad, two side-by-side laptops with a horizontal flow on Mac).
|
||||||
10
packaging/apple/studio/Sources/studio/DeviceView.swift
Normal file
10
packaging/apple/studio/Sources/studio/DeviceView.swift
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// The device is a swappable layer behind this protocol; `SceneKitDeviceRenderer` is the
|
||||||
|
// current implementation (textures the app screenshot onto a real .usdz model).
|
||||||
|
protocol DeviceRenderer {
|
||||||
|
// Produces the device as a SwiftUI view sized to `spec.height` (width follows the
|
||||||
|
// device aspect), already framing `shot` and posed per `spec`.
|
||||||
|
@MainActor func view(shot: NSImage?, spec: DeviceSpec) -> AnyView
|
||||||
|
}
|
||||||
77
packaging/apple/studio/Sources/studio/Platform.swift
Normal file
77
packaging/apple/studio/Sources/studio/Platform.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// A 3D device model + the per-model quirks the renderer needs (screen material name,
|
||||||
|
// orientation fix so the screen faces the camera, optional front-glass mesh to hide,
|
||||||
|
// whether to tint the body graphite).
|
||||||
|
struct DeviceModel {
|
||||||
|
var url: URL
|
||||||
|
var screenMaterial: String
|
||||||
|
var glassMaterial: String? // nil = no separate glass mesh
|
||||||
|
var bodyYaw: Double = 0 // deg about Y to face the screen toward the camera (-Z)
|
||||||
|
var recolorBody: Bool = false // graphite tint
|
||||||
|
// Fraction of the square render the device's upright height fills. Lower = more margin
|
||||||
|
// for wide 3/4 poses (a laptop's fanned base projects past a tight frame and clips).
|
||||||
|
var fillFraction: CGFloat = 0.66
|
||||||
|
// Overscan for auto-generated screen UVs: >0 shrinks the screenshot slightly so its
|
||||||
|
// edges aren't hidden when the screen mesh is a touch larger than the visible display.
|
||||||
|
var screenPad: CGFloat = 0
|
||||||
|
|
||||||
|
static func iphone(_ assets: URL) -> DeviceModel {
|
||||||
|
DeviceModel(url: assets.appendingPathComponent("iphone-17-pro-max.usdz"),
|
||||||
|
screenMaterial: "_7ProMax_Screen", glassMaterial: "glass",
|
||||||
|
bodyYaw: 0, recolorBody: true)
|
||||||
|
}
|
||||||
|
static func ipad(_ assets: URL) -> DeviceModel {
|
||||||
|
// Screen mesh "Material_002" (display only; the bezel is real geometry). Front
|
||||||
|
// faces +Z, so yaw 180° to face -Z. Standard UVs — no remap or bezel padding.
|
||||||
|
DeviceModel(url: assets.appendingPathComponent("ipad-pro.usdz"),
|
||||||
|
screenMaterial: "Material_002", glassMaterial: nil,
|
||||||
|
bodyYaw: 180, recolorBody: true)
|
||||||
|
}
|
||||||
|
static func macbook(_ assets: URL) -> DeviceModel {
|
||||||
|
// Open-laptop model; display mesh "screen_black" (no UVs — auto-generated). Front
|
||||||
|
// faces +Z, so yaw 180° to face -Z.
|
||||||
|
DeviceModel(url: assets.appendingPathComponent("macbook-air.usdz"),
|
||||||
|
screenMaterial: "screen_black", glassMaterial: nil,
|
||||||
|
bodyYaw: 180, recolorBody: false, fillFraction: 0.46, screenPad: 0.008)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An App Store target: canvas size, device model, capture simulator, output subfolder.
|
||||||
|
enum Platform: String {
|
||||||
|
case iphone, ipad, mac
|
||||||
|
|
||||||
|
var canvas: CGSize {
|
||||||
|
switch self {
|
||||||
|
case .iphone: CGSize(width: 1284, height: 2778) // 6.5" iPhone
|
||||||
|
case .ipad: CGSize(width: 2064, height: 2752) // 13" iPad Pro (matches the M5 sim)
|
||||||
|
case .mac: CGSize(width: 2880, height: 1800) // Mac App Store (16:10 landscape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceModel(assets: URL) -> DeviceModel {
|
||||||
|
switch self {
|
||||||
|
case .iphone: DeviceModel.iphone(assets)
|
||||||
|
case .ipad: DeviceModel.ipad(assets)
|
||||||
|
case .mac: DeviceModel.macbook(assets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulator used by capture.sh (informational here; capture reads its own env).
|
||||||
|
var simulator: String {
|
||||||
|
switch self {
|
||||||
|
case .iphone: "iPhone 17 Pro Max"
|
||||||
|
case .ipad: "iPad Pro 13-inch (M5)"
|
||||||
|
case .mac: "My Mac"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output goes under <Language>/<subfolder> so the sets stay separate.
|
||||||
|
var outputSubfolder: String {
|
||||||
|
switch self {
|
||||||
|
case .iphone: "iPhone"
|
||||||
|
case .ipad: "iPad"
|
||||||
|
case .mac: "Mac"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SceneKit
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// Full 3D device layer: textures the app screenshot onto the real iPhone .usdz screen
|
||||||
|
// mesh and renders it with SceneKit off-screen. Slots behind the same DeviceRenderer
|
||||||
|
// protocol as the 2D compositor — ScreenFrame is unchanged. Tilt is baked into the
|
||||||
|
// scene (a real camera + perspective), not faked.
|
||||||
|
|
||||||
|
struct SceneKitDeviceRenderer: DeviceRenderer {
|
||||||
|
let model: DeviceModel
|
||||||
|
|
||||||
|
var supersample: CGFloat = 2 // render big, SwiftUI downscales for clean edges
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func view(shot: NSImage?, spec: DeviceSpec) -> AnyView {
|
||||||
|
// Render into a square (so any pose fits without clipping); the upright phone
|
||||||
|
// occupies `heightFraction` of it, so displaySide maps that to spec.height.
|
||||||
|
let displaySide = spec.height / model.fillFraction
|
||||||
|
let renderSide = min(displaySide * supersample, 3200)
|
||||||
|
guard let img = render(shot: shot, spec: spec, side: renderSide) else {
|
||||||
|
return AnyView(Color.clear.frame(width: displaySide, height: displaySide))
|
||||||
|
}
|
||||||
|
return AnyView(Image(nsImage: img).resizable().frame(width: displaySide, height: displaySide))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func render(shot: NSImage?, spec: DeviceSpec, side: CGFloat) -> NSImage? {
|
||||||
|
guard let scene = try? SCNScene(url: model.url),
|
||||||
|
let device = MTLCreateSystemDefaultDevice() else { return nil }
|
||||||
|
let root = scene.rootNode
|
||||||
|
let d2r = Double.pi / 180
|
||||||
|
|
||||||
|
// Orient the model so its screen faces the camera (-Z). Some models (iPad) have
|
||||||
|
// the screen on ±X, so a per-model yaw fix is applied first.
|
||||||
|
let oriented = SCNNode()
|
||||||
|
for child in root.childNodes { oriented.addChildNode(child) }
|
||||||
|
oriented.eulerAngles = SCNVector3(0, model.bodyYaw * d2r, 0)
|
||||||
|
|
||||||
|
// Re-parent under a pivot so the pose rotates it about its own centre. Compute the
|
||||||
|
// (oriented) bounding box before applying the pose. rootNode.boundingBox aggregates
|
||||||
|
// the big ancestor unit-scale correctly (flattenedClone collapses these models).
|
||||||
|
let pivot = SCNNode()
|
||||||
|
pivot.addChildNode(oriented)
|
||||||
|
root.addChildNode(pivot)
|
||||||
|
let (bmin, bmax) = pivot.boundingBox
|
||||||
|
let center = SCNVector3((bmin.x + bmax.x) / 2, (bmin.y + bmax.y) / 2, (bmin.z + bmax.z) / 2)
|
||||||
|
let height = CGFloat(bmax.y - bmin.y)
|
||||||
|
pivot.pivot = SCNMatrix4MakeTranslation(center.x, center.y, center.z)
|
||||||
|
pivot.position = center
|
||||||
|
pivot.eulerAngles = SCNVector3(spec.pose.pitch * d2r, spec.pose.yaw * d2r, spec.pose.roll * d2r)
|
||||||
|
|
||||||
|
// Texture the screenshot onto the screen mesh, shown flat and full-brightness.
|
||||||
|
// The model's screen carries a wavy normal map (a "screen protector" look) that
|
||||||
|
// ripples the image — clear it so the app content stays crisp, as Apple requires.
|
||||||
|
// Some screen meshes ship with no UV coordinates (an image can't map onto them —
|
||||||
|
// it renders as a flat colour). Generate planar UVs from the vertex positions.
|
||||||
|
addPlanarUVsToScreen(in: pivot, material: model.screenMaterial)
|
||||||
|
|
||||||
|
// Texture EVERY material with the screen name (some models split the display into
|
||||||
|
// several meshes that share the material name); setting only the first leaves the
|
||||||
|
// rest white.
|
||||||
|
// A thin black margin baked around the shot so the window's own corners clear the
|
||||||
|
// display's rounded edge — done in the image (not via UV overscan) so the clamped
|
||||||
|
// border samples black instead of smearing the capture's rounded-corner pixels.
|
||||||
|
let screenShot = (shot != nil && model.screenPad > 0) ? matted(shot!, pad: model.screenPad) : shot
|
||||||
|
for mat in materials(named: model.screenMaterial, in: pivot) where spec.blackScreen || shot != nil {
|
||||||
|
mat.diffuse.contents = spec.blackScreen ? NSColor.black : screenShot
|
||||||
|
mat.lightingModel = .constant
|
||||||
|
mat.normal.contents = nil
|
||||||
|
mat.emission.contents = nil
|
||||||
|
mat.metalness.contents = NSNumber(value: 0)
|
||||||
|
mat.roughness.contents = NSNumber(value: 1)
|
||||||
|
mat.isDoubleSided = false
|
||||||
|
mat.diffuse.wrapS = .clamp
|
||||||
|
mat.diffuse.wrapT = .clamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the front glass mesh (if any) — it has its own normal-mapped waviness that
|
||||||
|
// reflects as swirls over the screen. We keep the crisp display instead.
|
||||||
|
if let glass = model.glassMaterial { hideMeshes(withMaterial: glass, in: pivot) }
|
||||||
|
|
||||||
|
// Recolor a silver body to graphite (iPhone); models already dark (iPad) skip it.
|
||||||
|
if model.recolorBody { recolorBody(in: pivot) }
|
||||||
|
|
||||||
|
// Camera on the screen side (front = -Z), oriented by hand: look(at:) renders
|
||||||
|
// nothing here, but a straight 180° yaw does. Framed so an upright phone height
|
||||||
|
// fills `heightFraction` of the square canvas (leaving margin for the pose).
|
||||||
|
let fovV = 18.0
|
||||||
|
let cam = SCNCamera()
|
||||||
|
cam.usesOrthographicProjection = false
|
||||||
|
cam.fieldOfView = fovV
|
||||||
|
cam.projectionDirection = .vertical
|
||||||
|
// zFar must comfortably exceed the camera distance; models differ hugely in unit
|
||||||
|
// scale (iPhone ~16 units tall, iPad ~300), so keep this large.
|
||||||
|
cam.zNear = 0.01; cam.zFar = 100_000
|
||||||
|
// HDR + subtle bloom so bright specular highlights on the metal/glass glow like a
|
||||||
|
// real product shot instead of clipping flat.
|
||||||
|
cam.wantsHDR = true
|
||||||
|
cam.wantsExposureAdaptation = false
|
||||||
|
cam.bloomThreshold = 0.9
|
||||||
|
cam.bloomIntensity = 0.25
|
||||||
|
cam.bloomBlurRadius = 6
|
||||||
|
let camNode = SCNNode(); camNode.camera = cam
|
||||||
|
let dist = Double(height) / (2 * Double(model.fillFraction) * tan(fovV / 2 * .pi / 180))
|
||||||
|
camNode.position = SCNVector3(center.x, center.y, center.z - SCNFloat(dist))
|
||||||
|
camNode.eulerAngles = SCNVector3(0, Double.pi, 0)
|
||||||
|
root.addChildNode(camNode)
|
||||||
|
|
||||||
|
lightRig(into: root, center: center)
|
||||||
|
// Image-based lighting: a studio softbox environment gives the metal frame and
|
||||||
|
// glass real reflections/highlights — this is what stops it looking plasticky.
|
||||||
|
scene.lightingEnvironment.contents = Self.studioEnvironment
|
||||||
|
scene.lightingEnvironment.intensity = 1.5
|
||||||
|
|
||||||
|
let renderer = SCNRenderer(device: device, options: nil)
|
||||||
|
renderer.scene = scene
|
||||||
|
renderer.pointOfView = camNode
|
||||||
|
renderer.autoenablesDefaultLighting = false
|
||||||
|
let px = CGSize(width: side, height: side)
|
||||||
|
// First snapshot primes the renderer and comes back empty; the second is real.
|
||||||
|
_ = renderer.snapshot(atTime: 0, with: CGSize(width: 16, height: 16), antialiasingMode: .none)
|
||||||
|
return renderer.snapshot(atTime: 0, with: px, antialiasingMode: .multisampling4X)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func lightRig(into root: SCNNode, center: SCNVector3) {
|
||||||
|
func light(_ type: SCNLight.LightType, _ intensity: CGFloat, at pos: SCNVector3) {
|
||||||
|
let l = SCNLight(); l.type = type; l.intensity = intensity; l.temperature = 6500
|
||||||
|
l.castsShadow = false
|
||||||
|
let n = SCNNode(); n.light = l; n.position = pos; n.look(at: center)
|
||||||
|
root.addChildNode(n)
|
||||||
|
}
|
||||||
|
// Low ambient (the environment map does the fill); a crisp key for the highlight
|
||||||
|
// streak down the frame, a fill from the other side, and a top rim so the upper
|
||||||
|
// frame/bezel catches a highlight too instead of reading flat.
|
||||||
|
light(.ambient, 180, at: center)
|
||||||
|
light(.directional, 850, at: SCNVector3(center.x - 0.5, center.y + 0.6, center.z - 0.8))
|
||||||
|
light(.directional, 320, at: SCNVector3(center.x + 0.7, center.y - 0.2, center.z - 0.6))
|
||||||
|
light(.directional, 1100, at: SCNVector3(center.x - 0.15, center.y + 1.3, center.z - 0.5)) // top rim
|
||||||
|
light(.spot, 900, at: SCNVector3(center.x + 0.2, center.y + 1.1, center.z - 0.7)) // top glint
|
||||||
|
}
|
||||||
|
|
||||||
|
// A procedural equirectangular studio environment: bright "ceiling" softbox fading to
|
||||||
|
// a darker floor, so reflective surfaces show a gradient with a hot highlight band.
|
||||||
|
static let studioEnvironment: NSImage = {
|
||||||
|
let w = 1024, h = 512
|
||||||
|
let img = NSImage(size: NSSize(width: w, height: h))
|
||||||
|
img.lockFocus()
|
||||||
|
let grad = NSGradient(colorsAndLocations:
|
||||||
|
(NSColor(white: 1.0, alpha: 1), 0.0), // top = ceiling (bright)
|
||||||
|
(NSColor(white: 0.9, alpha: 1), 0.28),
|
||||||
|
(NSColor(white: 0.5, alpha: 1), 0.55),
|
||||||
|
(NSColor(white: 0.22, alpha: 1), 0.8),
|
||||||
|
(NSColor(white: 0.1, alpha: 1), 1.0)) // bottom = floor (dark)
|
||||||
|
grad?.draw(in: NSRect(x: 0, y: 0, width: w, height: h), angle: 90)
|
||||||
|
// Two softbox bands (upper "ceiling" + lower "floor bounce") so reflective
|
||||||
|
// surfaces catch a highlight at both the top and bottom of the frame.
|
||||||
|
NSColor.white.setFill()
|
||||||
|
NSBezierPath(roundedRect: NSRect(x: w / 4, y: Int(Double(h) * 0.66), width: w / 2, height: h / 8),
|
||||||
|
xRadius: 20, yRadius: 20).fill()
|
||||||
|
NSColor(white: 0.75, alpha: 1).setFill()
|
||||||
|
NSBezierPath(roundedRect: NSRect(x: w / 5, y: Int(Double(h) * 0.24), width: Int(Double(w) * 0.6), height: h / 10),
|
||||||
|
xRadius: 16, yRadius: 16).fill()
|
||||||
|
img.unlockFocus()
|
||||||
|
return img
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Tint the phone body graphite. Skips the screen, hidden glass, and camera lenses.
|
||||||
|
// Uses `multiply` so the metallic reflections survive (just darkened), giving a
|
||||||
|
// space-black look with sharp rail highlights rather than bright silver.
|
||||||
|
private func recolorBody(in node: SCNNode) {
|
||||||
|
let skip = ["screen", "glass", "lens", "logo"]
|
||||||
|
let graphite = NSColor(calibratedRed: 0.19, green: 0.19, blue: 0.21, alpha: 1)
|
||||||
|
func walk(_ n: SCNNode) {
|
||||||
|
for m in n.geometry?.materials ?? [] {
|
||||||
|
let name = (m.name ?? "").lowercased()
|
||||||
|
// Never tint the screen — its material name may not contain "screen"
|
||||||
|
// (the iPad's is just "Material").
|
||||||
|
if m.name == model.screenMaterial { continue }
|
||||||
|
if skip.contains(where: { name.contains($0) }) { continue }
|
||||||
|
m.multiply.contents = graphite
|
||||||
|
// Sharper, brighter specular so the frame highlights "pop" like polished
|
||||||
|
// metal instead of a soft satin.
|
||||||
|
m.metalness.contents = NSNumber(value: 1.0)
|
||||||
|
m.roughness.contents = NSNumber(value: 0.22)
|
||||||
|
}
|
||||||
|
n.childNodes.forEach(walk)
|
||||||
|
}
|
||||||
|
walk(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideMeshes(withMaterial substring: String, in node: SCNNode) {
|
||||||
|
if node.geometry?.materials.contains(where: { ($0.name ?? "").localizedCaseInsensitiveContains(substring) }) == true {
|
||||||
|
node.isHidden = true
|
||||||
|
}
|
||||||
|
for c in node.childNodes { hideMeshes(withMaterial: substring, in: c) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the screen mesh planar UVs (mapped over its two largest-extent axes) when it
|
||||||
|
// has none, so a screenshot texture maps across the display.
|
||||||
|
private func addPlanarUVsToScreen(in node: SCNNode, material name: String) {
|
||||||
|
guard let g = node.geometry,
|
||||||
|
g.materials.contains(where: { ($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame }),
|
||||||
|
g.sources(for: .texcoord).isEmpty,
|
||||||
|
let vsrc = g.sources(for: .vertex).first else {
|
||||||
|
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read vertex positions.
|
||||||
|
var pos: [(Float, Float, Float)] = []
|
||||||
|
let stride = vsrc.dataStride, off = vsrc.dataOffset
|
||||||
|
vsrc.data.withUnsafeBytes { (p: UnsafeRawBufferPointer) in
|
||||||
|
for i in 0..<vsrc.vectorCount {
|
||||||
|
let b = off + i * stride
|
||||||
|
pos.append((p.load(fromByteOffset: b, as: Float.self),
|
||||||
|
p.load(fromByteOffset: b + 4, as: Float.self),
|
||||||
|
p.load(fromByteOffset: b + 8, as: Float.self)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let xs = pos.map(\.0), ys = pos.map(\.1), zs = pos.map(\.2)
|
||||||
|
let ext = [xs.max()! - xs.min()!, ys.max()! - ys.min()!, zs.max()! - zs.min()!]
|
||||||
|
// Screen plane = the two axes with the largest extent (drop the thin normal axis).
|
||||||
|
let normalAxis = ext.firstIndex(of: ext.min()!)!
|
||||||
|
let planeAxes = [0, 1, 2].filter { $0 != normalAxis } // [u-axis, v-axis]
|
||||||
|
func comp(_ p: (Float, Float, Float), _ a: Int) -> Float { a == 0 ? p.0 : (a == 1 ? p.1 : p.2) }
|
||||||
|
let ua = planeAxes[0], va = planeAxes[1]
|
||||||
|
let umin = [xs, ys, zs][ua].min()!, urange = max(1e-6, ext[ua])
|
||||||
|
let vmin = [xs, ys, zs][va].min()!, vrange = max(1e-6, ext[va])
|
||||||
|
// Plain [0,1] planar mapping — the screenshot fills the mesh exactly. Any margin the
|
||||||
|
// display needs is baked into the image (see `matted`), not added here, so the clamped
|
||||||
|
// edge stays black instead of smearing the capture's corner pixels.
|
||||||
|
let uvs: [CGPoint] = pos.map {
|
||||||
|
CGPoint(x: CGFloat((comp($0, ua) - umin) / urange),
|
||||||
|
y: CGFloat(1 - (comp($0, va) - vmin) / vrange)) // flip V for top-left origin
|
||||||
|
}
|
||||||
|
let uvSource = SCNGeometrySource(textureCoordinates: uvs)
|
||||||
|
let newGeo = SCNGeometry(sources: g.sources(for: .vertex) + g.sources(for: .normal) + [uvSource],
|
||||||
|
elements: g.elements)
|
||||||
|
newGeo.materials = g.materials
|
||||||
|
node.geometry = newGeo
|
||||||
|
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the shot centred on a black canvas `pad` larger on each side, so texturing it
|
||||||
|
// leaves a thin black border around the window inside the display's rounded corners.
|
||||||
|
private func matted(_ shot: NSImage, pad: CGFloat) -> NSImage {
|
||||||
|
let s = shot.size
|
||||||
|
let canvas = NSSize(width: s.width * (1 + 2 * pad), height: s.height * (1 + 2 * pad))
|
||||||
|
let out = NSImage(size: canvas)
|
||||||
|
out.lockFocus()
|
||||||
|
NSColor.black.setFill()
|
||||||
|
NSRect(origin: .zero, size: canvas).fill()
|
||||||
|
shot.draw(in: NSRect(x: s.width * pad, y: s.height * pad, width: s.width, height: s.height))
|
||||||
|
out.unlockFocus()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private func materials(named name: String, in node: SCNNode) -> [SCNMaterial] {
|
||||||
|
var out = (node.geometry?.materials ?? []).filter {
|
||||||
|
($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame
|
||||||
|
}
|
||||||
|
for c in node.childNodes { out += materials(named: name, in: c) }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
230
packaging/apple/studio/Sources/studio/ScreenFrame.swift
Normal file
230
packaging/apple/studio/Sources/studio/ScreenFrame.swift
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// The full 1284x2778 marketing frame: gradient + globe + device + caption.
|
||||||
|
// Placement mirrors the old screens.typ: images are pinned top-left and pushed by
|
||||||
|
// (dx, dy); captions are centered and pinned to the top or bottom edge.
|
||||||
|
|
||||||
|
struct ScreenFrame: View {
|
||||||
|
let spec: ScreenSpec
|
||||||
|
let caption: Caption
|
||||||
|
let globe: Image?
|
||||||
|
let shot: NSImage?
|
||||||
|
let device: DeviceRenderer?
|
||||||
|
var canvas: CGSize = CGSize(width: 1284, height: 2778)
|
||||||
|
|
||||||
|
private var allDevices: [DeviceSpec] { spec.device.map { [$0] } ?? spec.devices }
|
||||||
|
|
||||||
|
private var titleColor: Color { spec.captionTheme == .light ? .white : Color(hex: "#1b1226") }
|
||||||
|
private var subColor: Color { spec.captionTheme == .light ? Color(hex: "#f3ecfb") : Color(hex: "#2c2138") }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let cw = canvas.width, ch = canvas.height
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
spec.bg.gradient
|
||||||
|
|
||||||
|
if let g = spec.globe, let globe {
|
||||||
|
globe.resizable().scaledToFit()
|
||||||
|
.frame(width: g.width)
|
||||||
|
.saturation(0.82)
|
||||||
|
.brightness(-0.06)
|
||||||
|
.offset(x: g.dx, y: g.dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route sits on the globe but behind the phone, so it appears to pass through.
|
||||||
|
if let rt = spec.route { routeLayer(rt) }
|
||||||
|
|
||||||
|
// Encryption flow (behind the phones so beams/stream tuck into them).
|
||||||
|
if let b = spec.beams { beamsLayer(b) }
|
||||||
|
if let s = spec.stream { streamLayer(s) }
|
||||||
|
|
||||||
|
if let device {
|
||||||
|
// The renderer bakes the pose into the 3D scene; we place each phone by
|
||||||
|
// its center on the canvas and add a soft contact shadow.
|
||||||
|
ForEach(Array(allDevices.enumerated()), id: \.offset) { _, d in
|
||||||
|
device.view(shot: shot, spec: d)
|
||||||
|
.shadow(color: d.shadow ? .black.opacity(0.28) : .clear,
|
||||||
|
radius: 60, x: 0, y: 34)
|
||||||
|
.position(x: d.cx, y: d.cy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Padlock + banner labels sit on top of the flow.
|
||||||
|
if let l = spec.lock { lockLayer(l) }
|
||||||
|
ForEach(Array(spec.banners.enumerated()), id: \.offset) { _, b in
|
||||||
|
bannerLayer(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
captionLayer
|
||||||
|
}
|
||||||
|
// Pin to top-leading: oversized layers (e.g. the globe frame is wider than the
|
||||||
|
// canvas) must be clipped from the origin, NOT re-centered — otherwise the whole
|
||||||
|
// composition, captions included, shifts left by (contentWidth - cw) / 2.
|
||||||
|
.frame(width: cw, height: ch, alignment: .topLeading)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bezier(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
|
||||||
|
let u = 1 - t
|
||||||
|
let a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t
|
||||||
|
return CGPoint(x: a * p0.x + b * c1.x + c * c2.x + d * p1.x,
|
||||||
|
y: a * p0.y + b * c1.y + c * c2.y + d * p1.y)
|
||||||
|
}
|
||||||
|
static func bezierTangent(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
|
||||||
|
let u = 1 - t
|
||||||
|
let a = 3 * u * u, b = 6 * u * t, c = 3 * t * t
|
||||||
|
return CGPoint(x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p1.x - c2.x),
|
||||||
|
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p1.y - c2.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer route: a glowing arc from the departure city to the arrival city, with a
|
||||||
|
// pulsing marker at each end. Drawn on the globe, behind the phone.
|
||||||
|
private func routeLayer(_ r: RouteSpec) -> some View {
|
||||||
|
let color = Color(hex: r.color)
|
||||||
|
let path = Path { p in
|
||||||
|
p.move(to: r.from)
|
||||||
|
p.addCurve(to: r.to, control1: r.c1, control2: r.c2)
|
||||||
|
}
|
||||||
|
return ZStack {
|
||||||
|
path.stroke(color.opacity(0.55), style: StrokeStyle(lineWidth: r.lineWidth * 2.6, lineCap: .round))
|
||||||
|
.blur(radius: 22)
|
||||||
|
path.stroke(
|
||||||
|
LinearGradient(colors: [Color(hex: "#c98bff"), Color(hex: "#7c3aed"), Color(hex: "#c98bff")],
|
||||||
|
startPoint: .topTrailing, endPoint: .bottomLeading),
|
||||||
|
style: StrokeStyle(lineWidth: r.lineWidth, lineCap: .round))
|
||||||
|
path.stroke(.white.opacity(0.85), style: StrokeStyle(lineWidth: r.lineWidth * 0.35, lineCap: .round))
|
||||||
|
.blur(radius: 1)
|
||||||
|
marker(at: r.from, color)
|
||||||
|
marker(at: r.to, color)
|
||||||
|
}
|
||||||
|
.frame(width: canvas.width, height: canvas.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func marker(at pt: CGPoint, _ color: Color) -> some View {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(color.opacity(0.5)).frame(width: 60, height: 60).blur(radius: 14)
|
||||||
|
Circle().fill(color).frame(width: 30, height: 30)
|
||||||
|
Circle().fill(.white).frame(width: 14, height: 14)
|
||||||
|
}
|
||||||
|
.position(pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converging encryption beams: thin glowing lines fanning from the top phone's edge
|
||||||
|
// down to a single convergence point (above the lock).
|
||||||
|
private func beamsLayer(_ b: BeamsSpec) -> some View {
|
||||||
|
let color = Color(hex: b.color)
|
||||||
|
return Canvas { ctx, _ in
|
||||||
|
for i in 0..<b.count {
|
||||||
|
let f = b.count == 1 ? 0 : Double(i) / Double(b.count - 1) - 0.5 // -0.5…0.5
|
||||||
|
// Fan across the cross axis from the start line, converging on (cx, cy).
|
||||||
|
let start = b.horizontal
|
||||||
|
? CGPoint(x: b.y0, y: b.cy + CGFloat(f) * 2 * b.spread)
|
||||||
|
: CGPoint(x: b.cx + CGFloat(f) * 2 * b.spread, y: b.y0)
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: start)
|
||||||
|
path.addLine(to: CGPoint(x: b.cx, y: b.cy))
|
||||||
|
let op = 0.25 + 0.35 * (1 - abs(f) * 2) // brighter toward the centre beam
|
||||||
|
ctx.stroke(path, with: .color(color.opacity(op)),
|
||||||
|
style: StrokeStyle(lineWidth: 2.2, lineCap: .round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: canvas.width, height: canvas.height)
|
||||||
|
.blur(radius: 0.6)
|
||||||
|
.shadow(color: color.opacity(0.7), radius: 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical binary "protection" stream from the lock down to the receiving phone.
|
||||||
|
private func streamLayer(_ s: StreamSpec) -> some View {
|
||||||
|
let color = Color(hex: s.color)
|
||||||
|
return Canvas { ctx, _ in
|
||||||
|
let spacing = 44.0
|
||||||
|
let count = max(1, Int((s.y1 - s.y0) / spacing))
|
||||||
|
for i in 0...count {
|
||||||
|
let t = Double(i) / Double(count)
|
||||||
|
let along = s.y0 + (s.y1 - s.y0) * t
|
||||||
|
let at = s.horizontal ? CGPoint(x: along, y: s.cx) : CGPoint(x: s.cx, y: along)
|
||||||
|
let bit = (i % 3 == 0) ? "0" : "1"
|
||||||
|
// fade in near the lock and out near the receiving device
|
||||||
|
let op = 0.5 + 0.5 * sin(Double.pi * t)
|
||||||
|
var res = ctx.resolve(Text(bit)
|
||||||
|
.font(.system(size: 30, weight: .semibold, design: .monospaced)))
|
||||||
|
res.shading = .color(color.opacity(op))
|
||||||
|
ctx.draw(res, at: at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: canvas.width, height: canvas.height)
|
||||||
|
.shadow(color: color.opacity(0.6), radius: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glowing padlock (SF Symbol) — the encryption focal point.
|
||||||
|
private func lockLayer(_ l: LockSpec) -> some View {
|
||||||
|
let color = Color(hex: l.color)
|
||||||
|
return Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: l.size, weight: .regular))
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(colors: [.white, color], startPoint: .top, endPoint: .bottom))
|
||||||
|
.shadow(color: color.opacity(0.9), radius: 30)
|
||||||
|
.shadow(color: color.opacity(0.6), radius: 60)
|
||||||
|
.position(x: l.cx, y: l.cy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localized banner label (CHIFFREMENT / PROTECTION) from strings.json.
|
||||||
|
private func bannerLayer(_ b: Banner) -> some View {
|
||||||
|
let text = (b.kind == .encryption ? caption.encryption : caption.protection) ?? ""
|
||||||
|
return Text(text)
|
||||||
|
.font(.system(size: 48, weight: .bold))
|
||||||
|
.tracking(3)
|
||||||
|
.foregroundStyle(Color(hex: "#e7dcf7"))
|
||||||
|
.position(x: b.cx, y: b.cy)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func captionBlock(_ titleSize: CGFloat, _ subSize: CGFloat) -> some View {
|
||||||
|
let content = VStack(spacing: 10) {
|
||||||
|
Text(caption.title)
|
||||||
|
.font(.system(size: titleSize, weight: .heavy))
|
||||||
|
.foregroundStyle(titleColor)
|
||||||
|
Text(caption.subtitle)
|
||||||
|
.font(.system(size: subSize, weight: .semibold))
|
||||||
|
.foregroundStyle(subColor)
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(width: canvas.width - (spec.headerBackdrop ? 300 : 160))
|
||||||
|
|
||||||
|
if spec.headerBackdrop {
|
||||||
|
// The panel is the caption's background, so it grows with the content — long
|
||||||
|
// translations (Russian, etc.) get a taller/wider backdrop automatically.
|
||||||
|
let shape = RoundedRectangle(cornerRadius: 48, style: .continuous)
|
||||||
|
content
|
||||||
|
.padding(.horizontal, 56)
|
||||||
|
.padding(.vertical, 44)
|
||||||
|
.background(shape.fill(Color(hex: "#180a30").opacity(0.82))
|
||||||
|
.overlay(shape.stroke(Color.white.opacity(0.16), lineWidth: 1.5)))
|
||||||
|
.shadow(color: .black.opacity(0.35), radius: 24, y: 10)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var captionLayer: some View {
|
||||||
|
let top = spec.captionPlace == .top
|
||||||
|
// Keep the size for short captions, but shrink long translations (e.g. Russian /
|
||||||
|
// Polish / Portuguese wrap both lines) so the taller block still fits its band and
|
||||||
|
// never overlaps the phone. ViewThatFits picks the largest variant that fits.
|
||||||
|
// The top band is shorter than the bottom one because those screens' phones are
|
||||||
|
// large and start high, leaving less room above them.
|
||||||
|
let region: CGFloat = top ? (spec.headerBackdrop ? 560 : 290) : 360
|
||||||
|
let fitted = ViewThatFits(in: .vertical) {
|
||||||
|
captionBlock(104, 60)
|
||||||
|
captionBlock(92, 54)
|
||||||
|
captionBlock(82, 48)
|
||||||
|
captionBlock(72, 44)
|
||||||
|
}
|
||||||
|
.frame(width: canvas.width, height: region, alignment: top ? .top : .bottom)
|
||||||
|
|
||||||
|
return fitted
|
||||||
|
.padding(top ? .top : .bottom, top ? 96 : 110)
|
||||||
|
.frame(width: canvas.width, height: canvas.height,
|
||||||
|
alignment: top ? .top : .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
302
packaging/apple/studio/Sources/studio/ScreenSpec.swift
Normal file
302
packaging/apple/studio/Sources/studio/ScreenSpec.swift
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Layout data for each marketing screen. Numbers are in pixels (the canvas renders
|
||||||
|
// at scale 1, so a value of 104 == 104px). Ported from the old screens.typ `screens`
|
||||||
|
// dict — tune these against the originals.
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
// "#rrggbb"
|
||||||
|
init(hex: String) {
|
||||||
|
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||||
|
let v = UInt64(s, radix: 16) ?? 0
|
||||||
|
self.init(
|
||||||
|
.sRGB,
|
||||||
|
red: Double((v >> 16) & 0xff) / 255,
|
||||||
|
green: Double((v >> 8) & 0xff) / 255,
|
||||||
|
blue: Double(v & 0xff) / 255,
|
||||||
|
opacity: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CaptionPlace { case top, bottom }
|
||||||
|
enum CaptionTheme { case dark, light }
|
||||||
|
|
||||||
|
struct GradientSpec {
|
||||||
|
let stops: [String] // hex colors, top→bottom / start→end
|
||||||
|
let start: UnitPoint
|
||||||
|
let end: UnitPoint
|
||||||
|
|
||||||
|
var gradient: LinearGradient {
|
||||||
|
LinearGradient(colors: stops.map { Color(hex: $0) }, startPoint: start, endPoint: end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 3D device layer. The screenshot is textured onto the model's screen and the phone
|
||||||
|
// is posed in real 3D (pitch/yaw/roll), positioned by its center on the canvas and
|
||||||
|
// sized by its upright height — matching how the originals were laid out.
|
||||||
|
struct Pose {
|
||||||
|
var pitch: CGFloat = 0 // deg, about X (top tips away/toward viewer)
|
||||||
|
var yaw: CGFloat = 0 // deg, about Y (turn left/right, reveals a side edge)
|
||||||
|
var roll: CGFloat = 0 // deg, about Z (in-plane spin)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeviceSpec {
|
||||||
|
var height: CGFloat // upright phone height in canvas px (drives scale)
|
||||||
|
var cx: CGFloat // phone center X on the 1284-wide canvas
|
||||||
|
var cy: CGFloat // phone center Y on the 2778-tall canvas
|
||||||
|
var pose: Pose = Pose()
|
||||||
|
var shadow: Bool = true // soft contact shadow under the phone
|
||||||
|
var blackScreen: Bool = false // texture the screen solid black (ignore the shot)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobeSpec {
|
||||||
|
var width: CGFloat
|
||||||
|
var dx: CGFloat
|
||||||
|
var dy: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transfer route: two city markers (departure + arrival) on the globe joined by a
|
||||||
|
// glowing arc that swoops down and passes behind the phone — "send from Paris to LA,
|
||||||
|
// through your phone". Generated natively.
|
||||||
|
struct RouteSpec {
|
||||||
|
var from: CGPoint // departure marker (on the globe)
|
||||||
|
var to: CGPoint // arrival marker (on the globe)
|
||||||
|
var c1: CGPoint // Bézier controls — pull down to bulge the arc through the phone
|
||||||
|
var c2: CGPoint
|
||||||
|
var lineWidth: CGFloat = 12
|
||||||
|
var color: String = "#a855f7"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stay-private redesign: a vertical encryption flow between two stacked phones —
|
||||||
|
// converging light beams (encryption) → a glowing padlock → a binary stream (protection).
|
||||||
|
struct BeamsSpec {
|
||||||
|
var cx: CGFloat // convergence x
|
||||||
|
var y0: CGFloat // start-line coordinate: the y the beams fan from (x when horizontal)
|
||||||
|
var spread: CGFloat // half-extent the beams fan across at the start line
|
||||||
|
var cy: CGFloat // converge to this point
|
||||||
|
var count: Int = 22
|
||||||
|
var color: String = "#9ec3ff"
|
||||||
|
// Horizontal: beams fan across y from a vertical start line and converge left→right
|
||||||
|
// (for side-by-side devices) instead of fanning across x from a horizontal line.
|
||||||
|
var horizontal: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LockSpec {
|
||||||
|
var cx: CGFloat
|
||||||
|
var cy: CGFloat
|
||||||
|
var size: CGFloat
|
||||||
|
var color: String = "#a9c9ff"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamSpec {
|
||||||
|
var cx: CGFloat // cross-axis centre (x for a vertical stream, y for a horizontal one)
|
||||||
|
var y0: CGFloat // along-axis start (below the lock; right of the lock when horizontal)
|
||||||
|
var y1: CGFloat // along-axis end (the receiving device)
|
||||||
|
var color: String = "#9ec3ff"
|
||||||
|
var horizontal: Bool = false // flow left→right instead of top→bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json.
|
||||||
|
enum BannerKind { case encryption, protection }
|
||||||
|
struct Banner {
|
||||||
|
var kind: BannerKind
|
||||||
|
var cx: CGFloat
|
||||||
|
var cy: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScreenSpec {
|
||||||
|
let id: String
|
||||||
|
let bg: GradientSpec
|
||||||
|
let captionPlace: CaptionPlace
|
||||||
|
let captionTheme: CaptionTheme
|
||||||
|
var globe: GlobeSpec? = nil
|
||||||
|
var route: RouteSpec? = nil
|
||||||
|
var device: DeviceSpec? = nil
|
||||||
|
var devices: [DeviceSpec] = [] // multiple phones (e.g. stay-private)
|
||||||
|
var beams: BeamsSpec? = nil
|
||||||
|
var lock: LockSpec? = nil
|
||||||
|
var stream: StreamSpec? = nil
|
||||||
|
var banners: [Banner] = []
|
||||||
|
var headerBackdrop: Bool = false // dark scrim behind the top caption
|
||||||
|
// Which captured screenshot to texture (defaults to `id`). The hero reuses the
|
||||||
|
// approval modal shot, matching the original.
|
||||||
|
var shotId: String? = nil
|
||||||
|
|
||||||
|
static func all(for platform: Platform) -> [String: ScreenSpec] {
|
||||||
|
switch platform {
|
||||||
|
case .iphone: iphone
|
||||||
|
case .ipad: ipad
|
||||||
|
case .mac: mac
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let iphone: [String: ScreenSpec] = [
|
||||||
|
// Straight-on hero, large and centered, showing the Share/QR sheet.
|
||||||
|
"share-securely": ScreenSpec(
|
||||||
|
id: "share-securely",
|
||||||
|
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(
|
||||||
|
height: 2300, cx: 642, cy: 1560,
|
||||||
|
pose: Pose(pitch: 0, yaw: 0, roll: 0))
|
||||||
|
),
|
||||||
|
// Tilted hero: turned to reveal the right edge, top sloping down-right.
|
||||||
|
"choose-receivers": ScreenSpec(
|
||||||
|
id: "choose-receivers",
|
||||||
|
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(
|
||||||
|
height: 2160, cx: 620, cy: 1620,
|
||||||
|
pose: Pose(pitch: -9, yaw: -14, roll: 7))
|
||||||
|
),
|
||||||
|
// Strongly tilted, lying diagonally in the lower-left; globe + orbit above.
|
||||||
|
"send-anywhere": ScreenSpec(
|
||||||
|
id: "send-anywhere",
|
||||||
|
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .bottom, captionTheme: .dark,
|
||||||
|
globe: GlobeSpec(width: 1520, dx: -10, dy: -200),
|
||||||
|
route: RouteSpec(
|
||||||
|
from: CGPoint(x: 1284 - 289, y: 226), // departure — Paris (Europe, right of globe)
|
||||||
|
to: CGPoint(x: 211, y: 296), // arrival — Los Angeles (upper-left of globe)
|
||||||
|
c1: CGPoint(x: 1500, y: 1980), // pull the arc down through the phone
|
||||||
|
c2: CGPoint(x: -300, y: 1980),
|
||||||
|
lineWidth: 12),
|
||||||
|
device: DeviceSpec(
|
||||||
|
height: 1550, cx: 581, cy: 1600,
|
||||||
|
pose: Pose(pitch: 35, yaw: 35, roll: 15)),
|
||||||
|
shotId: "share-securely"
|
||||||
|
),
|
||||||
|
// Two partial phones with black screens + a generated binary data ribbon.
|
||||||
|
// Vertical encryption flow: two stacked centred phones, converging beams into a
|
||||||
|
// glowing padlock, then a binary "protection" stream down to the receiver.
|
||||||
|
"stay-private": ScreenSpec(
|
||||||
|
id: "stay-private",
|
||||||
|
bg: GradientSpec(
|
||||||
|
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"],
|
||||||
|
start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .light,
|
||||||
|
devices: [
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1500, cx: 642, cy: -20, // top phone, only its lower part shows
|
||||||
|
pose: Pose(roll: 180), shadow: false, blackScreen: true),
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1500, cx: 642, cy: 2820, // bottom phone, only its upper part shows
|
||||||
|
pose: Pose(), shadow: false, blackScreen: true),
|
||||||
|
],
|
||||||
|
beams: BeamsSpec(cx: 642, y0: 700, spread: 150, cy: 1120, count: 22),
|
||||||
|
lock: LockSpec(cx: 642, cy: 1330, size: 300),
|
||||||
|
stream: StreamSpec(cx: 642, y0: 1520, y1: 2360),
|
||||||
|
banners: [
|
||||||
|
Banner(kind: .encryption, cx: 642, cy: 1120),
|
||||||
|
Banner(kind: .protection, cx: 642, cy: 1620),
|
||||||
|
],
|
||||||
|
headerBackdrop: true
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
// iPad Pro layouts (canvas 2048x2732, centre x = 1024). First pass — same
|
||||||
|
// compositions as iPhone, retuned for the squarer, larger canvas.
|
||||||
|
static let ipad: [String: ScreenSpec] = [
|
||||||
|
"share-securely": ScreenSpec(
|
||||||
|
id: "share-securely",
|
||||||
|
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(height: 1950, cx: 1024, cy: 1520, pose: Pose())
|
||||||
|
),
|
||||||
|
"choose-receivers": ScreenSpec(
|
||||||
|
id: "choose-receivers",
|
||||||
|
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(
|
||||||
|
height: 1950, cx: 1000, cy: 1460, pose: Pose(pitch: -9, yaw: -14, roll: 7))
|
||||||
|
),
|
||||||
|
"send-anywhere": ScreenSpec(
|
||||||
|
id: "send-anywhere",
|
||||||
|
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .bottom, captionTheme: .dark,
|
||||||
|
globe: GlobeSpec(width: 2000, dx: 24, dy: -240),
|
||||||
|
route: RouteSpec(
|
||||||
|
from: CGPoint(x: 1342, y: 323), to: CGPoint(x: 315, y: 423),
|
||||||
|
c1: CGPoint(x: 2100, y: 2000), c2: CGPoint(x: -100, y: 2000), lineWidth: 14),
|
||||||
|
device: DeviceSpec(
|
||||||
|
height: 1180, cx: 1000, cy: 1720, pose: Pose(pitch: 12, yaw: 18, roll: -11)),
|
||||||
|
shotId: "share-securely"
|
||||||
|
),
|
||||||
|
"stay-private": ScreenSpec(
|
||||||
|
id: "stay-private",
|
||||||
|
bg: GradientSpec(
|
||||||
|
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .light,
|
||||||
|
devices: [
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1300, cx: 1024, cy: 120, pose: Pose(roll: 180), shadow: false,
|
||||||
|
blackScreen: true),
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1300, cx: 1024, cy: 2760, pose: Pose(), shadow: false, blackScreen: true
|
||||||
|
),
|
||||||
|
],
|
||||||
|
beams: BeamsSpec(cx: 1024, y0: 720, spread: 200, cy: 1220, count: 24),
|
||||||
|
lock: LockSpec(cx: 1024, cy: 1400, size: 320),
|
||||||
|
stream: StreamSpec(cx: 1024, y0: 1600, y1: 2360),
|
||||||
|
banners: [
|
||||||
|
Banner(kind: .encryption, cx: 1024, cy: 1200),
|
||||||
|
Banner(kind: .protection, cx: 1024, cy: 1680),
|
||||||
|
],
|
||||||
|
headerBackdrop: true
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MacBook layouts (canvas 2880x1800, landscape, centre x = 1440). First pass.
|
||||||
|
static let mac: [String: ScreenSpec] = [
|
||||||
|
"share-securely": ScreenSpec(
|
||||||
|
id: "share-securely",
|
||||||
|
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(height: 1500, cx: 1440, cy: 1110, pose: Pose())
|
||||||
|
),
|
||||||
|
"choose-receivers": ScreenSpec(
|
||||||
|
id: "choose-receivers",
|
||||||
|
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .dark,
|
||||||
|
device: DeviceSpec(height: 1500, cx: 1440, cy: 1110, pose: Pose())
|
||||||
|
),
|
||||||
|
// Globe backdrop in the upper half, route arcing through a centred laptop, caption
|
||||||
|
// at the bottom. Reuses the share capture (matches the other platforms).
|
||||||
|
"send-anywhere": ScreenSpec(
|
||||||
|
id: "send-anywhere",
|
||||||
|
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .bottom, captionTheme: .light,
|
||||||
|
globe: GlobeSpec(width: 1320, dx: 780, dy: -150),
|
||||||
|
route: RouteSpec(
|
||||||
|
from: CGPoint(x: 1650, y: 222), to: CGPoint(x: 976, y: 274),
|
||||||
|
c1: CGPoint(x: 5200, y: 1150), c2: CGPoint(x: -2000, y: 1150), lineWidth: 13),
|
||||||
|
device: DeviceSpec(height: 1300, cx: 1440, cy: 960, pose: Pose()),
|
||||||
|
headerBackdrop: true,
|
||||||
|
shotId: "share-securely"
|
||||||
|
),
|
||||||
|
// Vertical encryption flow between two stacked laptops (same structure as the phone
|
||||||
|
// layouts, retuned for the landscape canvas).
|
||||||
|
"stay-private": ScreenSpec(
|
||||||
|
id: "stay-private",
|
||||||
|
bg: GradientSpec(
|
||||||
|
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom),
|
||||||
|
captionPlace: .top, captionTheme: .light,
|
||||||
|
// Two laptops on the left and right; encryption flows horizontally between them:
|
||||||
|
// beams converge left→lock, a binary stream runs lock→right.
|
||||||
|
devices: [
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1040, cx: 120, cy: 1000, pose: Pose(), shadow: false, blackScreen: true),
|
||||||
|
DeviceSpec(
|
||||||
|
height: 1040, cx: 2760, cy: 1000, pose: Pose(), shadow: false, blackScreen: true
|
||||||
|
),
|
||||||
|
],
|
||||||
|
beams: BeamsSpec(cx: 1290, y0: 760, spread: 300, cy: 1000, count: 26, horizontal: true),
|
||||||
|
lock: LockSpec(cx: 1440, cy: 1000, size: 300),
|
||||||
|
stream: StreamSpec(cx: 1000, y0: 1600, y1: 2140, horizontal: true),
|
||||||
|
banners: [
|
||||||
|
Banner(kind: .encryption, cx: 940, cy: 1360),
|
||||||
|
Banner(kind: .protection, cx: 1960, cy: 1360),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
51
packaging/apple/studio/Sources/studio/Strings.swift
Normal file
51
packaging/apple/studio/Sources/studio/Strings.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Mirrors strings.json. Captions live only here (not app strings).
|
||||||
|
struct Caption: Decodable {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
// Optional in-composition banner labels (stay-private): CHIFFREMENT / PROTECTION.
|
||||||
|
let encryption: String?
|
||||||
|
let protection: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocaleStrings: Decodable {
|
||||||
|
let folder: String
|
||||||
|
private let screens: [String: Caption]
|
||||||
|
|
||||||
|
subscript(_ screen: String) -> Caption? { screens[screen] }
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey { case folder = "_folder" }
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
// `_folder` is a reserved key; every other key is a screen id -> Caption.
|
||||||
|
let dyn = try decoder.container(keyedBy: DynamicKey.self)
|
||||||
|
var acc: [String: Caption] = [:]
|
||||||
|
var folderName = ""
|
||||||
|
for key in dyn.allKeys {
|
||||||
|
if key.stringValue == "_folder" {
|
||||||
|
folderName = try dyn.decode(String.self, forKey: key)
|
||||||
|
} else if let cap = try? dyn.decode(Caption.self, forKey: key) {
|
||||||
|
acc[key.stringValue] = cap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.folder = folderName
|
||||||
|
self.screens = acc
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DynamicKey: CodingKey {
|
||||||
|
var stringValue: String
|
||||||
|
var intValue: Int? { nil }
|
||||||
|
init?(stringValue: String) { self.stringValue = stringValue }
|
||||||
|
init?(intValue: Int) { nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Strings: Decodable {
|
||||||
|
let screens: [String]
|
||||||
|
let locales: [String: LocaleStrings]
|
||||||
|
|
||||||
|
static func load(_ url: URL) throws -> Strings {
|
||||||
|
try JSONDecoder().decode(Strings.self, from: Data(contentsOf: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
111
packaging/apple/studio/Sources/studio/main.swift
Normal file
111
packaging/apple/studio/Sources/studio/main.swift
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Code-driven App Store screenshots, rendered natively with SwiftUI + ImageRenderer.
|
||||||
|
//
|
||||||
|
// swift run studio # -> generated/<Language>/<Name>.png
|
||||||
|
// swift run studio --publish # -> ../<Language>/<Name>.png (ships)
|
||||||
|
// LOCALES="fr de" SCREENS="share-securely" swift run studio
|
||||||
|
//
|
||||||
|
// Screenshots come from generated/shots/<platform>/<locale>/<screen>.png (from
|
||||||
|
// ./capture.sh) — transient build output, regenerated per run, never committed. The 3D
|
||||||
|
// device models + globe come from assets/. Run from the studio directory.
|
||||||
|
|
||||||
|
// Screen id -> output file basename (matches the existing App Store filenames).
|
||||||
|
let nameFor: [String: String] = [
|
||||||
|
"choose-receivers": "Choose Receivers", "send-anywhere": "Send Anywhere",
|
||||||
|
"share-securely": "Share Securely", "stay-private": "Stay private",
|
||||||
|
]
|
||||||
|
|
||||||
|
func env(_ key: String, _ fallback: String) -> String {
|
||||||
|
let v = ProcessInfo.processInfo.environment[key]
|
||||||
|
return (v?.isEmpty == false) ? v! : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadNSImage(_ path: String) -> (NSImage, CGSize)? {
|
||||||
|
guard let ns = NSImage(contentsOfFile: path) else { return nil }
|
||||||
|
let size = ns.representations.first.map { CGSize(width: $0.pixelsWide, height: $0.pixelsHigh) } ?? ns.size
|
||||||
|
return (ns, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func writePNG(_ view: some View, to url: URL) throws {
|
||||||
|
let renderer = ImageRenderer(content: view)
|
||||||
|
renderer.scale = 1
|
||||||
|
guard let cg = renderer.cgImage else {
|
||||||
|
throw NSError(domain: "studio", code: 1, userInfo: [NSLocalizedDescriptionKey: "render failed"])
|
||||||
|
}
|
||||||
|
let rep = NSBitmapImageRep(cgImage: cg)
|
||||||
|
rep.size = NSSize(width: cg.width, height: cg.height)
|
||||||
|
guard let data = rep.representation(using: .png, properties: [:]) else {
|
||||||
|
throw NSError(domain: "studio", code: 2, userInfo: [NSLocalizedDescriptionKey: "png encode failed"])
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
try data.write(to: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func run() throws {
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
let publish = args.contains("--publish")
|
||||||
|
let cwd = FileManager.default.currentDirectoryPath
|
||||||
|
let base = URL(fileURLWithPath: cwd)
|
||||||
|
|
||||||
|
let strings = try Strings.load(base.appendingPathComponent("strings.json"))
|
||||||
|
|
||||||
|
let locales = env("LOCALES", "en fr de es it nl pl pt ru").split(separator: " ").map(String.init)
|
||||||
|
let screens = env("SCREENS", "choose-receivers send-anywhere share-securely stay-private")
|
||||||
|
.split(separator: " ").map(String.init)
|
||||||
|
|
||||||
|
let globe = loadNSImage(base.appendingPathComponent("assets/globe.png").path).map { Image(nsImage: $0.0) }
|
||||||
|
|
||||||
|
// Target platform: canvas size + 3D device model. PLATFORM=iphone|ipad (default iphone).
|
||||||
|
let platform = Platform(rawValue: env("PLATFORM", "iphone")) ?? .iphone
|
||||||
|
let canvas = platform.canvas
|
||||||
|
let model = platform.deviceModel(assets: base.appendingPathComponent("assets"))
|
||||||
|
let device: DeviceRenderer? = FileManager.default.fileExists(atPath: model.url.path)
|
||||||
|
? SceneKitDeviceRenderer(model: model) : nil
|
||||||
|
if device == nil {
|
||||||
|
FileHandle.standardError.write(Data("warning: missing model \(model.url.lastPathComponent) — devices will be blank\n".utf8))
|
||||||
|
}
|
||||||
|
let specs = ScreenSpec.all(for: platform)
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
for loc in locales {
|
||||||
|
guard let ls = strings.locales[loc] else {
|
||||||
|
FileHandle.standardError.write(Data("warning: no strings for locale \(loc)\n".utf8)); continue
|
||||||
|
}
|
||||||
|
// iPhone -> <Language>/, iPad -> <Language>/iPad/ so the sets stay separate.
|
||||||
|
var outDir = publish
|
||||||
|
? base.appendingPathComponent("../\(ls.folder)").standardized
|
||||||
|
: base.appendingPathComponent("generated/\(ls.folder)")
|
||||||
|
if !platform.outputSubfolder.isEmpty {
|
||||||
|
outDir = outDir.appendingPathComponent(platform.outputSubfolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
for scr in screens {
|
||||||
|
guard let spec = specs[scr], let caption = ls[scr] else {
|
||||||
|
FileHandle.standardError.write(Data("warning: skipping \(loc)/\(scr)\n".utf8)); continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let shotId = spec.shotId ?? scr
|
||||||
|
let shotsDir = env("SHOTS_DIR", "generated/shots/\(platform.rawValue)")
|
||||||
|
let shot = loadNSImage(base.appendingPathComponent("\(shotsDir)/\(loc)/\(shotId).png").path)?.0
|
||||||
|
let hasDevice = spec.device != nil || !spec.devices.isEmpty
|
||||||
|
let frame = ScreenFrame(spec: spec, caption: caption, globe: globe, shot: shot,
|
||||||
|
device: hasDevice ? device : nil, canvas: canvas)
|
||||||
|
let out = outDir.appendingPathComponent("\(nameFor[scr] ?? scr).png")
|
||||||
|
try writePNG(frame, to: out)
|
||||||
|
print(" ✅ \(out.path)")
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print("\nDone — \(count) screenshot(s)\(publish ? " (published)" : "").")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageRenderer needs an AppKit context for text/font resolution.
|
||||||
|
let app = NSApplication.shared
|
||||||
|
app.setActivationPolicy(.prohibited)
|
||||||
|
do { try MainActor.assumeIsolated { try run() } }
|
||||||
|
catch { FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)); exit(1) }
|
||||||
22
packaging/apple/studio/assets/ATTRIBUTION.md
Normal file
22
packaging/apple/studio/assets/ATTRIBUTION.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Third-party asset attributions
|
||||||
|
|
||||||
|
## iphone-17-pro-max.usdz
|
||||||
|
- **Title:** iphone 17 pro max silver
|
||||||
|
- **Author:** TechFreak (Sketchfab)
|
||||||
|
- **Source:** https://sketchfab.com/3d-models/iphone-17-pro-max-silver-9dbfe0d846f341bf9fc501a854f5de1a
|
||||||
|
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
|
||||||
|
|
||||||
|
## ipad-pro.usdz
|
||||||
|
- **Title:** 2018 Apple iPad Pro
|
||||||
|
- **Author:** lazercar (Sketchfab)
|
||||||
|
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
|
||||||
|
|
||||||
|
## macbook-air.usdz
|
||||||
|
- **Title:** MacBook Air
|
||||||
|
- **Author:** Jakob (Sketchfab — https://sketchfab.com/jakob3dlindblom)
|
||||||
|
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
|
||||||
|
|
||||||
|
CC BY 4.0 requires visible credit wherever these models (or renders derived from them)
|
||||||
|
are published. The App Store screenshots produced by this studio are derivatives, so
|
||||||
|
keep this attribution with the project. If a suitable place exists (e.g. the app's
|
||||||
|
acknowledgements / licenses screen), surface the credit there as well.
|
||||||
BIN
packaging/apple/studio/assets/globe.png
Normal file
BIN
packaging/apple/studio/assets/globe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 MiB |
BIN
packaging/apple/studio/assets/ipad-pro.usdz
Normal file
BIN
packaging/apple/studio/assets/ipad-pro.usdz
Normal file
Binary file not shown.
BIN
packaging/apple/studio/assets/iphone-17-pro-max.usdz
Normal file
BIN
packaging/apple/studio/assets/iphone-17-pro-max.usdz
Normal file
Binary file not shown.
BIN
packaging/apple/studio/assets/macbook-air.usdz
Normal file
BIN
packaging/apple/studio/assets/macbook-air.usdz
Normal file
Binary file not shown.
120
packaging/apple/studio/capture.sh
Executable file
120
packaging/apple/studio/capture.sh
Executable file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Capture real, localized app screens for the studio pipeline.
|
||||||
|
#
|
||||||
|
# Drives the app straight into each marketing screen via the DEBUG `-VniScreenshot`
|
||||||
|
# launch argument (no UI automation needed), once per locale via `-AppleLanguages`,
|
||||||
|
# and writes assets/shots/<locale>/<screen>.png at the device's native resolution.
|
||||||
|
#
|
||||||
|
# ./capture.sh # all locales
|
||||||
|
# LOCALES="en fr" ./capture.sh
|
||||||
|
# SCREENSHOT_DEVICE="iPhone 17 Pro Max" ./capture.sh
|
||||||
|
#
|
||||||
|
# Requires a Debug build (the fixture gateway is compiled under #if DEBUG).
|
||||||
|
set -euo pipefail
|
||||||
|
trap 'echo "capture.sh: failed (rc=$?) at line $LINENO" >&2' ERR
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
APPLE_DIR="$(cd ../../../apple && pwd)"
|
||||||
|
BUNDLE_ID="com.vnidrop.app"
|
||||||
|
LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"}
|
||||||
|
|
||||||
|
# PLATFORM=iphone|ipad selects the simulator and the shots subdir (must match main.swift).
|
||||||
|
PLATFORM="${PLATFORM:-iphone}"
|
||||||
|
case "$PLATFORM" in
|
||||||
|
ipad) DEVICE="${SCREENSHOT_DEVICE:-iPad Pro 13-inch (M5)}";;
|
||||||
|
*) DEVICE="${SCREENSHOT_DEVICE:-iPhone 17 Pro Max}";;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# scenario (launch arg value) -> studio screen id (output filename stem).
|
||||||
|
# send-anywhere reuses the share screenshot (see ScreenSpec shotId), so it isn't
|
||||||
|
# captured separately; stay-private uses black screens (no capture).
|
||||||
|
SCENARIOS="share:share-securely approval:choose-receivers"
|
||||||
|
|
||||||
|
# Screenshots are transient build output, regenerated per run — never committed. They
|
||||||
|
# live under generated/ (git-ignored), not in assets/.
|
||||||
|
SHOTS_DIR="${SHOTS_DIR:-generated/shots/$PLATFORM}"
|
||||||
|
|
||||||
|
# ---- macOS: no simulator. Build the native app, drive it with the fixture args, size its
|
||||||
|
# window to 16:10 and grab it with screencapture. Needs Accessibility + Screen Recording
|
||||||
|
# permission granted to the terminal (you'll be prompted the first time).
|
||||||
|
if [ "$PLATFORM" = "mac" ]; then
|
||||||
|
WIN_X=60; WIN_Y=80; WIN_W=1440; WIN_H=900 # 16:10, matches the MacBook screen aspect
|
||||||
|
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)
|
||||||
|
echo "==> Building VniDrop (Debug) for macOS"
|
||||||
|
xcodebuild build -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
|
||||||
|
-destination 'platform=macOS' -configuration Debug CODE_SIGNING_ALLOWED=NO >/dev/null
|
||||||
|
APP="$(xcodebuild -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
|
||||||
|
-destination 'platform=macOS' -configuration Debug -showBuildSettings 2>/dev/null \
|
||||||
|
| awk -F' = ' '/ BUILT_PRODUCTS_DIR /{print $2; exit}')/VniDrop.app"
|
||||||
|
EXE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist" 2>/dev/null || echo VniDrop)"
|
||||||
|
EXE="$APP/Contents/MacOS/$EXE_NAME"
|
||||||
|
echo " app: $APP (exe: $EXE_NAME)"
|
||||||
|
echo " (grant Accessibility + Screen Recording to your terminal if prompted)"
|
||||||
|
for loc in $LOCALES; do
|
||||||
|
mkdir -p "$SHOTS_DIR/$loc"
|
||||||
|
for pair in $SCENARIOS; do
|
||||||
|
scenario="${pair%%:*}"; screen="${pair##*:}"
|
||||||
|
pkill -x "$EXE_NAME" 2>/dev/null || true; sleep 1
|
||||||
|
# Launch the binary directly (avoids LaunchServices -1712 for DerivedData apps).
|
||||||
|
"$EXE" -VniScreenshot "$scenario" \
|
||||||
|
-AppleLanguages "($loc)" -AppleLocale "$loc" -AppleInterfaceStyle Dark &
|
||||||
|
sleep 4
|
||||||
|
osascript >/dev/null 2>&1 <<-OSA || true
|
||||||
|
tell application "System Events" to tell process "$EXE_NAME"
|
||||||
|
set frontmost to true
|
||||||
|
set position of front window to {$WIN_X, $WIN_Y}
|
||||||
|
set size of front window to {$WIN_W, $WIN_H}
|
||||||
|
end tell
|
||||||
|
OSA
|
||||||
|
sleep 1
|
||||||
|
tmp="$(mktemp -t vnishot).png"
|
||||||
|
screencapture -x -R${WIN_X},${WIN_Y},${WIN_W},${WIN_H} "$tmp"
|
||||||
|
mv "$tmp" "$SHOTS_DIR/$loc/$screen.png"
|
||||||
|
echo " 🖥️ $SHOTS_DIR/$loc/$screen.png"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
pkill -x "$EXE_NAME" 2>/dev/null || true
|
||||||
|
echo ""; echo "Done. Now run 'PLATFORM=mac swift run studio' to composite."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)
|
||||||
|
|
||||||
|
echo "==> Building VniDrop (Debug) for $DEVICE"
|
||||||
|
xcodebuild build -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
|
||||||
|
-destination "platform=iOS Simulator,name=$DEVICE" -configuration Debug \
|
||||||
|
CODE_SIGNING_ALLOWED=NO >/dev/null
|
||||||
|
APP="$(xcodebuild -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
|
||||||
|
-destination "platform=iOS Simulator,name=$DEVICE" -configuration Debug \
|
||||||
|
-showBuildSettings 2>/dev/null | awk -F' = ' '/ BUILT_PRODUCTS_DIR /{print $2; exit}')/VniDrop.app"
|
||||||
|
|
||||||
|
UDID="$(xcrun simctl list devices available | grep -F "$DEVICE (" | head -1 \
|
||||||
|
| grep -oiE '[0-9a-f-]{36}' | head -1)"
|
||||||
|
[ -n "$UDID" ] || { echo "error: no simulator '$DEVICE'"; exit 1; }
|
||||||
|
echo " device: $UDID"
|
||||||
|
echo " app: $APP"
|
||||||
|
|
||||||
|
xcrun simctl boot "$UDID" 2>/dev/null || true
|
||||||
|
xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl ui "$UDID" appearance dark >/dev/null 2>&1 || true # match the dark marketing look
|
||||||
|
xcrun simctl status_bar "$UDID" override --time "9:41" \
|
||||||
|
--batteryState charged --batteryLevel 100 --cellularBars 4 --wifiBars 3 >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl install "$UDID" "$APP"
|
||||||
|
|
||||||
|
for loc in $LOCALES; do
|
||||||
|
mkdir -p "$SHOTS_DIR/$loc"
|
||||||
|
for pair in $SCENARIOS; do
|
||||||
|
scenario="${pair%%:*}"; screen="${pair##*:}"
|
||||||
|
xcrun simctl launch --terminate-running-process "$UDID" "$BUNDLE_ID" \
|
||||||
|
-VniScreenshot "$scenario" -AppleLanguages "($loc)" -AppleLocale "$loc" >/dev/null
|
||||||
|
sleep 4
|
||||||
|
# simctl can't write into the project tree (TCC blocks the CoreSimulator helper
|
||||||
|
# on external/again-protected volumes), so capture to a temp file and move it in.
|
||||||
|
tmp="$(mktemp -t vnishot).png"
|
||||||
|
xcrun simctl io "$UDID" screenshot "$tmp" >/dev/null 2>&1
|
||||||
|
mv "$tmp" "$SHOTS_DIR/$loc/$screen.png"
|
||||||
|
echo " 📸 $SHOTS_DIR/$loc/$screen.png"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Done. Now run 'swift run studio' to composite."
|
||||||
16
packaging/apple/studio/generate.sh
Executable file
16
packaging/apple/studio/generate.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-shot: capture fresh per-locale screenshots from the app, then composite every
|
||||||
|
# marketing screen. Screenshots are transient (generated/shots, git-ignored) and are
|
||||||
|
# regenerated each run — never committed.
|
||||||
|
#
|
||||||
|
# ./generate.sh # capture + composite -> generated/<Language>/
|
||||||
|
# ./generate.sh --publish # capture + composite -> ../<Language>/ (ships)
|
||||||
|
# LOCALES="en fr" ./generate.sh # subset of locales
|
||||||
|
#
|
||||||
|
# During layout iteration you don't need to re-capture every time: run `./capture.sh`
|
||||||
|
# once, then `swift run studio` on its own (it reads the existing generated/shots).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
./capture.sh
|
||||||
|
swift run studio "$@"
|
||||||
200
packaging/apple/studio/strings.json
Normal file
200
packaging/apple/studio/strings.json
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Marketing captions for App Store screenshots. NOT app strings — these live only here. Translations below the English source are a first pass and should be reviewed by a native speaker.",
|
||||||
|
"screens": [
|
||||||
|
"choose-receivers",
|
||||||
|
"send-anywhere",
|
||||||
|
"share-securely",
|
||||||
|
"stay-private"
|
||||||
|
],
|
||||||
|
"locales": {
|
||||||
|
"en": {
|
||||||
|
"_folder": "English",
|
||||||
|
"choose-receivers": {
|
||||||
|
"title": "Choose Receivers",
|
||||||
|
"subtitle": "Approve each person invited"
|
||||||
|
},
|
||||||
|
"send-anywhere": {
|
||||||
|
"title": "Send Anywhere",
|
||||||
|
"subtitle": "Direct file transfer worldwide"
|
||||||
|
},
|
||||||
|
"share-securely": {
|
||||||
|
"title": "Share Securely",
|
||||||
|
"subtitle": "QR, NFC or file"
|
||||||
|
},
|
||||||
|
"stay-private": {
|
||||||
|
"title": "Stay Private",
|
||||||
|
"subtitle": "Encrypted with no server copy",
|
||||||
|
"encryption": "ENCRYPTION",
|
||||||
|
"protection": "PROTECTION"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"_folder": "French",
|
||||||
|
"choose-receivers": {
|
||||||
|
"title": "Choisissez les destinataires",
|
||||||
|
"subtitle": "Approuvez chaque personne invitée"
|
||||||
|
},
|
||||||
|
"send-anywhere": {
|
||||||
|
"title": "Envoyez partout",
|
||||||
|
"subtitle": "Transfert direct dans le monde entier"
|
||||||
|
},
|
||||||
|
"share-securely": {
|
||||||
|
"title": "Partagez en sécurité",
|
||||||
|
"subtitle": "QR, NFC ou fichier"
|
||||||
|
},
|
||||||
|
"stay-private": {
|
||||||
|
"title": "Restez privé",
|
||||||
|
"subtitle": "Chiffré, sans copie serveur",
|
||||||
|
"encryption": "CHIFFREMENT",
|
||||||
|
"protection": "PROTECTION"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"_folder": "German",
|
||||||
|
"choose-receivers": {
|
||||||
|
"title": "Empfänger auswählen",
|
||||||
|
"subtitle": "Jede eingeladene Person bestätigen"
|
||||||
|
},
|
||||||
|
"send-anywhere": {
|
||||||
|
"title": "Überallhin senden",
|
||||||
|
"subtitle": "Direkte Dateiübertragung weltweit"
|
||||||
|
},
|
||||||
|
"share-securely": {
|
||||||
|
"title": "Sicher teilen",
|
||||||
|
"subtitle": "QR, NFC oder Datei"
|
||||||
|
},
|
||||||
|
"stay-private": {
|
||||||
|
"title": "Privat bleiben",
|
||||||
|
"subtitle": "Verschlüsselt, ohne Server-Kopie",
|
||||||
|
"encryption": "VERSCHLÜSSELUNG",
|
||||||
|
"protection": "SCHUTZ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"_folder": "Spanish",
|
||||||
|
"choose-receivers": {
|
||||||
|
"title": "Elige destinatarios",
|
||||||
|
"subtitle": "Aprueba a cada invitado"
|
||||||
|
},
|
||||||
|
"send-anywhere": {
|
||||||
|
"title": "Envía a cualquier lugar",
|
||||||
|
"subtitle": "Transferencia directa en todo el mundo"
|
||||||
|
},
|
||||||
|
"share-securely": {
|
||||||
|
"title": "Comparte con seguridad",
|
||||||
|
"subtitle": "QR, NFC o archivo"
|
||||||
|
},
|
||||||
|
"stay-private": {
|
||||||
|
"title": "Mantén la privacidad",
|
||||||
|
"subtitle": "Cifrado, sin copia en servidor",
|
||||||
|
"encryption": "CIFRADO",
|
||||||
|
"protection": "PROTECCIÓN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"_folder": "Italian",
|
||||||
|
"choose-receivers": {
|
||||||
|
"title": "Scegli i destinatari",
|
||||||
|
"subtitle": "Approva ogni invitato"
|
||||||
|
},
|
||||||
|
"send-anywhere": {
|
||||||
|
"title": "Invia ovunque",
|
||||||
|
"subtitle": "Trasferimento diretto in tutto il mondo"
|
||||||
|
},
|
||||||
|
"share-securely": {
|
||||||
|
"title": "Condividi in sicurezza",
|
||||||
|
"subtitle": "QR, NFC o file"
|
||||||
|
},
|
||||||
|
"stay-private": {
|
||||||
|
"title": "Resta privato",
|
||||||
|
"subtitle": "Crittografato, senza copia sul server",
|
||||||
|
"encryption": "CRITTOGRAFIA",
|
||||||
|
"protection": "PROTEZIONE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl": {
|
||||||
|
"_folder": "Dutch",
|
||||||
|
"choose-receivers": {
|
||||||
|
"title": "Kies ontvangers",
|
||||||
|
"subtitle": "Keur elke genodigde goed"
|
||||||
|
},
|
||||||
|
"send-anywhere": {
|
||||||
|
"title": "Verstuur overal",
|
||||||
|
"subtitle": "Directe bestandsoverdracht wereldwijd"
|
||||||
|
},
|
||||||
|
"share-securely": {
|
||||||
|
"title": "Deel veilig",
|
||||||
|
"subtitle": "QR, NFC of bestand"
|
||||||
|
},
|
||||||
|
"stay-private": {
|
||||||
|
"title": "Blijf privé",
|
||||||
|
"subtitle": "Versleuteld, geen serverkopie",
|
||||||
|
"encryption": "VERSLEUTELING",
|
||||||
|
"protection": "BESCHERMING"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl": {
|
||||||
|
"_folder": "Polish",
|
||||||
|
"choose-receivers": {
|
||||||
|
"title": "Wybierz odbiorców",
|
||||||
|
"subtitle": "Zatwierdź każdą zaproszoną osobę"
|
||||||
|
},
|
||||||
|
"send-anywhere": {
|
||||||
|
"title": "Wysyłaj wszędzie",
|
||||||
|
"subtitle": "Bezpośredni transfer plików na całym świecie"
|
||||||
|
},
|
||||||
|
"share-securely": {
|
||||||
|
"title": "Udostępniaj bezpiecznie",
|
||||||
|
"subtitle": "Kod QR, NFC lub plik"
|
||||||
|
},
|
||||||
|
"stay-private": {
|
||||||
|
"title": "Zachowaj prywatność",
|
||||||
|
"subtitle": "Szyfrowane, bez kopii na serwerze",
|
||||||
|
"encryption": "SZYFROWANIE",
|
||||||
|
"protection": "OCHRONA"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"_folder": "Portuguese",
|
||||||
|
"choose-receivers": {
|
||||||
|
"title": "Escolha os destinatários",
|
||||||
|
"subtitle": "Aprove cada pessoa convidada"
|
||||||
|
},
|
||||||
|
"send-anywhere": {
|
||||||
|
"title": "Envie para qualquer lugar",
|
||||||
|
"subtitle": "Transferência direta pelo mundo todo"
|
||||||
|
},
|
||||||
|
"share-securely": {
|
||||||
|
"title": "Compartilhe com segurança",
|
||||||
|
"subtitle": "QR, NFC ou arquivo"
|
||||||
|
},
|
||||||
|
"stay-private": {
|
||||||
|
"title": "Mantenha a privacidade",
|
||||||
|
"subtitle": "Criptografado, sem cópia no servidor",
|
||||||
|
"encryption": "CRIPTOGRAFIA",
|
||||||
|
"protection": "PROTEÇÃO"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"_folder": "Russian",
|
||||||
|
"choose-receivers": {
|
||||||
|
"title": "Выбирайте получателей",
|
||||||
|
"subtitle": "Подтверждайте каждого приглашённого"
|
||||||
|
},
|
||||||
|
"send-anywhere": {
|
||||||
|
"title": "Отправляйте куда угодно",
|
||||||
|
"subtitle": "Прямая передача файлов по всему миру"
|
||||||
|
},
|
||||||
|
"share-securely": {
|
||||||
|
"title": "Делитесь безопасно",
|
||||||
|
"subtitle": "QR, NFC или файл"
|
||||||
|
},
|
||||||
|
"stay-private": {
|
||||||
|
"title": "Оставайтесь приватными",
|
||||||
|
"subtitle": "Шифрование без копии на сервере",
|
||||||
|
"encryption": "ШИФРОВАНИЕ",
|
||||||
|
"protection": "ЗАЩИТА"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ rpm="$(find_single "$input_dir/rpm" '*.rpm' 'RPM package')"
|
|||||||
dmg="$(find_single "$input_dir/macos" '*.dmg' 'macOS DMG')"
|
dmg="$(find_single "$input_dir/macos" '*.dmg' 'macOS DMG')"
|
||||||
appcast="$(find_single "$input_dir/macos" 'appcast.xml' 'Sparkle appcast')"
|
appcast="$(find_single "$input_dir/macos" 'appcast.xml' 'Sparkle appcast')"
|
||||||
apple_metadata="$(find_single "$input_dir/macos" '*.build-info.json' 'direct macOS build metadata')"
|
apple_metadata="$(find_single "$input_dir/macos" '*.build-info.json' 'direct macOS build metadata')"
|
||||||
|
apple_core="$(find_single "$input_dir/macos" 'VnidropCore-*.zip' 'Apple prebuilt core bundle')"
|
||||||
play_apk="$(find_single "$input_dir/play" '*-play-universal.apk' 'Play-signed APK')"
|
play_apk="$(find_single "$input_dir/play" '*-play-universal.apk' 'Play-signed APK')"
|
||||||
play_metadata="$(find_single "$input_dir/play" 'play-release.json' 'Play release metadata')"
|
play_metadata="$(find_single "$input_dir/play" 'play-release.json' 'Play release metadata')"
|
||||||
msix="$(find_single "$input_dir/windows" '*.msix' 'Windows MSIX')"
|
msix="$(find_single "$input_dir/windows" '*.msix' 'Windows MSIX')"
|
||||||
@@ -67,6 +68,7 @@ windows_metadata="$(find_single "$input_dir/windows" '*.build-info.json' 'Window
|
|||||||
[[ $(basename "$deb") == "vnidrop_${version}-1_amd64.deb" ]]
|
[[ $(basename "$deb") == "vnidrop_${version}-1_amd64.deb" ]]
|
||||||
[[ $(basename "$rpm") == "vnidrop-${version}-1.x86_64.rpm" ]]
|
[[ $(basename "$rpm") == "vnidrop-${version}-1.x86_64.rpm" ]]
|
||||||
[[ $(basename "$dmg") == "VniDrop-${version}.dmg" ]]
|
[[ $(basename "$dmg") == "VniDrop-${version}.dmg" ]]
|
||||||
|
[[ $(basename "$apple_core") == "VnidropCore-${version}.zip" ]]
|
||||||
[[ $(basename "$play_apk") == "VniDrop-${version}-${android_code}-play-universal.apk" ]]
|
[[ $(basename "$play_apk") == "VniDrop-${version}-${android_code}-play-universal.apk" ]]
|
||||||
[[ $(basename "$msix") == "VniDrop_${version}_x64.msix" ]]
|
[[ $(basename "$msix") == "VniDrop_${version}_x64.msix" ]]
|
||||||
[[ $(basename "$msixupload") == "VniDrop_${version}_x64.msixupload" ]]
|
[[ $(basename "$msixupload") == "VniDrop_${version}_x64.msixupload" ]]
|
||||||
@@ -83,10 +85,12 @@ deb_checksum="$(find_single "$input_dir/deb" '*.sha256' 'Debian checksum')"
|
|||||||
rpm_checksum="$(find_single "$input_dir/rpm" '*.sha256' 'RPM checksum')"
|
rpm_checksum="$(find_single "$input_dir/rpm" '*.sha256' 'RPM checksum')"
|
||||||
windows_checksums="$(find_single "$input_dir/windows" 'SHA256SUMS' 'Windows checksums')"
|
windows_checksums="$(find_single "$input_dir/windows" 'SHA256SUMS' 'Windows checksums')"
|
||||||
play_checksums="$(find_single "$input_dir/play" 'SHA256SUMS' 'Play APK checksums')"
|
play_checksums="$(find_single "$input_dir/play" 'SHA256SUMS' 'Play APK checksums')"
|
||||||
|
apple_core_checksum="$(find_single "$input_dir/macos" 'VnidropCore-*.zip.sha256' 'Apple prebuilt core checksum')"
|
||||||
verify_checksum_file "$deb_checksum"
|
verify_checksum_file "$deb_checksum"
|
||||||
verify_checksum_file "$rpm_checksum"
|
verify_checksum_file "$rpm_checksum"
|
||||||
verify_checksum_file "$windows_checksums"
|
verify_checksum_file "$windows_checksums"
|
||||||
verify_checksum_file "$play_checksums"
|
verify_checksum_file "$play_checksums"
|
||||||
|
verify_checksum_file "$apple_core_checksum"
|
||||||
|
|
||||||
[[ $(jq -r '.releaseStatus' "$play_metadata") == draft ]]
|
[[ $(jq -r '.releaseStatus' "$play_metadata") == draft ]]
|
||||||
[[ $(jq -r '.releaseName' "$play_metadata") == "$version" ]]
|
[[ $(jq -r '.releaseName' "$play_metadata") == "$version" ]]
|
||||||
@@ -103,7 +107,7 @@ mkdir -p "$output_dir"
|
|||||||
printf 'Release output directory must be empty: %s\n' "$output_dir" >&2
|
printf 'Release output directory must be empty: %s\n' "$output_dir" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$output_dir/"
|
cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$apple_core" "$output_dir/"
|
||||||
|
|
||||||
payloads=(
|
payloads=(
|
||||||
"$output_dir/$(basename "$deb")"
|
"$output_dir/$(basename "$deb")"
|
||||||
@@ -111,6 +115,7 @@ payloads=(
|
|||||||
"$output_dir/$(basename "$dmg")"
|
"$output_dir/$(basename "$dmg")"
|
||||||
"$output_dir/$(basename "$appcast")"
|
"$output_dir/$(basename "$appcast")"
|
||||||
"$output_dir/$(basename "$play_apk")"
|
"$output_dir/$(basename "$play_apk")"
|
||||||
|
"$output_dir/$(basename "$apple_core")"
|
||||||
)
|
)
|
||||||
files_json="$(
|
files_json="$(
|
||||||
for file in "${payloads[@]}"; do
|
for file in "${payloads[@]}"; do
|
||||||
@@ -168,6 +173,7 @@ jq -n \
|
|||||||
"$(basename "$dmg")" \
|
"$(basename "$dmg")" \
|
||||||
"$(basename "$appcast")" \
|
"$(basename "$appcast")" \
|
||||||
"$(basename "$play_apk")" \
|
"$(basename "$play_apk")" \
|
||||||
|
"$(basename "$apple_core")" \
|
||||||
release-manifest.json \
|
release-manifest.json \
|
||||||
> SHA256SUMS
|
> SHA256SUMS
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ printf 'deb\n' > "$input_dir/deb/vnidrop_${version}-1_amd64.deb"
|
|||||||
printf 'rpm\n' > "$input_dir/rpm/vnidrop-${version}-1.x86_64.rpm"
|
printf 'rpm\n' > "$input_dir/rpm/vnidrop-${version}-1.x86_64.rpm"
|
||||||
printf 'dmg\n' > "$input_dir/macos/VniDrop-${version}.dmg"
|
printf 'dmg\n' > "$input_dir/macos/VniDrop-${version}.dmg"
|
||||||
printf '<url>VniDrop-%s.dmg</url>\n' "$version" > "$input_dir/macos/appcast.xml"
|
printf '<url>VniDrop-%s.dmg</url>\n' "$version" > "$input_dir/macos/appcast.xml"
|
||||||
|
printf 'core\n' > "$input_dir/macos/VnidropCore-${version}.zip"
|
||||||
printf 'apk\n' > "$input_dir/play/VniDrop-${version}-${android_code}-play-universal.apk"
|
printf 'apk\n' > "$input_dir/play/VniDrop-${version}-${android_code}-play-universal.apk"
|
||||||
printf 'msix\n' > "$input_dir/windows/VniDrop_${version}_x64.msix"
|
printf 'msix\n' > "$input_dir/windows/VniDrop_${version}_x64.msix"
|
||||||
printf 'msixupload\n' > "$input_dir/windows/VniDrop_${version}_x64.msixupload"
|
printf 'msixupload\n' > "$input_dir/windows/VniDrop_${version}_x64.msixupload"
|
||||||
@@ -68,6 +69,11 @@ jq -n \
|
|||||||
sha256sum "vnidrop-${version}-1.x86_64.rpm" \
|
sha256sum "vnidrop-${version}-1.x86_64.rpm" \
|
||||||
> "vnidrop-${version}-1.x86_64.rpm.sha256"
|
> "vnidrop-${version}-1.x86_64.rpm.sha256"
|
||||||
)
|
)
|
||||||
|
(
|
||||||
|
cd "$input_dir/macos"
|
||||||
|
sha256sum "VnidropCore-${version}.zip" \
|
||||||
|
> "VnidropCore-${version}.zip.sha256"
|
||||||
|
)
|
||||||
(
|
(
|
||||||
cd "$input_dir/play"
|
cd "$input_dir/play"
|
||||||
sha256sum \
|
sha256sum \
|
||||||
@@ -94,6 +100,7 @@ expected_public_files=(
|
|||||||
"SHA256SUMS"
|
"SHA256SUMS"
|
||||||
"VniDrop-${version}-${android_code}-play-universal.apk"
|
"VniDrop-${version}-${android_code}-play-universal.apk"
|
||||||
"VniDrop-${version}.dmg"
|
"VniDrop-${version}.dmg"
|
||||||
|
"VnidropCore-${version}.zip"
|
||||||
"appcast.xml"
|
"appcast.xml"
|
||||||
"release-manifest.json"
|
"release-manifest.json"
|
||||||
"vnidrop-${version}-1.x86_64.rpm"
|
"vnidrop-${version}-1.x86_64.rpm"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# VniDrop diagnostics API
|
# VniDrop diagnostics API
|
||||||
|
|
||||||
Cloudflare Worker for ingesting batched telemetry, crash reports, and user-submitted
|
Cloudflare Worker for ingesting user-submitted bug reports. D1 stores searchable
|
||||||
bug reports. D1 stores searchable metadata; R2 stores larger stack traces and logs.
|
metadata; R2 stores the larger attached logs.
|
||||||
|
|
||||||
The service is designed for modest traffic and low operating cost:
|
The service is designed for modest traffic and low operating cost:
|
||||||
|
|
||||||
- one D1 row is written per telemetry batch, not per event;
|
- one D1 row is written per bug report;
|
||||||
- crash stacks and bug logs are stored in R2 instead of D1;
|
- bug logs are stored in R2 instead of D1;
|
||||||
- request and batch limits reject oversized work before storage writes;
|
- request limits reject oversized work before storage writes;
|
||||||
- an hourly scheduled cleanup and an R2 lifecycle rule enforce retention;
|
- an hourly scheduled cleanup and an R2 lifecycle rule enforce retention;
|
||||||
- no Queue, Durable Object, or KV resources are required.
|
- no Queue, Durable Object, or KV resources are required.
|
||||||
|
|
||||||
@@ -35,15 +35,13 @@ X-VniDrop-Install-Id: <anonymous install UUID>
|
|||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| `GET` | `/live` | process liveness; does not touch storage |
|
| `GET` | `/live` | process liveness; does not touch storage |
|
||||||
| `GET` | `/health` | authenticated readiness; checks required configuration and the D1 schema |
|
| `GET` | `/health` | authenticated readiness; checks required configuration and the D1 schema |
|
||||||
| `POST` | `/v1/events` | `{ batchId, installId, appVersion?, platform?, events: [...] }` |
|
|
||||||
| `POST` | `/v1/crashes` | app crash payload |
|
|
||||||
| `POST` | `/v1/bugs` | app bug-report payload |
|
| `POST` | `/v1/bugs` | app bug-report payload |
|
||||||
|
|
||||||
Batch and report IDs are client-generated UUIDs. A client must reuse the same ID
|
Report IDs are client-generated UUIDs. A client must reuse the same ID when
|
||||||
when retrying so D1 can acknowledge the request without storing it twice.
|
retrying so D1 can acknowledge the request without storing it twice.
|
||||||
|
|
||||||
Accepted reports return `202`. Defaults are a 262,144-byte request limit and at
|
Accepted reports return `202`. The default is a 262,144-byte request limit.
|
||||||
most 50 events per batch. Cloudflare rate-limit bindings allow 30 requests per
|
Cloudflare rate-limit bindings allow 30 requests per
|
||||||
installation and 120 requests per source, per ingest route, per minute. Source
|
installation and 120 requests per source, per ingest route, per minute. Source
|
||||||
limits run before shared-key verification so rejected traffic is bounded too.
|
limits run before shared-key verification so rejected traffic is bounded too.
|
||||||
These counters are eventually consistent and local to a Cloudflare location, so
|
These counters are eventually consistent and local to a Cloudflare location, so
|
||||||
@@ -153,11 +151,11 @@ migrations to the isolated local database assigned to each test file.
|
|||||||
`RETENTION_DAYS` defaults to 90. The `17 * * * *` cron trigger runs cleanup at
|
`RETENTION_DAYS` defaults to 90. The `17 * * * *` cron trigger runs cleanup at
|
||||||
17 minutes past every hour. Cleanup works in bounded batches: it deletes each
|
17 minutes past every hour. Cleanup works in bounded batches: it deletes each
|
||||||
expired report's referenced R2 object before deleting that exact D1 row. The R2
|
expired report's referenced R2 object before deleting that exact D1 row. The R2
|
||||||
lifecycle rule is an independent backstop for stack and log objects, including
|
lifecycle rule is an independent backstop for log objects, including objects left
|
||||||
objects left behind by a partial ingest failure. Each scheduled run can remove
|
behind by a partial ingest failure. Each scheduled run can remove 7,200 bug rows
|
||||||
8,000 event batches and 7,200 rows from each report table while staying below
|
while staying below D1's per-invocation query ceiling. Later hourly runs continue
|
||||||
D1's per-invocation query ceiling. Later hourly runs continue any backlog.
|
any backlog.
|
||||||
Reaching the cap emits a structured warning with the remaining expired-row counts;
|
Reaching the cap emits a structured warning with the remaining expired-row count;
|
||||||
alert on that warning because
|
alert on that warning because
|
||||||
retention is necessarily best-effort during sustained distributed abuse.
|
retention is necessarily best-effort during sustained distributed abuse.
|
||||||
|
|
||||||
@@ -179,23 +177,20 @@ vnidrop.diagnostics.ingestKey=<same value as INGEST_KEY>
|
|||||||
|
|
||||||
Both the endpoint and key are required. When both are empty the app uses its
|
Both the endpoint and key are required. When both are empty the app uses its
|
||||||
offline-safe no-op transport; configuring only one fails the Gradle build.
|
offline-safe no-op transport; configuring only one fails the Gradle build.
|
||||||
`vnidrop.diagnostics.included=false` disables
|
`vnidrop.diagnostics.included=false` routes bug reports to that no-op transport
|
||||||
automatic telemetry and crash upload, but a configured endpoint can still accept
|
(never sent); a configured endpoint accepts an explicit user-submitted bug report.
|
||||||
an explicit user-submitted bug report. Treat the app-side key as an abuse-control
|
Treat the app-side key as an abuse-control token with the limitations described
|
||||||
token with the limitations described above.
|
above.
|
||||||
|
|
||||||
## Reading reports
|
## Reading reports
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
|
||||||
--command "SELECT id, exception_type, platform, occurred_at FROM crashes ORDER BY occurred_at DESC LIMIT 20"
|
|
||||||
|
|
||||||
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
||||||
--command "SELECT id, what_happened, status, occurred_at FROM bugs WHERE status = 'open' ORDER BY occurred_at DESC LIMIT 20"
|
--command "SELECT id, what_happened, status, occurred_at FROM bugs WHERE status = 'open' ORDER BY occurred_at DESC LIMIT 20"
|
||||||
```
|
```
|
||||||
|
|
||||||
R2 object keys use `crashes/<id>/<attempt-id>/stack.txt` and
|
R2 object keys use `bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment
|
||||||
`bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment prevents a retry
|
prevents a retry from overwriting an already accepted object before D1 detects
|
||||||
from overwriting an already accepted object before D1 detects the duplicate.
|
the duplicate.
|
||||||
There is no public administration endpoint; inspect reports through authenticated
|
There is no public administration endpoint; inspect reports through authenticated
|
||||||
Cloudflare tools or a future Access-protected dashboard.
|
Cloudflare tools or a future Access-protected dashboard.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Telemetry and crash auto-reporting were removed from the app; only user-initiated
|
||||||
|
-- bug reports remain. Drop the now-unused ingestion tables and their indexes.
|
||||||
|
DROP INDEX IF EXISTS idx_event_batches_received;
|
||||||
|
DROP INDEX IF EXISTS idx_event_batches_install;
|
||||||
|
DROP TABLE IF EXISTS event_batches;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_crashes_received;
|
||||||
|
DROP INDEX IF EXISTS idx_crashes_fingerprint;
|
||||||
|
DROP INDEX IF EXISTS idx_crashes_install;
|
||||||
|
DROP TABLE IF EXISTS crashes;
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
normalizeBug,
|
normalizeBug,
|
||||||
normalizeCrash,
|
|
||||||
normalizeEvents,
|
|
||||||
readJsonObject,
|
readJsonObject,
|
||||||
} from "./input";
|
} from "./input";
|
||||||
import {
|
import {
|
||||||
type DiagnosticsEnv,
|
type DiagnosticsEnv,
|
||||||
runRetention,
|
runRetention,
|
||||||
storeBug,
|
storeBug,
|
||||||
storeCrash,
|
|
||||||
storeEvents,
|
|
||||||
} from "./storage";
|
} from "./storage";
|
||||||
|
|
||||||
const DEFAULT_MAX_BODY_BYTES = 262_144;
|
const DEFAULT_MAX_BODY_BYTES = 262_144;
|
||||||
const HARD_MAX_BODY_BYTES = 1_048_576;
|
const HARD_MAX_BODY_BYTES = 1_048_576;
|
||||||
const DEFAULT_MAX_EVENTS = 50;
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> {
|
||||||
@@ -72,41 +67,6 @@ export default {
|
|||||||
if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId);
|
if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId);
|
||||||
|
|
||||||
switch (url.pathname) {
|
switch (url.pathname) {
|
||||||
case "/v1/events": {
|
|
||||||
const maxEvents = boundedPositiveInt(env.MAX_EVENTS_PER_BATCH, DEFAULT_MAX_EVENTS, 1, 100);
|
|
||||||
const normalized = normalizeEvents(parsed.value, maxEvents);
|
|
||||||
if (!normalized.ok) {
|
|
||||||
return json({ error: normalized.error }, normalized.status, requestId);
|
|
||||||
}
|
|
||||||
const result = await storeEvents(normalized.value, env);
|
|
||||||
return json(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
id: result.id,
|
|
||||||
stored: result.stored,
|
|
||||||
duplicate: result.duplicate,
|
|
||||||
},
|
|
||||||
202,
|
|
||||||
requestId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "/v1/crashes": {
|
|
||||||
const normalized = normalizeCrash(parsed.value);
|
|
||||||
if (!normalized.ok) {
|
|
||||||
return json({ error: normalized.error }, normalized.status, requestId);
|
|
||||||
}
|
|
||||||
const result = await storeCrash(normalized.value, env);
|
|
||||||
return json(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
id: result.id,
|
|
||||||
fingerprint: result.fingerprint,
|
|
||||||
duplicate: result.duplicate,
|
|
||||||
},
|
|
||||||
202,
|
|
||||||
requestId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "/v1/bugs": {
|
case "/v1/bugs": {
|
||||||
const normalized = normalizeBug(parsed.value);
|
const normalized = normalizeBug(parsed.value);
|
||||||
if (!normalized.ok) {
|
if (!normalized.ok) {
|
||||||
@@ -159,12 +119,6 @@ async function readiness(env: DiagnosticsEnv, requestId: string): Promise<Respon
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await env.DB.batch([
|
await env.DB.batch([
|
||||||
env.DB.prepare(
|
|
||||||
"SELECT id, received_at, install_id, payload_json FROM event_batches LIMIT 1",
|
|
||||||
),
|
|
||||||
env.DB.prepare(
|
|
||||||
"SELECT id, occurred_at, stack_r2_key, breadcrumbs_json FROM crashes LIMIT 1",
|
|
||||||
),
|
|
||||||
env.DB.prepare(
|
env.DB.prepare(
|
||||||
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
|
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
|
||||||
),
|
),
|
||||||
@@ -216,8 +170,8 @@ async function installRateLimited(
|
|||||||
return !result.success;
|
return !result.success;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isIngestPath(path: string): path is "/v1/events" | "/v1/crashes" | "/v1/bugs" {
|
function isIngestPath(path: string): path is "/v1/bugs" {
|
||||||
return path === "/v1/events" || path === "/v1/crashes" || path === "/v1/bugs";
|
return path === "/v1/bugs";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {
|
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -8,21 +8,6 @@ export type InputFailure = {
|
|||||||
|
|
||||||
export type InputResult<T> = { ok: true; value: T } | InputFailure;
|
export type InputResult<T> = { ok: true; value: T } | InputFailure;
|
||||||
|
|
||||||
export type NormalizedProperties = Record<string, string>;
|
|
||||||
|
|
||||||
export interface NormalizedEvent {
|
|
||||||
name: string;
|
|
||||||
timestampMillis: number;
|
|
||||||
properties: NormalizedProperties;
|
|
||||||
schemaVersion: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedBreadcrumb {
|
|
||||||
name: string;
|
|
||||||
timestampMillis: number;
|
|
||||||
properties: NormalizedProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedDevice {
|
export interface NormalizedDevice {
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
deviceModel: string;
|
deviceModel: string;
|
||||||
@@ -31,28 +16,6 @@ export interface NormalizedDevice {
|
|||||||
batteryLevel: string;
|
batteryLevel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedEventsPayload {
|
|
||||||
batchId: string;
|
|
||||||
installId: string;
|
|
||||||
appVersion: string;
|
|
||||||
platform: string;
|
|
||||||
events: NormalizedEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedCrashPayload {
|
|
||||||
id: string;
|
|
||||||
installId: string;
|
|
||||||
appVersion: string;
|
|
||||||
platform: string;
|
|
||||||
exceptionType: string;
|
|
||||||
exceptionMessage: string;
|
|
||||||
stackTrace: string;
|
|
||||||
occurredAt: number;
|
|
||||||
diagnosticsEnabledAtCapture: boolean;
|
|
||||||
breadcrumbs: NormalizedBreadcrumb[];
|
|
||||||
schemaVersion: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedBugPayload {
|
export interface NormalizedBugPayload {
|
||||||
id: string;
|
id: string;
|
||||||
installId: string;
|
installId: string;
|
||||||
@@ -65,16 +28,12 @@ export interface NormalizedBugPayload {
|
|||||||
contact: string;
|
contact: string;
|
||||||
logs: string;
|
logs: string;
|
||||||
device: NormalizedDevice;
|
device: NormalizedDevice;
|
||||||
breadcrumbs: NormalizedBreadcrumb[];
|
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_LOG_BYTES = 192 * 1024;
|
export const MAX_LOG_BYTES = 192 * 1024;
|
||||||
export const MAX_BREADCRUMBS_JSON_BYTES = 16_000;
|
|
||||||
export const MAX_DEVICE_JSON_BYTES = 4_000;
|
export const MAX_DEVICE_JSON_BYTES = 4_000;
|
||||||
|
|
||||||
const MAX_PROPERTIES = 12;
|
|
||||||
const MAX_BREADCRUMBS = 40;
|
|
||||||
const MISSING = Symbol("missing");
|
const MISSING = Symbol("missing");
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
const UTF8_ENCODER = new TextEncoder();
|
const UTF8_ENCODER = new TextEncoder();
|
||||||
@@ -164,119 +123,6 @@ export async function readJsonObject(
|
|||||||
return success(parsed);
|
return success(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeEvents(
|
|
||||||
body: JsonObject,
|
|
||||||
maxEvents = 50,
|
|
||||||
): InputResult<NormalizedEventsPayload> {
|
|
||||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
|
||||||
if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) {
|
|
||||||
throw new RangeError("maxEvents must be a positive safe integer");
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id");
|
|
||||||
if (!batchId.ok) return batchId;
|
|
||||||
const installId = installIdField(body);
|
|
||||||
if (!installId.ok) return installId;
|
|
||||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
|
||||||
if (!appVersion.ok) return appVersion;
|
|
||||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
|
||||||
if (!platform.ok) return platform;
|
|
||||||
|
|
||||||
const batchSchema = schemaVersion(body);
|
|
||||||
if (!batchSchema.ok) return batchSchema;
|
|
||||||
const rawEvents = pick(body, ["events"]);
|
|
||||||
if (!Array.isArray(rawEvents)) return failure(400, "invalid_events");
|
|
||||||
if (rawEvents.length === 0) return failure(400, "empty_batch");
|
|
||||||
if (rawEvents.length > maxEvents) return failure(400, "batch_too_large");
|
|
||||||
|
|
||||||
const events: NormalizedEvent[] = [];
|
|
||||||
for (const rawEvent of rawEvents) {
|
|
||||||
const event = normalizeEvent(rawEvent);
|
|
||||||
if (!event.ok) return event;
|
|
||||||
events.push(event.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return success({
|
|
||||||
batchId: batchId.value,
|
|
||||||
installId: installId.value,
|
|
||||||
appVersion: appVersion.value,
|
|
||||||
platform: platform.value,
|
|
||||||
events,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCrash(body: JsonObject): InputResult<NormalizedCrashPayload> {
|
|
||||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
|
||||||
|
|
||||||
const id = idField(body, ["id"], "invalid_id");
|
|
||||||
if (!id.ok) return id;
|
|
||||||
const installId = installIdField(body);
|
|
||||||
if (!installId.ok) return installId;
|
|
||||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
|
||||||
if (!appVersion.ok) return appVersion;
|
|
||||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
|
||||||
if (!platform.ok) return platform;
|
|
||||||
const exceptionType = stringField(
|
|
||||||
body,
|
|
||||||
["exceptionType", "exception_type"],
|
|
||||||
120,
|
|
||||||
"invalid_exception_type",
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!exceptionType.ok) return exceptionType;
|
|
||||||
const exceptionMessage = stringField(
|
|
||||||
body,
|
|
||||||
["exceptionMessage", "exception_message"],
|
|
||||||
2_000,
|
|
||||||
"invalid_exception_message",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!exceptionMessage.ok) return exceptionMessage;
|
|
||||||
const stackTrace = stringField(
|
|
||||||
body,
|
|
||||||
["stackTrace", "stack_trace"],
|
|
||||||
32_000,
|
|
||||||
"invalid_stack_trace",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!stackTrace.ok) return stackTrace;
|
|
||||||
const occurredAt = timestampField(
|
|
||||||
body,
|
|
||||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
|
||||||
);
|
|
||||||
if (!occurredAt.ok) return occurredAt;
|
|
||||||
const diagnosticsEnabled = booleanField(
|
|
||||||
body,
|
|
||||||
[
|
|
||||||
"diagnosticsEnabledAtCapture",
|
|
||||||
"diagnostics_enabled_at_capture",
|
|
||||||
"diagnostics_enabled",
|
|
||||||
],
|
|
||||||
"invalid_diagnostics_enabled",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!diagnosticsEnabled.ok) return diagnosticsEnabled;
|
|
||||||
const version = schemaVersion(body);
|
|
||||||
if (!version.ok) return version;
|
|
||||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
|
||||||
if (!breadcrumbs.ok) return breadcrumbs;
|
|
||||||
|
|
||||||
return success({
|
|
||||||
id: id.value,
|
|
||||||
installId: installId.value,
|
|
||||||
appVersion: appVersion.value,
|
|
||||||
platform: platform.value,
|
|
||||||
exceptionType: exceptionType.value,
|
|
||||||
exceptionMessage: exceptionMessage.value,
|
|
||||||
stackTrace: stackTrace.value,
|
|
||||||
occurredAt: occurredAt.value,
|
|
||||||
diagnosticsEnabledAtCapture: diagnosticsEnabled.value,
|
|
||||||
breadcrumbs: breadcrumbs.value,
|
|
||||||
schemaVersion: version.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
|
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
|
||||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||||
|
|
||||||
@@ -319,8 +165,6 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
|
|||||||
if (!logs.ok) return logs;
|
if (!logs.ok) return logs;
|
||||||
const device = normalizeDevice(pick(body, ["device"]));
|
const device = normalizeDevice(pick(body, ["device"]));
|
||||||
if (!device.ok) return device;
|
if (!device.ok) return device;
|
||||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
|
||||||
if (!breadcrumbs.ok) return breadcrumbs;
|
|
||||||
const version = schemaVersion(body);
|
const version = schemaVersion(body);
|
||||||
if (!version.ok) return version;
|
if (!version.ok) return version;
|
||||||
|
|
||||||
@@ -336,80 +180,10 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
|
|||||||
contact: contact.value,
|
contact: contact.value,
|
||||||
logs: includeLogs.value === true ? logs.value : "",
|
logs: includeLogs.value === true ? logs.value : "",
|
||||||
device: device.value,
|
device: device.value,
|
||||||
breadcrumbs: breadcrumbs.value,
|
|
||||||
schemaVersion: version.value,
|
schemaVersion: version.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEvent(raw: unknown): InputResult<NormalizedEvent> {
|
|
||||||
if (!isPlainObject(raw)) return failure(400, "invalid_event");
|
|
||||||
const name = stringField(raw, ["name"], 64, "invalid_event", true, true);
|
|
||||||
if (!name.ok) return name;
|
|
||||||
const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]);
|
|
||||||
if (!timestamp.ok) return failure(400, "invalid_event");
|
|
||||||
const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event");
|
|
||||||
if (!properties.ok) return properties;
|
|
||||||
const version = schemaVersion(raw);
|
|
||||||
if (!version.ok) return version;
|
|
||||||
return success({
|
|
||||||
name: name.value,
|
|
||||||
timestampMillis: timestamp.value,
|
|
||||||
properties: properties.value,
|
|
||||||
schemaVersion: version.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult<NormalizedBreadcrumb[]> {
|
|
||||||
if (raw === MISSING) return success([]);
|
|
||||||
if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs");
|
|
||||||
|
|
||||||
const breadcrumbs: NormalizedBreadcrumb[] = [];
|
|
||||||
for (const item of raw.slice(0, MAX_BREADCRUMBS)) {
|
|
||||||
if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs");
|
|
||||||
const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true);
|
|
||||||
if (!name.ok) return name;
|
|
||||||
const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]);
|
|
||||||
if (!timestamp.ok) return failure(400, "invalid_breadcrumbs");
|
|
||||||
const properties = normalizeProperties(
|
|
||||||
pick(item, ["properties", "props"]),
|
|
||||||
"invalid_breadcrumbs",
|
|
||||||
);
|
|
||||||
if (!properties.ok) return properties;
|
|
||||||
|
|
||||||
breadcrumbs.push({
|
|
||||||
name: name.value,
|
|
||||||
timestampMillis: timestamp.value,
|
|
||||||
properties: properties.value,
|
|
||||||
});
|
|
||||||
if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) {
|
|
||||||
breadcrumbs.pop();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return success(breadcrumbs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProperties(
|
|
||||||
raw: unknown | typeof MISSING,
|
|
||||||
error: string,
|
|
||||||
): InputResult<NormalizedProperties> {
|
|
||||||
if (raw === MISSING) return success({});
|
|
||||||
if (!isPlainObject(raw)) return failure(400, error);
|
|
||||||
|
|
||||||
const entries: Array<[string, string]> = [];
|
|
||||||
const normalizedKeys = new Set<string>();
|
|
||||||
for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) {
|
|
||||||
if (typeof value !== "string") return failure(400, error);
|
|
||||||
const normalizedKey = truncateUtf8(key, 40);
|
|
||||||
if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) {
|
|
||||||
return failure(400, error);
|
|
||||||
}
|
|
||||||
normalizedKeys.add(normalizedKey);
|
|
||||||
entries.push([normalizedKey, truncateUtf8(value, 128)]);
|
|
||||||
}
|
|
||||||
return success(Object.fromEntries(entries));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
|
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
|
||||||
if (raw === MISSING) raw = {};
|
if (raw === MISSING) raw = {};
|
||||||
if (!isPlainObject(raw)) return failure(400, "invalid_device");
|
if (!isPlainObject(raw)) return failure(400, "invalid_device");
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import type {
|
import type { NormalizedBugPayload } from "./input";
|
||||||
NormalizedBugPayload,
|
|
||||||
NormalizedCrashPayload,
|
|
||||||
NormalizedEventsPayload,
|
|
||||||
} from "./input";
|
|
||||||
|
|
||||||
export type DiagnosticsEnv = Cloudflare.Env & {
|
export type DiagnosticsEnv = Cloudflare.Env & {
|
||||||
INGEST_KEY?: string;
|
INGEST_KEY?: string;
|
||||||
AE?: AnalyticsEngineDataset;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface StoreResult {
|
export interface StoreResult {
|
||||||
@@ -15,130 +10,6 @@ export interface StoreResult {
|
|||||||
stored: number;
|
stored: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function storeEvents(
|
|
||||||
payload: NormalizedEventsPayload,
|
|
||||||
env: DiagnosticsEnv,
|
|
||||||
): Promise<StoreResult> {
|
|
||||||
const result = await env.DB.prepare(
|
|
||||||
`INSERT INTO event_batches (id, received_at, install_id, app_version, platform, event_count, payload_json)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO NOTHING`,
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
payload.batchId,
|
|
||||||
Date.now(),
|
|
||||||
payload.installId,
|
|
||||||
payload.appVersion,
|
|
||||||
payload.platform,
|
|
||||||
payload.events.length,
|
|
||||||
JSON.stringify(payload.events),
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
const duplicate = result.meta.changes === 0;
|
|
||||||
if (!duplicate && env.AE) {
|
|
||||||
try {
|
|
||||||
for (const event of payload.events) {
|
|
||||||
env.AE.writeDataPoint({
|
|
||||||
blobs: [
|
|
||||||
event.name,
|
|
||||||
payload.platform,
|
|
||||||
payload.appVersion,
|
|
||||||
payload.installId,
|
|
||||||
JSON.stringify(event.properties),
|
|
||||||
payload.batchId,
|
|
||||||
],
|
|
||||||
doubles: [event.timestampMillis, event.schemaVersion],
|
|
||||||
indexes: [payload.installId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// D1 remains the durable source of truth if the optional analytics index is unavailable.
|
|
||||||
console.error(
|
|
||||||
JSON.stringify({
|
|
||||||
message: "failed to index diagnostics event batch",
|
|
||||||
batchId: payload.batchId,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: payload.batchId,
|
|
||||||
duplicate,
|
|
||||||
stored: duplicate ? 0 : payload.events.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function storeCrash(
|
|
||||||
payload: NormalizedCrashPayload,
|
|
||||||
env: DiagnosticsEnv,
|
|
||||||
): Promise<StoreResult & { fingerprint: string }> {
|
|
||||||
const database = env.DB.withSession("first-primary");
|
|
||||||
const existing = await database
|
|
||||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
|
||||||
.bind(payload.id)
|
|
||||||
.first<{ fingerprint: string }>();
|
|
||||||
if (existing) {
|
|
||||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: existing.fingerprint };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fingerprint = await crashFingerprint(payload.exceptionType, payload.stackTrace);
|
|
||||||
const stackKey = payload.stackTrace
|
|
||||||
? `crashes/${payload.id}/${crypto.randomUUID()}/stack.txt`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (stackKey) {
|
|
||||||
await env.BLOBS.put(stackKey, payload.stackTrace, {
|
|
||||||
httpMetadata: { contentType: "text/plain; charset=utf-8" },
|
|
||||||
customMetadata: { installId: payload.installId, fingerprint },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await database
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO crashes (
|
|
||||||
id, received_at, occurred_at, install_id, app_version, platform,
|
|
||||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
|
||||||
stack_r2_key, breadcrumbs_json, schema_version
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO NOTHING`,
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
payload.id,
|
|
||||||
Date.now(),
|
|
||||||
payload.occurredAt,
|
|
||||||
payload.installId,
|
|
||||||
payload.appVersion,
|
|
||||||
payload.platform,
|
|
||||||
payload.exceptionType,
|
|
||||||
payload.exceptionMessage,
|
|
||||||
fingerprint,
|
|
||||||
payload.diagnosticsEnabledAtCapture ? 1 : 0,
|
|
||||||
stackKey,
|
|
||||||
JSON.stringify(payload.breadcrumbs),
|
|
||||||
payload.schemaVersion,
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
const duplicate = result.meta.changes === 0;
|
|
||||||
if (duplicate) {
|
|
||||||
const stored = await database
|
|
||||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
|
||||||
.bind(payload.id)
|
|
||||||
.first<{ fingerprint: string }>();
|
|
||||||
if (!stored) throw new Error("duplicate crash row was not readable");
|
|
||||||
if (stackKey) await deleteAttemptBlob(env, stackKey);
|
|
||||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: stored.fingerprint };
|
|
||||||
}
|
|
||||||
return { id: payload.id, duplicate: false, stored: 1, fingerprint };
|
|
||||||
} catch (error) {
|
|
||||||
if (stackKey) {
|
|
||||||
await deleteAttemptBlob(env, stackKey);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function storeBug(
|
export async function storeBug(
|
||||||
payload: NormalizedBugPayload,
|
payload: NormalizedBugPayload,
|
||||||
env: DiagnosticsEnv,
|
env: DiagnosticsEnv,
|
||||||
@@ -161,12 +32,12 @@ export async function storeBug(
|
|||||||
try {
|
try {
|
||||||
const result = await database
|
const result = await database
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO bugs (
|
`INSERT INTO bugs (
|
||||||
id, received_at, occurred_at, install_id, app_version, platform,
|
id, received_at, occurred_at, install_id, app_version, platform,
|
||||||
what_happened, expected, steps, contact, logs_r2_key,
|
what_happened, expected, steps, contact, logs_r2_key,
|
||||||
device_json, breadcrumbs_json, status, schema_version
|
device_json, status, schema_version
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
|
||||||
ON CONFLICT(id) DO NOTHING`,
|
ON CONFLICT(id) DO NOTHING`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
payload.id,
|
payload.id,
|
||||||
@@ -181,7 +52,6 @@ export async function storeBug(
|
|||||||
payload.contact,
|
payload.contact,
|
||||||
logsKey,
|
logsKey,
|
||||||
JSON.stringify(payload.device),
|
JSON.stringify(payload.device),
|
||||||
JSON.stringify(payload.breadcrumbs),
|
|
||||||
payload.schemaVersion,
|
payload.schemaVersion,
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
@@ -201,26 +71,22 @@ export async function storeBug(
|
|||||||
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
||||||
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
|
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
|
||||||
const cutoff = Date.now() - retentionDays * 86_400_000;
|
const cutoff = Date.now() - retentionDays * 86_400_000;
|
||||||
// Eight full passes plus the backlog check use at most 43 of D1's 50 queries per invocation.
|
// Eight passes plus the backlog check stay well within D1's 50 queries per invocation.
|
||||||
for (let pass = 0; pass < 8; pass += 1) {
|
for (let pass = 0; pass < 8; pass += 1) {
|
||||||
const hasFullBatch = await runRetentionPass(env, cutoff);
|
const hasFullBatch = await runRetentionPass(env, cutoff);
|
||||||
if (!hasFullBatch) return;
|
if (!hasFullBatch) return;
|
||||||
}
|
}
|
||||||
const [events, crashes, bugs] = await env.DB.batch<{ count: number }>([
|
const bugs = await env.DB.prepare(
|
||||||
env.DB.prepare("SELECT COUNT(*) AS count FROM event_batches WHERE received_at < ?").bind(
|
"SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?",
|
||||||
cutoff,
|
)
|
||||||
),
|
.bind(cutoff)
|
||||||
env.DB.prepare("SELECT COUNT(*) AS count FROM crashes WHERE received_at < ?").bind(cutoff),
|
.first<{ count: number }>();
|
||||||
env.DB.prepare("SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?").bind(cutoff),
|
|
||||||
]);
|
|
||||||
console.warn(
|
console.warn(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
message: "diagnostics retention reached its per-run pass limit",
|
message: "diagnostics retention reached its per-run pass limit",
|
||||||
cutoff,
|
cutoff,
|
||||||
backlog: {
|
backlog: {
|
||||||
eventBatches: events.results[0]?.count ?? 0,
|
bugs: bugs?.count ?? 0,
|
||||||
crashes: crashes.results[0]?.count ?? 0,
|
|
||||||
bugs: bugs.results[0]?.count ?? 0,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -228,28 +94,17 @@ export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
|||||||
|
|
||||||
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
|
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
|
||||||
const reportBatchSize = 900;
|
const reportBatchSize = 900;
|
||||||
const eventBatchSize = 1_000;
|
const bugs = await expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize);
|
||||||
const [crashes, bugs] = await Promise.all([
|
|
||||||
expiredBlobRows(env.DB, "crashes", "stack_r2_key", cutoff, reportBatchSize),
|
|
||||||
expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const blobKeys = [...crashes, ...bugs]
|
const blobKeys = bugs
|
||||||
.map((row) => row.blobKey)
|
.map((row) => row.blobKey)
|
||||||
.filter((key): key is string => key !== null);
|
.filter((key): key is string => key !== null);
|
||||||
for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
|
for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
|
||||||
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
|
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
const statements = [retentionStatement(env.DB, "event_batches", cutoff, eventBatchSize)];
|
if (bugs.length > 0) await deleteRowsById(env.DB, "bugs", bugs).run();
|
||||||
if (crashes.length > 0) statements.push(deleteRowsById(env.DB, "crashes", crashes));
|
return bugs.length === reportBatchSize;
|
||||||
if (bugs.length > 0) statements.push(deleteRowsById(env.DB, "bugs", bugs));
|
|
||||||
const [eventsResult] = await env.DB.batch(statements);
|
|
||||||
return (
|
|
||||||
eventsResult.meta.changes === eventBatchSize ||
|
|
||||||
crashes.length === reportBatchSize ||
|
|
||||||
bugs.length === reportBatchSize
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExpiredBlobRow {
|
interface ExpiredBlobRow {
|
||||||
@@ -259,8 +114,8 @@ interface ExpiredBlobRow {
|
|||||||
|
|
||||||
async function expiredBlobRows(
|
async function expiredBlobRows(
|
||||||
database: D1Database,
|
database: D1Database,
|
||||||
table: "crashes" | "bugs",
|
table: "bugs",
|
||||||
column: "stack_r2_key" | "logs_r2_key",
|
column: "logs_r2_key",
|
||||||
cutoff: number,
|
cutoff: number,
|
||||||
batchSize: number,
|
batchSize: number,
|
||||||
): Promise<ExpiredBlobRow[]> {
|
): Promise<ExpiredBlobRow[]> {
|
||||||
@@ -277,25 +132,9 @@ async function expiredBlobRows(
|
|||||||
return result.results;
|
return result.results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function retentionStatement(
|
|
||||||
database: D1Database,
|
|
||||||
table: "event_batches" | "crashes" | "bugs",
|
|
||||||
cutoff: number,
|
|
||||||
batchSize: number,
|
|
||||||
): D1PreparedStatement {
|
|
||||||
return database
|
|
||||||
.prepare(
|
|
||||||
`DELETE FROM ${table}
|
|
||||||
WHERE rowid IN (
|
|
||||||
SELECT rowid FROM ${table} WHERE received_at < ? ORDER BY received_at LIMIT ?
|
|
||||||
)`,
|
|
||||||
)
|
|
||||||
.bind(cutoff, batchSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteRowsById(
|
function deleteRowsById(
|
||||||
database: D1Database,
|
database: D1Database,
|
||||||
table: "crashes" | "bugs",
|
table: "bugs",
|
||||||
rows: ExpiredBlobRow[],
|
rows: ExpiredBlobRow[],
|
||||||
): D1PreparedStatement {
|
): D1PreparedStatement {
|
||||||
return database
|
return database
|
||||||
@@ -303,18 +142,6 @@ function deleteRowsById(
|
|||||||
.bind(JSON.stringify(rows.map((row) => row.id)));
|
.bind(JSON.stringify(rows.map((row) => row.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function crashFingerprint(exceptionType: string, stackTrace: string): Promise<string> {
|
|
||||||
const topFrames = stackTrace
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 4)
|
|
||||||
.join("\n");
|
|
||||||
const bytes = new TextEncoder().encode(`${exceptionType}\n${topFrames}`);
|
|
||||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
||||||
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> {
|
async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await env.BLOBS.delete(key);
|
await env.BLOBS.delete(key);
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
MAX_BREADCRUMBS_JSON_BYTES,
|
|
||||||
MAX_DEVICE_JSON_BYTES,
|
MAX_DEVICE_JSON_BYTES,
|
||||||
MAX_LOG_BYTES,
|
MAX_LOG_BYTES,
|
||||||
normalizeBug,
|
normalizeBug,
|
||||||
normalizeCrash,
|
|
||||||
normalizeEvents,
|
|
||||||
readJsonObject,
|
readJsonObject,
|
||||||
} from "../src/input";
|
} from "../src/input";
|
||||||
|
|
||||||
@@ -57,7 +54,7 @@ describe("readJsonObject", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("requires application/json with a UTF-8 charset", async () => {
|
it("requires application/json with a UTF-8 charset", async () => {
|
||||||
const missing = new Request("https://example.test/v1/events", {
|
const missing = new Request("https://example.test/v1/bugs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: "{}",
|
body: "{}",
|
||||||
});
|
});
|
||||||
@@ -108,18 +105,6 @@ describe("readJsonObject", () => {
|
|||||||
|
|
||||||
describe("normalizers", () => {
|
describe("normalizers", () => {
|
||||||
it("preserves false booleans and rejects their string representation", () => {
|
it("preserves false booleans and rejects their string representation", () => {
|
||||||
const crash = crashPayload(false);
|
|
||||||
const normalizedCrash = normalizeCrash(crash);
|
|
||||||
expect(normalizedCrash.ok).toBe(true);
|
|
||||||
if (normalizedCrash.ok) {
|
|
||||||
expect(normalizedCrash.value.diagnosticsEnabledAtCapture).toBe(false);
|
|
||||||
}
|
|
||||||
expect(normalizeCrash(crashPayload("false"))).toEqual({
|
|
||||||
ok: false,
|
|
||||||
status: 400,
|
|
||||||
error: "invalid_diagnostics_enabled",
|
|
||||||
});
|
|
||||||
|
|
||||||
const bug = bugPayload({ include_logs: false, logs: "discard me" });
|
const bug = bugPayload({ include_logs: false, logs: "discard me" });
|
||||||
const normalizedBug = normalizeBug(bug);
|
const normalizedBug = normalizeBug(bug);
|
||||||
expect(normalizedBug.ok).toBe(true);
|
expect(normalizedBug.ok).toBe(true);
|
||||||
@@ -133,20 +118,11 @@ describe("normalizers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps logs, breadcrumbs, and device JSON within valid byte budgets", () => {
|
it("keeps logs and device JSON within valid byte budgets", () => {
|
||||||
const properties = Object.fromEntries(
|
|
||||||
Array.from({ length: 12 }, (_, index) => [`key-${index}-${"\u0000".repeat(40)}`, "\u0000".repeat(128)]),
|
|
||||||
);
|
|
||||||
const breadcrumbs = Array.from({ length: 40 }, (_, index) => ({
|
|
||||||
name: `crumb-${index}`,
|
|
||||||
timestamp_millis: index,
|
|
||||||
properties,
|
|
||||||
}));
|
|
||||||
const result = normalizeBug(
|
const result = normalizeBug(
|
||||||
bugPayload({
|
bugPayload({
|
||||||
include_logs: true,
|
include_logs: true,
|
||||||
logs: "😀".repeat(60_000),
|
logs: "😀".repeat(60_000),
|
||||||
breadcrumbs,
|
|
||||||
device: {
|
device: {
|
||||||
device_name: "\u0000".repeat(200),
|
device_name: "\u0000".repeat(200),
|
||||||
device_model: "\u0000".repeat(200),
|
device_model: "\u0000".repeat(200),
|
||||||
@@ -159,54 +135,38 @@ describe("normalizers", () => {
|
|||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
const breadcrumbsJson = JSON.stringify(result.value.breadcrumbs);
|
|
||||||
const deviceJson = JSON.stringify(result.value.device);
|
const deviceJson = JSON.stringify(result.value.device);
|
||||||
expect(ENCODER.encode(result.value.logs).byteLength).toBe(MAX_LOG_BYTES);
|
expect(ENCODER.encode(result.value.logs).byteLength).toBe(MAX_LOG_BYTES);
|
||||||
expect(ENCODER.encode(breadcrumbsJson).byteLength).toBeLessThanOrEqual(
|
|
||||||
MAX_BREADCRUMBS_JSON_BYTES,
|
|
||||||
);
|
|
||||||
expect(ENCODER.encode(deviceJson).byteLength).toBeLessThanOrEqual(MAX_DEVICE_JSON_BYTES);
|
expect(ENCODER.encode(deviceJson).byteLength).toBeLessThanOrEqual(MAX_DEVICE_JSON_BYTES);
|
||||||
expect(JSON.parse(breadcrumbsJson)).toEqual(result.value.breadcrumbs);
|
|
||||||
expect(JSON.parse(deviceJson)).toEqual(result.value.device);
|
expect(JSON.parse(deviceJson)).toEqual(result.value.device);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires stable report IDs and validates supplied IDs and schema versions", () => {
|
it("requires stable report IDs and validates supplied IDs and schema versions", () => {
|
||||||
const result = normalizeEvents({
|
const missingId = normalizeBug(bugPayload({ id: undefined }));
|
||||||
events: [{ name: "opened", ts: 1, schema_version: 1 }],
|
expect(missingId).toEqual({ ok: false, status: 400, error: "invalid_id" });
|
||||||
});
|
|
||||||
expect(result).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
|
|
||||||
const legacyInstall = normalizeEvents({
|
|
||||||
batch_id: ID,
|
|
||||||
install_id: "legacy-test-install",
|
|
||||||
events: [{ name: "opened", ts: 1 }],
|
|
||||||
});
|
|
||||||
expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
|
|
||||||
const missingInstall = normalizeEvents({
|
|
||||||
batch_id: ID,
|
|
||||||
events: [{ name: "opened", ts: 1 }],
|
|
||||||
});
|
|
||||||
expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
|
|
||||||
expect(
|
|
||||||
normalizeEvents({
|
|
||||||
batch_id: ID,
|
|
||||||
install_id: "bad\u0000install",
|
|
||||||
events: [{ name: "opened", ts: 1 }],
|
|
||||||
}),
|
|
||||||
).toEqual({ ok: false, status: 400, error: "invalid_install_id" });
|
|
||||||
|
|
||||||
expect(
|
const legacyInstall = normalizeBug(bugPayload({ install_id: "legacy-test-install" }));
|
||||||
normalizeEvents({
|
expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
|
||||||
batch_id: "not-a-uuid",
|
|
||||||
events: [{ name: "opened", timestamp_millis: 1 }],
|
const missingInstall = normalizeBug(bugPayload({ install_id: undefined }));
|
||||||
}),
|
expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
|
||||||
).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
|
|
||||||
expect(
|
expect(normalizeBug(bugPayload({ install_id: "bad\u0000install" }))).toEqual({
|
||||||
normalizeEvents({
|
ok: false,
|
||||||
batch_id: ID,
|
status: 400,
|
||||||
install_id: INSTALL_ID,
|
error: "invalid_install_id",
|
||||||
events: [{ name: "opened", timestamp_millis: 1, schema_version: 2 }],
|
});
|
||||||
}),
|
|
||||||
).toEqual({ ok: false, status: 400, error: "unsupported_schema_version" });
|
expect(normalizeBug(bugPayload({ id: "not-a-uuid" }))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
error: "invalid_id",
|
||||||
|
});
|
||||||
|
expect(normalizeBug(bugPayload({ schema_version: 2 }))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
error: "unsupported_schema_version",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,7 +175,7 @@ function chunkedJsonRequest(
|
|||||||
contentType = "application/json; charset=utf-8",
|
contentType = "application/json; charset=utf-8",
|
||||||
contentLength?: string,
|
contentLength?: string,
|
||||||
): Request {
|
): Request {
|
||||||
return new Request("https://example.test/v1/events", {
|
return new Request("https://example.test/v1/bugs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": contentType,
|
"content-type": contentType,
|
||||||
@@ -230,24 +190,8 @@ function chunkedJsonRequest(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function crashPayload(diagnosticsEnabled: unknown): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
id: ID,
|
|
||||||
install_id: INSTALL_ID,
|
|
||||||
app_version: "1.0",
|
|
||||||
platform: "test",
|
|
||||||
exception_type: "ExampleError",
|
|
||||||
exception_message: "message",
|
|
||||||
stack_trace: "stack",
|
|
||||||
occurred_at: 1,
|
|
||||||
diagnostics_enabled: diagnosticsEnabled,
|
|
||||||
schema_version: 1,
|
|
||||||
breadcrumbs: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
return {
|
const payload: Record<string, unknown> = {
|
||||||
id: ID,
|
id: ID,
|
||||||
install_id: INSTALL_ID,
|
install_id: INSTALL_ID,
|
||||||
app_version: "1.0",
|
app_version: "1.0",
|
||||||
@@ -259,8 +203,12 @@ function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unk
|
|||||||
contact: "",
|
contact: "",
|
||||||
logs: "",
|
logs: "",
|
||||||
device: {},
|
device: {},
|
||||||
breadcrumbs: [],
|
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
// An explicit `undefined` override omits the key entirely (simulating a missing field).
|
||||||
|
for (const key of Object.keys(overrides)) {
|
||||||
|
if (overrides[key] === undefined) delete payload[key];
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,8 @@ import { env, exports } from "cloudflare:workers";
|
|||||||
import { createExecutionContext } from "cloudflare:test";
|
import { createExecutionContext } from "cloudflare:test";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import worker from "../src/index";
|
import worker from "../src/index";
|
||||||
import type {
|
import type { NormalizedBugPayload } from "../src/input";
|
||||||
NormalizedBugPayload,
|
import { type DiagnosticsEnv, runRetention, storeBug } from "../src/storage";
|
||||||
NormalizedCrashPayload,
|
|
||||||
NormalizedEventsPayload,
|
|
||||||
} from "../src/input";
|
|
||||||
import {
|
|
||||||
type DiagnosticsEnv,
|
|
||||||
runRetention,
|
|
||||||
storeBug,
|
|
||||||
storeCrash,
|
|
||||||
storeEvents,
|
|
||||||
} from "../src/storage";
|
|
||||||
|
|
||||||
const INSTALL_ID = "10000000-0000-4000-8000-000000000000";
|
const INSTALL_ID = "10000000-0000-4000-8000-000000000000";
|
||||||
|
|
||||||
@@ -35,7 +25,7 @@ describe("diagnostics Worker", () => {
|
|||||||
expect(unknown.status).toBe(404);
|
expect(unknown.status).toBe(404);
|
||||||
|
|
||||||
const unauthorized = await exports.default.fetch(
|
const unauthorized = await exports.default.fetch(
|
||||||
jsonRequest("/v1/events", eventPayload(uuid(1)), "wrong-key"),
|
jsonRequest("/v1/bugs", bugPayload(uuid(1), "logs"), "wrong-key"),
|
||||||
);
|
);
|
||||||
expect(unauthorized.status).toBe(401);
|
expect(unauthorized.status).toBe(401);
|
||||||
expect(await unauthorized.json()).toEqual({ error: "unauthorized" });
|
expect(await unauthorized.json()).toEqual({ error: "unauthorized" });
|
||||||
@@ -44,7 +34,7 @@ describe("diagnostics Worker", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const preflight = await exports.default.fetch(
|
const preflight = await exports.default.fetch(
|
||||||
new Request("https://diagnostics.test/v1/events", { method: "OPTIONS" }),
|
new Request("https://diagnostics.test/v1/bugs", { method: "OPTIONS" }),
|
||||||
);
|
);
|
||||||
expect(preflight.status).toBe(204);
|
expect(preflight.status).toBe(204);
|
||||||
expect(preflight.headers.get("access-control-allow-origin")).toBeNull();
|
expect(preflight.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
@@ -68,7 +58,7 @@ describe("diagnostics Worker", () => {
|
|||||||
const context = createExecutionContext();
|
const context = createExecutionContext();
|
||||||
|
|
||||||
const response = await worker.fetch(
|
const response = await worker.fetch(
|
||||||
jsonRequest("/v1/events", eventPayload(uuid(3)), "wrong-key", "198.51.100.3"),
|
jsonRequest("/v1/bugs", bugPayload(uuid(3), "logs"), "wrong-key", "198.51.100.3"),
|
||||||
limitedEnv,
|
limitedEnv,
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
@@ -80,7 +70,7 @@ describe("diagnostics Worker", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns structured errors for invalid bodies and asynchronous storage failures", async () => {
|
it("returns structured errors for invalid bodies and asynchronous storage failures", async () => {
|
||||||
const invalid = await exports.default.fetch(jsonRequest("/v1/events", null));
|
const invalid = await exports.default.fetch(jsonRequest("/v1/bugs", null));
|
||||||
expect(invalid.status).toBe(400);
|
expect(invalid.status).toBe(400);
|
||||||
expect(await invalid.json()).toEqual({ error: "invalid_body" });
|
expect(await invalid.json()).toEqual({ error: "invalid_body" });
|
||||||
|
|
||||||
@@ -92,6 +82,8 @@ describe("diagnostics Worker", () => {
|
|||||||
};
|
};
|
||||||
const rejectingDatabase = {
|
const rejectingDatabase = {
|
||||||
prepare: () => statement,
|
prepare: () => statement,
|
||||||
|
batch: async () => Promise.reject(rejection),
|
||||||
|
withSession: () => ({ prepare: () => statement }),
|
||||||
} as unknown as D1Database;
|
} as unknown as D1Database;
|
||||||
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
||||||
|
|
||||||
@@ -106,7 +98,7 @@ describe("diagnostics Worker", () => {
|
|||||||
|
|
||||||
const ingestContext = createExecutionContext();
|
const ingestContext = createExecutionContext();
|
||||||
const failedIngest = await worker.fetch(
|
const failedIngest = await worker.fetch(
|
||||||
jsonRequest("/v1/events", eventPayload(uuid(2)), env.INGEST_KEY, "198.51.100.2"),
|
jsonRequest("/v1/bugs", bugPayload(uuid(2), "logs"), env.INGEST_KEY, "198.51.100.2"),
|
||||||
rejectingEnv,
|
rejectingEnv,
|
||||||
ingestContext,
|
ingestContext,
|
||||||
);
|
);
|
||||||
@@ -114,101 +106,6 @@ describe("diagnostics Worker", () => {
|
|||||||
expect(await failedIngest.json()).toEqual({ error: "internal" });
|
expect(await failedIngest.json()).toEqual({ error: "internal" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deduplicates event batches using the client batch ID", async () => {
|
|
||||||
const id = uuid(10);
|
|
||||||
const first = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
|
|
||||||
const second = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
|
|
||||||
|
|
||||||
expect(first.status).toBe(202);
|
|
||||||
expect(await first.json()).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
id,
|
|
||||||
stored: 1,
|
|
||||||
duplicate: false,
|
|
||||||
});
|
|
||||||
expect(second.status).toBe(202);
|
|
||||||
expect(await second.json()).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
id,
|
|
||||||
stored: 0,
|
|
||||||
duplicate: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const row = await env.DB.prepare(
|
|
||||||
"SELECT event_count AS eventCount, payload_json AS payloadJson FROM event_batches WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.first<{ eventCount: number; payloadJson: string }>();
|
|
||||||
expect(row?.eventCount).toBe(1);
|
|
||||||
expect(JSON.parse(row?.payloadJson ?? "null")).toEqual([
|
|
||||||
{
|
|
||||||
name: "app_open",
|
|
||||||
timestampMillis: 1,
|
|
||||||
properties: { screen: "home" },
|
|
||||||
schemaVersion: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps D1 idempotency when the optional analytics index is enabled", async () => {
|
|
||||||
const points: AnalyticsEngineDataPoint[] = [];
|
|
||||||
const analytics = {
|
|
||||||
writeDataPoint: (point: AnalyticsEngineDataPoint) => points.push(point),
|
|
||||||
} as AnalyticsEngineDataset;
|
|
||||||
const analyticsEnv: DiagnosticsEnv = { ...env, AE: analytics };
|
|
||||||
const payload: NormalizedEventsPayload = {
|
|
||||||
batchId: uuid(11),
|
|
||||||
installId: INSTALL_ID,
|
|
||||||
appVersion: "1.0",
|
|
||||||
platform: "test",
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
name: "indexed",
|
|
||||||
timestampMillis: 1,
|
|
||||||
properties: {},
|
|
||||||
schemaVersion: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: false, stored: 1 });
|
|
||||||
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: true, stored: 0 });
|
|
||||||
expect(points).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the accepted crash blob when a duplicate request arrives", async () => {
|
|
||||||
const id = uuid(20);
|
|
||||||
const first = await exports.default.fetch(
|
|
||||||
jsonRequest("/v1/crashes", crashPayload(id, "first stack")),
|
|
||||||
);
|
|
||||||
const second = await exports.default.fetch(
|
|
||||||
jsonRequest("/v1/crashes", crashPayload(id, "second stack")),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(first.status).toBe(202);
|
|
||||||
const firstBody = await first.json<{ fingerprint: string }>();
|
|
||||||
expect(firstBody).toMatchObject({ ok: true, id, duplicate: false });
|
|
||||||
expect(second.status).toBe(202);
|
|
||||||
const secondBody = await second.json<{ fingerprint: string }>();
|
|
||||||
expect(secondBody).toMatchObject({ ok: true, id, duplicate: true });
|
|
||||||
|
|
||||||
const row = await env.DB.prepare(
|
|
||||||
`SELECT stack_r2_key AS stackKey, breadcrumbs_json AS breadcrumbsJson,
|
|
||||||
fingerprint
|
|
||||||
FROM crashes WHERE id = ?`,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.first<{ stackKey: string; breadcrumbsJson: string; fingerprint: string }>();
|
|
||||||
expect(row?.stackKey).toMatch(new RegExp(`^crashes/${id}/[0-9a-f-]+/stack\\.txt$`));
|
|
||||||
expect(firstBody.fingerprint).toBe(row?.fingerprint);
|
|
||||||
expect(secondBody.fingerprint).toBe(row?.fingerprint);
|
|
||||||
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([]);
|
|
||||||
expect(await (await env.BLOBS.get(row?.stackKey ?? "missing"))?.text()).toBe("first stack");
|
|
||||||
|
|
||||||
const objects = await env.BLOBS.list({ prefix: `crashes/${id}/` });
|
|
||||||
expect(objects.objects.map((object) => object.key)).toEqual([row?.stackKey]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores bug metadata as JSON and cleans the duplicate upload attempt", async () => {
|
it("stores bug metadata as JSON and cleans the duplicate upload attempt", async () => {
|
||||||
const id = uuid(30);
|
const id = uuid(30);
|
||||||
const payload = bugPayload(id, "first logs");
|
const payload = bugPayload(id, "first logs");
|
||||||
@@ -224,7 +121,7 @@ describe("diagnostics Worker", () => {
|
|||||||
|
|
||||||
const row = await env.DB.prepare(
|
const row = await env.DB.prepare(
|
||||||
`SELECT occurred_at AS occurredAt, logs_r2_key AS logsKey,
|
`SELECT occurred_at AS occurredAt, logs_r2_key AS logsKey,
|
||||||
device_json AS deviceJson, breadcrumbs_json AS breadcrumbsJson
|
device_json AS deviceJson
|
||||||
FROM bugs WHERE id = ?`,
|
FROM bugs WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -232,7 +129,6 @@ describe("diagnostics Worker", () => {
|
|||||||
occurredAt: number;
|
occurredAt: number;
|
||||||
logsKey: string;
|
logsKey: string;
|
||||||
deviceJson: string;
|
deviceJson: string;
|
||||||
breadcrumbsJson: string;
|
|
||||||
}>();
|
}>();
|
||||||
expect(row?.occurredAt).toBe(3);
|
expect(row?.occurredAt).toBe(3);
|
||||||
expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({
|
expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({
|
||||||
@@ -242,9 +138,6 @@ describe("diagnostics Worker", () => {
|
|||||||
network: "offline",
|
network: "offline",
|
||||||
batteryLevel: "90%",
|
batteryLevel: "90%",
|
||||||
});
|
});
|
||||||
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([
|
|
||||||
{ name: "opened", timestampMillis: 2, properties: {} },
|
|
||||||
]);
|
|
||||||
expect(await (await env.BLOBS.get(row?.logsKey ?? "missing"))?.text()).toBe("first logs");
|
expect(await (await env.BLOBS.get(row?.logsKey ?? "missing"))?.text()).toBe("first logs");
|
||||||
|
|
||||||
const objects = await env.BLOBS.list({ prefix: `bugs/${id}/` });
|
const objects = await env.BLOBS.list({ prefix: `bugs/${id}/` });
|
||||||
@@ -252,9 +145,7 @@ describe("diagnostics Worker", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("acknowledges known report IDs without touching an unavailable blob store", async () => {
|
it("acknowledges known report IDs without touching an unavailable blob store", async () => {
|
||||||
const crash = normalizedCrash(uuid(31), "accepted stack");
|
|
||||||
const bug = normalizedBug(uuid(32), "accepted logs");
|
const bug = normalizedBug(uuid(32), "accepted logs");
|
||||||
const firstCrash = await storeCrash(crash, env);
|
|
||||||
await storeBug(bug, env);
|
await storeBug(bug, env);
|
||||||
let blobWrites = 0;
|
let blobWrites = 0;
|
||||||
const unavailableBlobs = {
|
const unavailableBlobs = {
|
||||||
@@ -265,14 +156,6 @@ describe("diagnostics Worker", () => {
|
|||||||
} as unknown as R2Bucket;
|
} as unknown as R2Bucket;
|
||||||
const unavailableEnv: DiagnosticsEnv = { ...env, BLOBS: unavailableBlobs };
|
const unavailableEnv: DiagnosticsEnv = { ...env, BLOBS: unavailableBlobs };
|
||||||
|
|
||||||
await expect(
|
|
||||||
storeCrash({ ...crash, stackTrace: "retry stack" }, unavailableEnv),
|
|
||||||
).resolves.toEqual({
|
|
||||||
id: crash.id,
|
|
||||||
duplicate: true,
|
|
||||||
stored: 0,
|
|
||||||
fingerprint: firstCrash.fingerprint,
|
|
||||||
});
|
|
||||||
await expect(
|
await expect(
|
||||||
storeBug({ ...bug, logs: "retry logs" }, unavailableEnv),
|
storeBug({ ...bug, logs: "retry logs" }, unavailableEnv),
|
||||||
).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 });
|
).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 });
|
||||||
@@ -286,88 +169,56 @@ describe("diagnostics Worker", () => {
|
|||||||
async () => Promise.reject(rejection),
|
async () => Promise.reject(rejection),
|
||||||
);
|
);
|
||||||
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
||||||
const crashId = uuid(33);
|
|
||||||
const bugId = uuid(34);
|
const bugId = uuid(34);
|
||||||
|
|
||||||
const crashResponse = await worker.fetch(
|
|
||||||
jsonRequest("/v1/crashes", crashPayload(crashId, "orphan candidate"), env.INGEST_KEY, "198.51.100.33"),
|
|
||||||
rejectingEnv,
|
|
||||||
createExecutionContext(),
|
|
||||||
);
|
|
||||||
const bugResponse = await worker.fetch(
|
const bugResponse = await worker.fetch(
|
||||||
jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"),
|
jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"),
|
||||||
rejectingEnv,
|
rejectingEnv,
|
||||||
createExecutionContext(),
|
createExecutionContext(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(crashResponse.status).toBe(500);
|
|
||||||
expect(await crashResponse.json()).toEqual({ error: "internal" });
|
|
||||||
expect(bugResponse.status).toBe(500);
|
expect(bugResponse.status).toBe(500);
|
||||||
expect(await bugResponse.json()).toEqual({ error: "internal" });
|
expect(await bugResponse.json()).toEqual({ error: "internal" });
|
||||||
expect((await env.BLOBS.list({ prefix: `crashes/${crashId}/` })).objects).toEqual([]);
|
|
||||||
expect((await env.BLOBS.list({ prefix: `bugs/${bugId}/` })).objects).toEqual([]);
|
expect((await env.BLOBS.list({ prefix: `bugs/${bugId}/` })).objects).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes expired rows and their exact R2 objects while preserving current data", async () => {
|
it("removes expired rows and their exact R2 objects while preserving current data", async () => {
|
||||||
const oldEventId = uuid(40);
|
|
||||||
const oldCrashId = uuid(41);
|
|
||||||
const oldBugId = uuid(42);
|
const oldBugId = uuid(42);
|
||||||
const currentEventId = uuid(43);
|
const currentBugId = uuid(43);
|
||||||
const oldCrashKey = `crashes/${oldCrashId}/retention/stack.txt`;
|
|
||||||
const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`;
|
const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`;
|
||||||
const oldReceivedAt = Date.now() - 100 * 86_400_000;
|
const oldReceivedAt = Date.now() - 100 * 86_400_000;
|
||||||
|
|
||||||
await Promise.all([
|
await env.BLOBS.put(oldBugKey, "expired logs");
|
||||||
env.BLOBS.put(oldCrashKey, "expired crash"),
|
|
||||||
env.BLOBS.put(oldBugKey, "expired logs"),
|
|
||||||
]);
|
|
||||||
await env.DB.batch([
|
await env.DB.batch([
|
||||||
env.DB.prepare(
|
|
||||||
`INSERT INTO event_batches
|
|
||||||
(id, received_at, install_id, app_version, platform, event_count, payload_json)
|
|
||||||
VALUES (?, ?, ?, '', '', 1, '[]')`,
|
|
||||||
).bind(oldEventId, oldReceivedAt, INSTALL_ID),
|
|
||||||
env.DB.prepare(
|
|
||||||
`INSERT INTO event_batches
|
|
||||||
(id, received_at, install_id, app_version, platform, event_count, payload_json)
|
|
||||||
VALUES (?, ?, ?, '', '', 1, '[]')`,
|
|
||||||
).bind(currentEventId, Date.now(), INSTALL_ID),
|
|
||||||
env.DB.prepare(
|
|
||||||
`INSERT INTO crashes
|
|
||||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
|
||||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
|
||||||
stack_r2_key, breadcrumbs_json, schema_version)
|
|
||||||
VALUES (?, ?, ?, ?, '', '', 'Error', '', 'fingerprint', 1, ?, '[]', 1)`,
|
|
||||||
).bind(oldCrashId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldCrashKey),
|
|
||||||
env.DB.prepare(
|
env.DB.prepare(
|
||||||
`INSERT INTO bugs
|
`INSERT INTO bugs
|
||||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||||
what_happened, expected, steps, contact, logs_r2_key,
|
what_happened, expected, steps, contact, logs_r2_key,
|
||||||
device_json, breadcrumbs_json, status, schema_version)
|
device_json, status, schema_version)
|
||||||
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', '[]', 'open', 1)`,
|
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', 'open', 1)`,
|
||||||
).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey),
|
).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey),
|
||||||
|
env.DB.prepare(
|
||||||
|
`INSERT INTO bugs
|
||||||
|
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||||
|
what_happened, expected, steps, contact, logs_r2_key,
|
||||||
|
device_json, status, schema_version)
|
||||||
|
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', NULL, '{}', 'open', 1)`,
|
||||||
|
).bind(currentBugId, Date.now(), Date.now(), INSTALL_ID),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await runRetention(env);
|
await runRetention(env);
|
||||||
|
|
||||||
for (const [table, id] of [
|
expect(
|
||||||
["event_batches", oldEventId],
|
await env.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(oldBugId).first(),
|
||||||
["crashes", oldCrashId],
|
).toBeNull();
|
||||||
["bugs", oldBugId],
|
|
||||||
] as const) {
|
|
||||||
const row = await env.DB.prepare(`SELECT id FROM ${table} WHERE id = ?`).bind(id).first();
|
|
||||||
expect(row).toBeNull();
|
|
||||||
}
|
|
||||||
expect(await env.BLOBS.head(oldCrashKey)).toBeNull();
|
|
||||||
expect(await env.BLOBS.head(oldBugKey)).toBeNull();
|
expect(await env.BLOBS.head(oldBugKey)).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
await env.DB.prepare("SELECT id FROM event_batches WHERE id = ?").bind(currentEventId).first(),
|
await env.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(currentBugId).first(),
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bounds a full retention run below the D1 per-invocation query limit", async () => {
|
it("bounds a full retention run below the D1 per-invocation query limit", async () => {
|
||||||
let queryCount = 0;
|
let queryCount = 0;
|
||||||
let batchCalls = 0;
|
|
||||||
const blobDeleteBatchSizes: number[] = [];
|
const blobDeleteBatchSizes: number[] = [];
|
||||||
const rows = Array.from({ length: 900 }, (_, index) => ({
|
const rows = Array.from({ length: 900 }, (_, index) => ({
|
||||||
id: `expired-${index}`,
|
id: `expired-${index}`,
|
||||||
@@ -381,17 +232,17 @@ describe("diagnostics Worker", () => {
|
|||||||
queryCount += 1;
|
queryCount += 1;
|
||||||
return d1Result(rows, 0);
|
return d1Result(rows, 0);
|
||||||
},
|
},
|
||||||
|
run: async () => {
|
||||||
|
queryCount += 1;
|
||||||
|
return d1Result([], rows.length);
|
||||||
|
},
|
||||||
|
first: async () => {
|
||||||
|
queryCount += 1;
|
||||||
|
return { count: rows.length };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return statement;
|
return statement;
|
||||||
},
|
},
|
||||||
batch: async (statements: D1PreparedStatement[]) => {
|
|
||||||
batchCalls += 1;
|
|
||||||
queryCount += statements.length;
|
|
||||||
if (batchCalls === 9) {
|
|
||||||
return statements.map(() => d1Result([{ count: 1 }], 0));
|
|
||||||
}
|
|
||||||
return statements.map((_, index) => d1Result([], index === 0 ? 1_000 : 900));
|
|
||||||
},
|
|
||||||
} as unknown as D1Database;
|
} as unknown as D1Database;
|
||||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
const blobs = {
|
const blobs = {
|
||||||
@@ -406,9 +257,10 @@ describe("diagnostics Worker", () => {
|
|||||||
warning.mockRestore();
|
warning.mockRestore();
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(queryCount).toBe(43);
|
// Eight passes (one SELECT + one DELETE each) plus the final backlog SELECT.
|
||||||
expect(blobDeleteBatchSizes).toHaveLength(16);
|
expect(queryCount).toBe(17);
|
||||||
expect(Math.max(...blobDeleteBatchSizes)).toBe(1_000);
|
expect(blobDeleteBatchSizes).toHaveLength(8);
|
||||||
|
expect(Math.max(...blobDeleteBatchSizes)).toBe(900);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converges an expired report backlog across bounded retention runs", async () => {
|
it("converges an expired report backlog across bounded retention runs", async () => {
|
||||||
@@ -424,13 +276,13 @@ describe("diagnostics Worker", () => {
|
|||||||
CROSS JOIN digits AS ones
|
CROSS JOIN digits AS ones
|
||||||
WHERE thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value < 7201
|
WHERE thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value < 7201
|
||||||
)
|
)
|
||||||
INSERT INTO crashes (
|
INSERT INTO bugs (
|
||||||
id, received_at, occurred_at, install_id, app_version, platform,
|
id, received_at, occurred_at, install_id, app_version, platform,
|
||||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
what_happened, expected, steps, contact, logs_r2_key,
|
||||||
stack_r2_key, breadcrumbs_json, schema_version
|
device_json, status, schema_version
|
||||||
)
|
)
|
||||||
SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '',
|
SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '',
|
||||||
'Error', '', 'fingerprint-' || value, 0, NULL, '[]', 1
|
'failed', 'worked', '', '', NULL, '{}', 'open', 1
|
||||||
FROM sequence`,
|
FROM sequence`,
|
||||||
)
|
)
|
||||||
.bind(oldReceivedAt, oldReceivedAt, INSTALL_ID)
|
.bind(oldReceivedAt, oldReceivedAt, INSTALL_ID)
|
||||||
@@ -440,13 +292,13 @@ describe("diagnostics Worker", () => {
|
|||||||
try {
|
try {
|
||||||
await runRetention(env);
|
await runRetention(env);
|
||||||
const afterFirstRun = await env.DB.prepare(
|
const afterFirstRun = await env.DB.prepare(
|
||||||
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'",
|
"SELECT COUNT(*) AS count FROM bugs WHERE id LIKE 'retention-backlog-%'",
|
||||||
).first<{ count: number }>();
|
).first<{ count: number }>();
|
||||||
expect(afterFirstRun?.count).toBe(1);
|
expect(afterFirstRun?.count).toBe(1);
|
||||||
|
|
||||||
await runRetention(env);
|
await runRetention(env);
|
||||||
const afterSecondRun = await env.DB.prepare(
|
const afterSecondRun = await env.DB.prepare(
|
||||||
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'",
|
"SELECT COUNT(*) AS count FROM bugs WHERE id LIKE 'retention-backlog-%'",
|
||||||
).first<{ count: number }>();
|
).first<{ count: number }>();
|
||||||
expect(afterSecondRun?.count).toBe(0);
|
expect(afterSecondRun?.count).toBe(0);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -482,39 +334,6 @@ function healthRequest(key = env.INGEST_KEY, source = "198.51.100.1"): Request {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventPayload(batchId: string): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
batchId,
|
|
||||||
installId: INSTALL_ID,
|
|
||||||
appVersion: "1.0",
|
|
||||||
platform: "test",
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
name: "app_open",
|
|
||||||
timestampMillis: 1,
|
|
||||||
properties: { screen: "home" },
|
|
||||||
schemaVersion: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function crashPayload(id: string, stackTrace: string): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
installId: INSTALL_ID,
|
|
||||||
appVersion: "1.0",
|
|
||||||
platform: "test",
|
|
||||||
exceptionType: "TestError",
|
|
||||||
exceptionMessage: "failed",
|
|
||||||
stackTrace,
|
|
||||||
timestampMillis: 2,
|
|
||||||
diagnosticsEnabledAtCapture: true,
|
|
||||||
breadcrumbs: [],
|
|
||||||
schemaVersion: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bugPayload(id: string, logs: string): Record<string, unknown> {
|
function bugPayload(id: string, logs: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -535,23 +354,6 @@ function bugPayload(id: string, logs: string): Record<string, unknown> {
|
|||||||
network: "offline",
|
network: "offline",
|
||||||
batteryLevel: "90%",
|
batteryLevel: "90%",
|
||||||
},
|
},
|
||||||
breadcrumbs: [{ name: "opened", timestampMillis: 2, properties: {} }],
|
|
||||||
schemaVersion: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizedCrash(id: string, stackTrace: string): NormalizedCrashPayload {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
installId: INSTALL_ID,
|
|
||||||
appVersion: "1.0",
|
|
||||||
platform: "test",
|
|
||||||
exceptionType: "TestError",
|
|
||||||
exceptionMessage: "failed",
|
|
||||||
stackTrace,
|
|
||||||
occurredAt: 2,
|
|
||||||
diagnosticsEnabledAtCapture: true,
|
|
||||||
breadcrumbs: [],
|
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -575,7 +377,6 @@ function normalizedBug(id: string, logs: string): NormalizedBugPayload {
|
|||||||
network: "offline",
|
network: "offline",
|
||||||
batteryLevel: "90%",
|
batteryLevel: "90%",
|
||||||
},
|
},
|
||||||
breadcrumbs: [],
|
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// Generated by Wrangler by running `wrangler types` (hash: e2953336d40e96c4b5125a5de01f7cac)
|
// Generated by Wrangler by running `wrangler types` (hash: 9c27cfd9219ba0d4efc153db78cad4c3)
|
||||||
// Runtime types generated with workerd@1.20260708.1 2026-07-14 nodejs_compat
|
// Runtime types generated with workerd@1.20260708.1 2026-07-14 nodejs_compat
|
||||||
interface __BaseEnv_Env {
|
interface __BaseEnv_Env {
|
||||||
BLOBS: R2Bucket;
|
BLOBS: R2Bucket;
|
||||||
@@ -7,7 +7,6 @@ interface __BaseEnv_Env {
|
|||||||
INSTALL_RATE_LIMITER: RateLimit;
|
INSTALL_RATE_LIMITER: RateLimit;
|
||||||
SOURCE_RATE_LIMITER: RateLimit;
|
SOURCE_RATE_LIMITER: RateLimit;
|
||||||
MAX_BODY_BYTES: "262144";
|
MAX_BODY_BYTES: "262144";
|
||||||
MAX_EVENTS_PER_BATCH: "50";
|
|
||||||
RETENTION_DAYS: "90";
|
RETENTION_DAYS: "90";
|
||||||
}
|
}
|
||||||
declare namespace Cloudflare {
|
declare namespace Cloudflare {
|
||||||
@@ -21,7 +20,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
|
|||||||
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
||||||
};
|
};
|
||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "MAX_BODY_BYTES" | "MAX_EVENTS_PER_BATCH" | "RETENTION_DAYS">> {}
|
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "MAX_BODY_BYTES" | "RETENTION_DAYS">> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin runtime types
|
// Begin runtime types
|
||||||
|
|||||||
@@ -47,13 +47,8 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Optional: bind Analytics Engine as `AE` when event volume justifies it.
|
|
||||||
// "analytics_engine_datasets": [
|
|
||||||
// { "binding": "AE", "dataset": "vnidrop_events" },
|
|
||||||
// ],
|
|
||||||
"vars": {
|
"vars": {
|
||||||
"MAX_BODY_BYTES": "262144",
|
"MAX_BODY_BYTES": "262144",
|
||||||
"MAX_EVENTS_PER_BATCH": "50",
|
|
||||||
"RETENTION_DAYS": "90",
|
"RETENTION_DAYS": "90",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.gradle.api.tasks.PathSensitivity
|
|||||||
import org.gradle.api.tasks.TaskAction
|
import org.gradle.api.tasks.TaskAction
|
||||||
import org.gradle.jvm.tasks.Jar
|
import org.gradle.jvm.tasks.Jar
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
abstract class VerifyHostCargoTaskSelection : DefaultTask() {
|
abstract class VerifyHostCargoTaskSelection : DefaultTask() {
|
||||||
@get:Input
|
@get:Input
|
||||||
@@ -49,7 +50,7 @@ val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
|
|||||||
.orElse(Variant.Debug)
|
.orElse(Variant.Debug)
|
||||||
|
|
||||||
// Compile-time switches (gradle.properties or -P…).
|
// Compile-time switches (gradle.properties or -P…).
|
||||||
// included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack.
|
// included=false: user-initiated bug reports use a NoOp transport (never sent).
|
||||||
// endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
|
// endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
|
||||||
val diagnosticsIncluded: Boolean =
|
val diagnosticsIncluded: Boolean =
|
||||||
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false
|
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false
|
||||||
@@ -110,6 +111,55 @@ val generateDiagnosticsBuildConfig by tasks.registering {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App-wide public constants (privacy policy URL, …) from the shared app.properties,
|
||||||
|
// so Apple and KMP read one source of truth instead of hardcoding values.
|
||||||
|
val appProperties = Properties().apply {
|
||||||
|
rootProject.file("app.properties").inputStream().use(::load)
|
||||||
|
}
|
||||||
|
val privacyPolicyUrl: String = appProperties.getProperty("PRIVACY_POLICY_URL")?.trim().orEmpty()
|
||||||
|
check(privacyPolicyUrl.isNotEmpty()) { "PRIVACY_POLICY_URL must be set in app.properties" }
|
||||||
|
|
||||||
|
val appConfigDir = layout.buildDirectory.dir("generated/appconfig/commonMain/kotlin")
|
||||||
|
val generateAppConfig by tasks.registering {
|
||||||
|
group = "build"
|
||||||
|
description = "Generates AppConfig from the shared app.properties"
|
||||||
|
val outputDir = appConfigDir
|
||||||
|
val privacy = privacyPolicyUrl
|
||||||
|
inputs.property("PRIVACY_POLICY_URL", privacy)
|
||||||
|
outputs.dir(outputDir)
|
||||||
|
doLast {
|
||||||
|
val packageDir = outputDir.get().asFile.resolve("com/vnidrop/app")
|
||||||
|
packageDir.mkdirs()
|
||||||
|
fun esc(value: String): String = buildString {
|
||||||
|
for (ch in value) {
|
||||||
|
when (ch) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
'"' -> append("\\\"")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> append("\\r")
|
||||||
|
'\t' -> append("\\t")
|
||||||
|
'$' -> append("\\$")
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packageDir.resolve("AppConfig.kt").writeText(
|
||||||
|
"""
|
||||||
|
|package com.vnidrop.app
|
||||||
|
|
|
||||||
|
|/**
|
||||||
|
| * Generated by shared/build.gradle.kts from the shared app.properties.
|
||||||
|
| * Single source of truth for app-wide public constants (also used by Apple).
|
||||||
|
| */
|
||||||
|
|object AppConfig {
|
||||||
|
| const val PRIVACY_POLICY_URL: String = "${esc(privacy)}"
|
||||||
|
|}
|
||||||
|
|
|
||||||
|
""".trimMargin(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
androidTarget {
|
androidTarget {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
@@ -122,6 +172,7 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain {
|
commonMain {
|
||||||
kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig))
|
kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig))
|
||||||
|
kotlin.srcDir(files(appConfigDir).builtBy(generateAppConfig))
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
|
<application>
|
||||||
|
<service
|
||||||
|
android:name="com.vnidrop.app.background.BackgroundSharingService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="connectedDevice" />
|
||||||
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.os.Build
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import com.vnidrop.app.background.AndroidBackgroundSharingController
|
||||||
import com.vnidrop.app.core.rememberFileSystemService
|
import com.vnidrop.app.core.rememberFileSystemService
|
||||||
import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService
|
import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService
|
||||||
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
||||||
@@ -18,7 +19,8 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitati
|
|||||||
val context = activity.applicationContext
|
val context = activity.applicationContext
|
||||||
val fileSystemService = rememberFileSystemService()
|
val fileSystemService = rememberFileSystemService()
|
||||||
val notificationService = rememberAndroidLocalNotificationService(activity)
|
val notificationService = rememberAndroidLocalNotificationService(activity)
|
||||||
return remember(context, fileSystemService, notificationService) {
|
val backgroundSharingController = remember(context) { AndroidBackgroundSharingController(context) }
|
||||||
|
return remember(context, fileSystemService, notificationService, backgroundSharingController) {
|
||||||
AppDependencies(
|
AppDependencies(
|
||||||
environment = PlatformEnvironment(
|
environment = PlatformEnvironment(
|
||||||
name = "Android ${Build.VERSION.SDK_INT}",
|
name = "Android ${Build.VERSION.SDK_INT}",
|
||||||
@@ -30,6 +32,7 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitati
|
|||||||
deviceInfoProvider = AndroidDeviceInfoProvider(context),
|
deviceInfoProvider = AndroidDeviceInfoProvider(context),
|
||||||
fileSystemService = fileSystemService,
|
fileSystemService = fileSystemService,
|
||||||
localNotificationService = notificationService,
|
localNotificationService = notificationService,
|
||||||
|
backgroundSharingController = backgroundSharingController,
|
||||||
externalInvitations = externalInvitations,
|
externalInvitations = externalInvitations,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.vnidrop.app.background
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
class AndroidBackgroundSharingController(
|
||||||
|
private val context: Context,
|
||||||
|
) : BackgroundSharingController {
|
||||||
|
override fun setSharingActive(active: Boolean) {
|
||||||
|
val intent = Intent(context, BackgroundSharingService::class.java)
|
||||||
|
if (active) {
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
} else {
|
||||||
|
context.stopService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.vnidrop.app.background
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.IBinder
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import vnidrop.shared.generated.resources.Res
|
||||||
|
import vnidrop.shared.generated.resources.notifications_background_sharing_body
|
||||||
|
import vnidrop.shared.generated.resources.notifications_background_sharing_channel
|
||||||
|
import vnidrop.shared.generated.resources.notifications_background_sharing_title
|
||||||
|
|
||||||
|
class BackgroundSharingService : Service() {
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
ensureNotificationChannel()
|
||||||
|
startForeground(NotificationId, createNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureNotificationChannel() {
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
ChannelId,
|
||||||
|
localizedString(Res.string.notifications_background_sharing_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotification(): Notification {
|
||||||
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val contentIntent = launchIntent?.let {
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
it,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Notification.Builder(this, ChannelId)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setContentTitle(localizedString(Res.string.notifications_background_sharing_title))
|
||||||
|
.setContentText(localizedString(Res.string.notifications_background_sharing_body))
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(contentIntent)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun localizedString(resource: org.jetbrains.compose.resources.StringResource): String =
|
||||||
|
runBlocking { getString(resource) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ChannelId = "vnidrop-background-sharing"
|
||||||
|
const val NotificationId = 3_427
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
import java.io.File
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
|
|
||||||
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
|
||||||
AndroidPendingCrashStore(appDataDir)
|
|
||||||
|
|
||||||
private class AndroidPendingCrashStore(
|
|
||||||
appDataDir: String,
|
|
||||||
) : PendingCrashStore {
|
|
||||||
private val directory = File(appDataDir, "diagnostics/crashes")
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun write(report: CrashReport) {
|
|
||||||
if (!isValidDiagnosticId(report.id)) return
|
|
||||||
directory.mkdirs()
|
|
||||||
val target = File(directory, "${report.id}.crash")
|
|
||||||
val temporary = File(directory, ".${report.id}.tmp")
|
|
||||||
val payload = CrashReportCodec.encode(report)
|
|
||||||
temporary.writeText(payload, StandardCharsets.UTF_8)
|
|
||||||
if (!temporary.renameTo(target)) {
|
|
||||||
target.writeText(payload, StandardCharsets.UTF_8)
|
|
||||||
temporary.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun list(): List<CrashReport> {
|
|
||||||
if (!directory.isDirectory) return emptyList()
|
|
||||||
return directory
|
|
||||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
|
||||||
.orEmpty()
|
|
||||||
.sortedByDescending { it.lastModified() }
|
|
||||||
.mapNotNull { file ->
|
|
||||||
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun delete(id: String) {
|
|
||||||
if (!isValidDiagnosticId(id)) return
|
|
||||||
File(directory, "$id.crash").delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
|
||||||
require(maxCount > 0) { "maxCount must be positive" }
|
|
||||||
if (!directory.isDirectory) return
|
|
||||||
directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") }
|
|
||||||
.orEmpty()
|
|
||||||
.forEach(File::delete)
|
|
||||||
val reports = directory
|
|
||||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
|
||||||
.orEmpty()
|
|
||||||
.mapNotNull { file ->
|
|
||||||
val report = runCatching {
|
|
||||||
CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8))
|
|
||||||
}.getOrNull()
|
|
||||||
if (report == null) {
|
|
||||||
file.delete()
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
file to report
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sortedByDescending { (_, report) -> report.timestampMillis }
|
|
||||||
reports.forEachIndexed { index, (file, report) ->
|
|
||||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
|
||||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
|
||||||
runCatching { onCrash(throwable) }
|
|
||||||
previous?.uncaughtException(thread, throwable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Auf NFC-Tag schreiben</string>
|
<string name="button_write_nfc">Auf NFC-Tag schreiben</string>
|
||||||
<string name="device_model_title">Gerätemodell</string>
|
<string name="device_model_title">Gerätemodell</string>
|
||||||
<string name="device_name_title">Gerätename</string>
|
<string name="device_name_title">Gerätename</string>
|
||||||
<string name="diagnostics_description">Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Die Freigabe von Diagnosedaten ist deaktiviert.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Die Freigabe von Diagnosedaten ist aktiviert.</string>
|
|
||||||
<string name="diagnostics_title">Diagnosedaten teilen</string>
|
|
||||||
<string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string>
|
<string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string>
|
||||||
<string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string>
|
<string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string>
|
||||||
<string name="error_destination_exists">Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.</string>
|
<string name="error_destination_exists">Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.</string>
|
||||||
@@ -120,6 +116,9 @@
|
|||||||
<string name="nav_settings">Einstellungen</string>
|
<string name="nav_settings">Einstellungen</string>
|
||||||
<string name="network_title">Netzwerk</string>
|
<string name="network_title">Netzwerk</string>
|
||||||
<string name="notifications_description">Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.</string>
|
<string name="notifications_description">Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.</string>
|
||||||
|
<string name="notifications_background_sharing_body">VniDrop kann Ihre Dateien im Hintergrund freigeben.</string>
|
||||||
|
<string name="notifications_background_sharing_channel">Aktive Übertragungen</string>
|
||||||
|
<string name="notifications_background_sharing_title">Freigabe im Hintergrund</string>
|
||||||
<string name="notifications_enabled_message">Mitteilungen aktiviert.</string>
|
<string name="notifications_enabled_message">Mitteilungen aktiviert.</string>
|
||||||
<string name="notifications_local_title">Mitteilungen erlauben</string>
|
<string name="notifications_local_title">Mitteilungen erlauben</string>
|
||||||
<string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string>
|
<string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Escribir en etiqueta NFC</string>
|
<string name="button_write_nfc">Escribir en etiqueta NFC</string>
|
||||||
<string name="device_model_title">Modelo del dispositivo</string>
|
<string name="device_model_title">Modelo del dispositivo</string>
|
||||||
<string name="device_name_title">Nombre del dispositivo</string>
|
<string name="device_name_title">Nombre del dispositivo</string>
|
||||||
<string name="diagnostics_description">Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen.</string>
|
|
||||||
<string name="diagnostics_disabled_message">El uso compartido de diagnósticos está desactivado.</string>
|
|
||||||
<string name="diagnostics_enabled_message">El uso compartido de diagnósticos está activado.</string>
|
|
||||||
<string name="diagnostics_title">Compartir diagnósticos</string>
|
|
||||||
<string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string>
|
<string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string>
|
||||||
<string name="error_device_info">No se pudo cargar la información del dispositivo.</string>
|
<string name="error_device_info">No se pudo cargar la información del dispositivo.</string>
|
||||||
<string name="error_destination_exists">Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.</string>
|
<string name="error_destination_exists">Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.</string>
|
||||||
@@ -120,6 +116,9 @@
|
|||||||
<string name="nav_settings">Ajustes</string>
|
<string name="nav_settings">Ajustes</string>
|
||||||
<string name="network_title">Red</string>
|
<string name="network_title">Red</string>
|
||||||
<string name="notifications_description">Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.</string>
|
<string name="notifications_description">Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.</string>
|
||||||
|
<string name="notifications_background_sharing_body">VniDrop está listo para compartir sus archivos en segundo plano.</string>
|
||||||
|
<string name="notifications_background_sharing_channel">Transferencias activas</string>
|
||||||
|
<string name="notifications_background_sharing_title">Compartiendo en segundo plano</string>
|
||||||
<string name="notifications_enabled_message">Notificaciones activadas.</string>
|
<string name="notifications_enabled_message">Notificaciones activadas.</string>
|
||||||
<string name="notifications_local_title">Permitir notificaciones</string>
|
<string name="notifications_local_title">Permitir notificaciones</string>
|
||||||
<string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string>
|
<string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Écrire sur un tag NFC</string>
|
<string name="button_write_nfc">Écrire sur un tag NFC</string>
|
||||||
<string name="device_model_title">Modèle de l’appareil</string>
|
<string name="device_model_title">Modèle de l’appareil</string>
|
||||||
<string name="device_name_title">Nom de l’appareil</string>
|
<string name="device_name_title">Nom de l’appareil</string>
|
||||||
<string name="diagnostics_description">Envoyer des rapports de plantage et des événements d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Le partage des diagnostics est désactivé.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Le partage des diagnostics est activé.</string>
|
|
||||||
<string name="diagnostics_title">Partager les diagnostics</string>
|
|
||||||
<string name="error_camera">L’accès à la caméra est nécessaire pour scanner un QR code.</string>
|
<string name="error_camera">L’accès à la caméra est nécessaire pour scanner un QR code.</string>
|
||||||
<string name="error_device_info">Impossible de charger les informations de l’appareil.</string>
|
<string name="error_device_info">Impossible de charger les informations de l’appareil.</string>
|
||||||
<string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string>
|
<string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string>
|
||||||
@@ -120,6 +116,9 @@
|
|||||||
<string name="nav_settings">Réglages</string>
|
<string name="nav_settings">Réglages</string>
|
||||||
<string name="network_title">Réseau</string>
|
<string name="network_title">Réseau</string>
|
||||||
<string name="notifications_description">Soyez averti de l’activité des transferts lorsque VniDrop est en arrière-plan.</string>
|
<string name="notifications_description">Soyez averti de l’activité des transferts lorsque VniDrop est en arrière-plan.</string>
|
||||||
|
<string name="notifications_background_sharing_body">VniDrop est prêt à partager vos fichiers en arrière-plan.</string>
|
||||||
|
<string name="notifications_background_sharing_channel">Transferts actifs</string>
|
||||||
|
<string name="notifications_background_sharing_title">Partage en arrière-plan</string>
|
||||||
<string name="notifications_enabled_message">Notifications activées.</string>
|
<string name="notifications_enabled_message">Notifications activées.</string>
|
||||||
<string name="notifications_local_title">Autoriser les notifications</string>
|
<string name="notifications_local_title">Autoriser les notifications</string>
|
||||||
<string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string>
|
<string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Scrivi su tag NFC</string>
|
<string name="button_write_nfc">Scrivi su tag NFC</string>
|
||||||
<string name="device_model_title">Modello del dispositivo</string>
|
<string name="device_model_title">Modello del dispositivo</string>
|
||||||
<string name="device_name_title">Nome del dispositivo</string>
|
<string name="device_name_title">Nome del dispositivo</string>
|
||||||
<string name="diagnostics_description">Invia report di arresto anomalo ed eventi d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.</string>
|
|
||||||
<string name="diagnostics_disabled_message">La condivisione dei dati diagnostici è disattivata.</string>
|
|
||||||
<string name="diagnostics_enabled_message">La condivisione dei dati diagnostici è attivata.</string>
|
|
||||||
<string name="diagnostics_title">Condividi dati diagnostici</string>
|
|
||||||
<string name="error_camera">Per scansionare un codice QR è necessario l’accesso alla fotocamera.</string>
|
<string name="error_camera">Per scansionare un codice QR è necessario l’accesso alla fotocamera.</string>
|
||||||
<string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string>
|
<string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string>
|
||||||
<string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente.</string>
|
<string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente.</string>
|
||||||
@@ -120,6 +116,9 @@
|
|||||||
<string name="nav_settings">Impostazioni</string>
|
<string name="nav_settings">Impostazioni</string>
|
||||||
<string name="network_title">Rete</string>
|
<string name="network_title">Rete</string>
|
||||||
<string name="notifications_description">Ricevi avvisi sull’attività dei trasferimenti quando VniDrop è in background.</string>
|
<string name="notifications_description">Ricevi avvisi sull’attività dei trasferimenti quando VniDrop è in background.</string>
|
||||||
|
<string name="notifications_background_sharing_body">VniDrop è pronto a condividere i tuoi file in background.</string>
|
||||||
|
<string name="notifications_background_sharing_channel">Trasferimenti attivi</string>
|
||||||
|
<string name="notifications_background_sharing_title">Condivisione in background</string>
|
||||||
<string name="notifications_enabled_message">Notifiche attivate.</string>
|
<string name="notifications_enabled_message">Notifiche attivate.</string>
|
||||||
<string name="notifications_local_title">Consenti le notifiche</string>
|
<string name="notifications_local_title">Consenti le notifiche</string>
|
||||||
<string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string>
|
<string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Naar NFC-tag schrijven</string>
|
<string name="button_write_nfc">Naar NFC-tag schrijven</string>
|
||||||
<string name="device_model_title">Apparaatmodel</string>
|
<string name="device_model_title">Apparaatmodel</string>
|
||||||
<string name="device_name_title">Apparaatnaam</string>
|
<string name="device_name_title">Apparaatnaam</string>
|
||||||
<string name="diagnostics_description">Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Het delen van diagnostische gegevens is uitgeschakeld.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Het delen van diagnostische gegevens is ingeschakeld.</string>
|
|
||||||
<string name="diagnostics_title">Diagnostische gegevens delen</string>
|
|
||||||
<string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string>
|
<string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string>
|
||||||
<string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string>
|
<string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string>
|
||||||
<string name="error_destination_exists">Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.</string>
|
<string name="error_destination_exists">Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.</string>
|
||||||
@@ -120,6 +116,9 @@
|
|||||||
<string name="nav_settings">Instellingen</string>
|
<string name="nav_settings">Instellingen</string>
|
||||||
<string name="network_title">Netwerk</string>
|
<string name="network_title">Netwerk</string>
|
||||||
<string name="notifications_description">Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.</string>
|
<string name="notifications_description">Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.</string>
|
||||||
|
<string name="notifications_background_sharing_body">VniDrop is klaar om uw bestanden op de achtergrond te delen.</string>
|
||||||
|
<string name="notifications_background_sharing_channel">Actieve overdrachten</string>
|
||||||
|
<string name="notifications_background_sharing_title">Delen op de achtergrond</string>
|
||||||
<string name="notifications_enabled_message">Meldingen ingeschakeld.</string>
|
<string name="notifications_enabled_message">Meldingen ingeschakeld.</string>
|
||||||
<string name="notifications_local_title">Meldingen toestaan</string>
|
<string name="notifications_local_title">Meldingen toestaan</string>
|
||||||
<string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string>
|
<string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Zapisz na tagu NFC</string>
|
<string name="button_write_nfc">Zapisz na tagu NFC</string>
|
||||||
<string name="device_model_title">Model urządzenia</string>
|
<string name="device_model_title">Model urządzenia</string>
|
||||||
<string name="device_name_title">Nazwa urządzenia</string>
|
<string name="device_name_title">Nazwa urządzenia</string>
|
||||||
<string name="diagnostics_description">Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Udostępnianie diagnostyki jest wyłączone.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Udostępnianie diagnostyki jest włączone.</string>
|
|
||||||
<string name="diagnostics_title">Udostępniaj diagnostykę</string>
|
|
||||||
<string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string>
|
<string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string>
|
||||||
<string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string>
|
<string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string>
|
||||||
<string name="error_destination_exists">W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.</string>
|
<string name="error_destination_exists">W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.</string>
|
||||||
@@ -120,6 +116,9 @@
|
|||||||
<string name="nav_settings">Ustawienia</string>
|
<string name="nav_settings">Ustawienia</string>
|
||||||
<string name="network_title">Sieć</string>
|
<string name="network_title">Sieć</string>
|
||||||
<string name="notifications_description">Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.</string>
|
<string name="notifications_description">Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.</string>
|
||||||
|
<string name="notifications_background_sharing_body">VniDrop jest gotowy do udostępniania plików w tle.</string>
|
||||||
|
<string name="notifications_background_sharing_channel">Aktywne transfery</string>
|
||||||
|
<string name="notifications_background_sharing_title">Udostępnianie w tle</string>
|
||||||
<string name="notifications_enabled_message">Powiadomienia włączone.</string>
|
<string name="notifications_enabled_message">Powiadomienia włączone.</string>
|
||||||
<string name="notifications_local_title">Zezwól na powiadomienia</string>
|
<string name="notifications_local_title">Zezwól na powiadomienia</string>
|
||||||
<string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string>
|
<string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Escrever em etiqueta NFC</string>
|
<string name="button_write_nfc">Escrever em etiqueta NFC</string>
|
||||||
<string name="device_model_title">Modelo do dispositivo</string>
|
<string name="device_model_title">Modelo do dispositivo</string>
|
||||||
<string name="device_name_title">Nome do dispositivo</string>
|
<string name="device_name_title">Nome do dispositivo</string>
|
||||||
<string name="diagnostics_description">Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos.</string>
|
|
||||||
<string name="diagnostics_disabled_message">A partilha de diagnósticos está desativada.</string>
|
|
||||||
<string name="diagnostics_enabled_message">A partilha de diagnósticos está ativada.</string>
|
|
||||||
<string name="diagnostics_title">Partilhar diagnósticos</string>
|
|
||||||
<string name="error_camera">É necessário acesso à câmara para ler um código QR.</string>
|
<string name="error_camera">É necessário acesso à câmara para ler um código QR.</string>
|
||||||
<string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string>
|
<string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string>
|
||||||
<string name="error_destination_exists">Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.</string>
|
<string name="error_destination_exists">Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.</string>
|
||||||
@@ -120,6 +116,9 @@
|
|||||||
<string name="nav_settings">Definições</string>
|
<string name="nav_settings">Definições</string>
|
||||||
<string name="network_title">Rede</string>
|
<string name="network_title">Rede</string>
|
||||||
<string name="notifications_description">Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.</string>
|
<string name="notifications_description">Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.</string>
|
||||||
|
<string name="notifications_background_sharing_body">O VniDrop está pronto para partilhar os seus ficheiros em segundo plano.</string>
|
||||||
|
<string name="notifications_background_sharing_channel">Transferências ativas</string>
|
||||||
|
<string name="notifications_background_sharing_title">Partilha em segundo plano</string>
|
||||||
<string name="notifications_enabled_message">Notificações ativadas.</string>
|
<string name="notifications_enabled_message">Notificações ativadas.</string>
|
||||||
<string name="notifications_local_title">Permitir notificações</string>
|
<string name="notifications_local_title">Permitir notificações</string>
|
||||||
<string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string>
|
<string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Записать на NFC-метку</string>
|
<string name="button_write_nfc">Записать на NFC-метку</string>
|
||||||
<string name="device_model_title">Модель устройства</string>
|
<string name="device_model_title">Модель устройства</string>
|
||||||
<string name="device_name_title">Имя устройства</string>
|
<string name="device_name_title">Имя устройства</string>
|
||||||
<string name="diagnostics_description">Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Передача диагностики отключена.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Передача диагностики включена.</string>
|
|
||||||
<string name="diagnostics_title">Делиться диагностикой</string>
|
|
||||||
<string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string>
|
<string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string>
|
||||||
<string name="error_device_info">Не удалось загрузить сведения об устройстве.</string>
|
<string name="error_device_info">Не удалось загрузить сведения об устройстве.</string>
|
||||||
<string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string>
|
<string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string>
|
||||||
@@ -120,6 +116,9 @@
|
|||||||
<string name="nav_settings">Настройки</string>
|
<string name="nav_settings">Настройки</string>
|
||||||
<string name="network_title">Сеть</string>
|
<string name="network_title">Сеть</string>
|
||||||
<string name="notifications_description">Получайте уведомления об активности передач, пока VniDrop работает в фоне.</string>
|
<string name="notifications_description">Получайте уведомления об активности передач, пока VniDrop работает в фоне.</string>
|
||||||
|
<string name="notifications_background_sharing_body">VniDrop готов отправлять ваши файлы в фоновом режиме.</string>
|
||||||
|
<string name="notifications_background_sharing_channel">Активные передачи</string>
|
||||||
|
<string name="notifications_background_sharing_title">Отправка в фоне</string>
|
||||||
<string name="notifications_enabled_message">Уведомления включены.</string>
|
<string name="notifications_enabled_message">Уведомления включены.</string>
|
||||||
<string name="notifications_local_title">Разрешить уведомления</string>
|
<string name="notifications_local_title">Разрешить уведомления</string>
|
||||||
<string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string>
|
<string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Write to NFC tag</string>
|
<string name="button_write_nfc">Write to NFC tag</string>
|
||||||
<string name="device_model_title">Device model</string>
|
<string name="device_model_title">Device model</string>
|
||||||
<string name="device_name_title">Device name</string>
|
<string name="device_name_title">Device name</string>
|
||||||
<string name="diagnostics_description">Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Diagnostics sharing is off.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Diagnostics sharing is on.</string>
|
|
||||||
<string name="diagnostics_title">Share diagnostics</string>
|
|
||||||
<string name="error_camera">Camera access is required to scan a QR code.</string>
|
<string name="error_camera">Camera access is required to scan a QR code.</string>
|
||||||
<string name="error_device_info">Could not load device information.</string>
|
<string name="error_device_info">Could not load device information.</string>
|
||||||
<string name="error_destination_exists">A file with the same name already exists in the destination. Choose another folder or remove the existing file.</string>
|
<string name="error_destination_exists">A file with the same name already exists in the destination. Choose another folder or remove the existing file.</string>
|
||||||
@@ -120,6 +116,9 @@
|
|||||||
<string name="nav_settings">Settings</string>
|
<string name="nav_settings">Settings</string>
|
||||||
<string name="network_title">Network</string>
|
<string name="network_title">Network</string>
|
||||||
<string name="notifications_description">Get notified about transfer activity while VniDrop is in the background.</string>
|
<string name="notifications_description">Get notified about transfer activity while VniDrop is in the background.</string>
|
||||||
|
<string name="notifications_background_sharing_body">VniDrop is ready to share your files in the background.</string>
|
||||||
|
<string name="notifications_background_sharing_channel">Active transfers</string>
|
||||||
|
<string name="notifications_background_sharing_title">Sharing in the background</string>
|
||||||
<string name="notifications_enabled_message">Notifications enabled.</string>
|
<string name="notifications_enabled_message">Notifications enabled.</string>
|
||||||
<string name="notifications_local_title">Allow notifications</string>
|
<string name="notifications_local_title">Allow notifications</string>
|
||||||
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
|
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ fun App(
|
|||||||
graph.coreRepository,
|
graph.coreRepository,
|
||||||
graph.preferencesRepository,
|
graph.preferencesRepository,
|
||||||
graph.messages,
|
graph.messages,
|
||||||
graph.diagnostics,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val sendViewModel = viewModel {
|
val sendViewModel = viewModel {
|
||||||
@@ -105,7 +104,6 @@ fun App(
|
|||||||
dependencies.localNotificationService,
|
dependencies.localNotificationService,
|
||||||
graph.messages,
|
graph.messages,
|
||||||
graph.diagnostics.bugReports,
|
graph.diagnostics.bugReports,
|
||||||
graph.diagnostics,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val appState by appViewModel.state.collectAsStateWithLifecycle()
|
val appState by appViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.vnidrop.app
|
|||||||
|
|
||||||
import com.vnidrop.app.core.CoreGateway
|
import com.vnidrop.app.core.CoreGateway
|
||||||
import com.vnidrop.app.core.CoreRepository
|
import com.vnidrop.app.core.CoreRepository
|
||||||
|
import com.vnidrop.app.background.BackgroundSharingCoordinator
|
||||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||||
import com.vnidrop.app.diagnostics.createDiagnosticsTransport
|
import com.vnidrop.app.diagnostics.createDiagnosticsTransport
|
||||||
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
|
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
|
||||||
@@ -19,9 +20,6 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class AppGraph(
|
class AppGraph(
|
||||||
val dependencies: AppDependencies,
|
val dependencies: AppDependencies,
|
||||||
@@ -40,11 +38,9 @@ class AppGraph(
|
|||||||
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
|
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
|
||||||
themeMode = ThemeMode.System,
|
themeMode = ThemeMode.System,
|
||||||
notificationsEnabled = false,
|
notificationsEnabled = false,
|
||||||
diagnosticsEnabled = false,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val diagnostics = DiagnosticsCoordinator.create(
|
val diagnostics = DiagnosticsCoordinator.create(
|
||||||
appDataDir = dependencies.environment.defaultCoreDataDir,
|
|
||||||
appVersion = dependencies.environment.appVersion,
|
appVersion = dependencies.environment.appVersion,
|
||||||
platform = dependencies.environment.name,
|
platform = dependencies.environment.name,
|
||||||
preferencesRepository = preferencesRepository,
|
preferencesRepository = preferencesRepository,
|
||||||
@@ -71,19 +67,19 @@ class AppGraph(
|
|||||||
messages = messages,
|
messages = messages,
|
||||||
scope = applicationScope,
|
scope = applicationScope,
|
||||||
)
|
)
|
||||||
|
private val backgroundSharingCoordinator = BackgroundSharingCoordinator(
|
||||||
|
repository = coreRepository,
|
||||||
|
controller = dependencies.backgroundSharingController,
|
||||||
|
scope = applicationScope,
|
||||||
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
||||||
diagnostics.start()
|
diagnostics.start()
|
||||||
applicationScope.launch {
|
|
||||||
visibility.isForeground
|
|
||||||
.drop(1)
|
|
||||||
.filter { isForeground -> !isForeground }
|
|
||||||
.collect { diagnostics.telemetry.flush() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
|
backgroundSharingCoordinator.stop()
|
||||||
coreRepository.shutdown()
|
coreRepository.shutdown()
|
||||||
applicationScope.cancel()
|
applicationScope.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.vnidrop.app
|
|||||||
import com.vnidrop.app.core.FileSystemService
|
import com.vnidrop.app.core.FileSystemService
|
||||||
import com.vnidrop.app.notifications.LocalNotificationService
|
import com.vnidrop.app.notifications.LocalNotificationService
|
||||||
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
||||||
|
import com.vnidrop.app.background.BackgroundSharingController
|
||||||
|
|
||||||
enum class UiPlatform {
|
enum class UiPlatform {
|
||||||
Android,
|
Android,
|
||||||
@@ -39,5 +40,6 @@ data class AppDependencies(
|
|||||||
val deviceInfoProvider: DeviceInfoProvider,
|
val deviceInfoProvider: DeviceInfoProvider,
|
||||||
val fileSystemService: FileSystemService,
|
val fileSystemService: FileSystemService,
|
||||||
val localNotificationService: LocalNotificationService,
|
val localNotificationService: LocalNotificationService,
|
||||||
|
val backgroundSharingController: BackgroundSharingController,
|
||||||
val externalInvitations: ExternalInvitationController,
|
val externalInvitations: ExternalInvitationController,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.vnidrop.app.background
|
||||||
|
|
||||||
|
import com.vnidrop.app.core.CoreGateway
|
||||||
|
import com.vnidrop.app.core.CoreState
|
||||||
|
import com.vnidrop.app.core.TransferDirection
|
||||||
|
import com.vnidrop.app.core.TransferStatus
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Keeps the platform process eligible to serve an outgoing share while its UI is backgrounded. */
|
||||||
|
fun interface BackgroundSharingController {
|
||||||
|
fun setSharingActive(active: Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundSharingCoordinator(
|
||||||
|
repository: CoreGateway,
|
||||||
|
private val controller: BackgroundSharingController,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
repository.state
|
||||||
|
.map(::requiresBackgroundSharing)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect(controller::setSharingActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() = controller.setSharingActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun requiresBackgroundSharing(state: CoreState): Boolean =
|
||||||
|
state.isInitialized && state.transfers.any { transfer ->
|
||||||
|
transfer.direction == TransferDirection.Send &&
|
||||||
|
transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
import com.vnidrop.app.logging.platformNowMillis
|
|
||||||
import kotlin.concurrent.atomics.AtomicReference
|
|
||||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed-size ring of high-level app breadcrumbs for crash / bug context.
|
|
||||||
* Always in-memory only; never auto-uploaded without policy + transport.
|
|
||||||
*
|
|
||||||
* Updates are best-effort under concurrency; losing a breadcrumb is preferable
|
|
||||||
* to blocking a dying process on a lock.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalAtomicApi::class)
|
|
||||||
class BreadcrumbBuffer(
|
|
||||||
private val capacity: Int = DefaultCapacity,
|
|
||||||
) {
|
|
||||||
init {
|
|
||||||
require(capacity > 0) { "capacity must be positive" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private val items = AtomicReference<List<Breadcrumb>>(emptyList())
|
|
||||||
|
|
||||||
fun add(name: String, properties: Map<String, String> = emptyMap(), timestampMillis: Long = platformNowMillis()) {
|
|
||||||
val sanitizedName = sanitizeDiagnosticName(name)
|
|
||||||
if (sanitizedName.isBlank()) return
|
|
||||||
val crumb = Breadcrumb(
|
|
||||||
name = sanitizedName,
|
|
||||||
timestampMillis = timestampMillis,
|
|
||||||
properties = sanitizeDiagnosticProperties(properties),
|
|
||||||
)
|
|
||||||
while (true) {
|
|
||||||
val current = items.load()
|
|
||||||
if (items.compareAndSet(current, (current + crumb).takeLast(capacity))) return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun snapshot(): List<Breadcrumb> = items.load()
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
items.store(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val DefaultCapacity = 40
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ data class BugReportDraft(
|
|||||||
class BugReportService(
|
class BugReportService(
|
||||||
private val preferencesRepository: PreferencesRepository,
|
private val preferencesRepository: PreferencesRepository,
|
||||||
private val transport: DiagnosticsTransport,
|
private val transport: DiagnosticsTransport,
|
||||||
private val breadcrumbs: BreadcrumbBuffer,
|
|
||||||
private val appVersion: String,
|
private val appVersion: String,
|
||||||
private val platform: String,
|
private val platform: String,
|
||||||
private val logReader: () -> String = {
|
private val logReader: () -> String = {
|
||||||
@@ -53,7 +52,6 @@ class BugReportService(
|
|||||||
network = deviceInfo?.network?.takeUtf8Bytes(96),
|
network = deviceInfo?.network?.takeUtf8Bytes(96),
|
||||||
batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64),
|
batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64),
|
||||||
),
|
),
|
||||||
breadcrumbs = breadcrumbs.snapshot(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
import com.vnidrop.app.logging.AppLogger
|
|
||||||
import com.vnidrop.app.logging.platformNowMillis
|
|
||||||
import com.vnidrop.app.preferences.PreferencesRepository
|
|
||||||
import com.vnidrop.app.util.randomUuidString
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.concurrent.atomics.AtomicBoolean
|
|
||||||
import kotlin.concurrent.atomics.AtomicReference
|
|
||||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captures uncaught exceptions to disk, then uploads on a later launch when
|
|
||||||
* diagnostics is enabled (and when a real [DiagnosticsTransport] is wired).
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalAtomicApi::class)
|
|
||||||
class CrashReporter(
|
|
||||||
private val store: PendingCrashStore,
|
|
||||||
private val preferencesRepository: PreferencesRepository,
|
|
||||||
private val transport: DiagnosticsTransport,
|
|
||||||
private val breadcrumbs: BreadcrumbBuffer,
|
|
||||||
private val appVersion: String,
|
|
||||||
private val platform: String,
|
|
||||||
private val scope: CoroutineScope,
|
|
||||||
) {
|
|
||||||
private val installed = AtomicBoolean(false)
|
|
||||||
private val observingPreferences = AtomicBoolean(false)
|
|
||||||
private val capturePolicy = AtomicReference(CrashCapturePolicy())
|
|
||||||
|
|
||||||
fun startObservingPreferences() {
|
|
||||||
if (!observingPreferences.compareAndSet(false, true)) return
|
|
||||||
scope.launch {
|
|
||||||
preferencesRepository.preferences.collect { prefs ->
|
|
||||||
capturePolicy.store(
|
|
||||||
CrashCapturePolicy(
|
|
||||||
installId = prefs.diagnosticsInstallId,
|
|
||||||
diagnosticsEnabled = prefs.diagnosticsEnabled,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!prefs.diagnosticsEnabled) {
|
|
||||||
runCatching(::deleteAllPending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun installUnhandledExceptionHandler() {
|
|
||||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
|
||||||
if (!installed.compareAndSet(false, true)) return
|
|
||||||
installPlatformCrashHook { throwable ->
|
|
||||||
capture(throwable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun capture(throwable: Throwable, diagnosticsEnabledOverride: Boolean? = null): CrashReport {
|
|
||||||
val policy = capturePolicy.load()
|
|
||||||
val report = CrashReport(
|
|
||||||
id = randomUuidString(),
|
|
||||||
timestampMillis = platformNowMillis(),
|
|
||||||
installId = sanitizeDiagnosticsInstallId(policy.installId),
|
|
||||||
appVersion = appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
|
|
||||||
platform = platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
|
|
||||||
exceptionType = throwable::class.simpleName ?: "Throwable",
|
|
||||||
exceptionMessage = LogRedactor.redact(throwable.message.orEmpty()).takeUtf8Bytes(MaxMessageBytes),
|
|
||||||
stackTrace = LogRedactor.redact(throwable.stackTraceToString()).takeUtf8Bytes(MaxStackBytes),
|
|
||||||
breadcrumbs = breadcrumbs.snapshot(),
|
|
||||||
diagnosticsEnabledAtCapture = diagnosticsEnabledOverride ?: policy.diagnosticsEnabled,
|
|
||||||
)
|
|
||||||
if (report.diagnosticsEnabledAtCapture != false) {
|
|
||||||
runCatching { store.write(report) }
|
|
||||||
runCatching {
|
|
||||||
store.prune(
|
|
||||||
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
|
|
||||||
maxCount = MaxLocalCrashCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AppLogger.error("crash", "captured crash ${report.id}", throwable)
|
|
||||||
return report
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads pending crashes that were captured with diagnostics enabled.
|
|
||||||
* Local files are deleted after successful delivery or bounded by local retention.
|
|
||||||
*/
|
|
||||||
suspend fun flushPending() {
|
|
||||||
store.prune(
|
|
||||||
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
|
|
||||||
maxCount = MaxLocalCrashCount,
|
|
||||||
)
|
|
||||||
for (report in store.list()) {
|
|
||||||
val preferences = preferencesRepository.preferences.first()
|
|
||||||
if (!preferences.diagnosticsEnabled || capturePolicy.load().diagnosticsEnabled == false) {
|
|
||||||
deleteAllPending()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (report.diagnosticsEnabledAtCapture == false) {
|
|
||||||
store.delete(report.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val installId = sanitizeDiagnosticsInstallId(
|
|
||||||
preferences.diagnosticsInstallId.ifBlank {
|
|
||||||
preferencesRepository.ensureDiagnosticsInstallId()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val resolved = report.copy(
|
|
||||||
installId = report.installId.ifBlank { installId },
|
|
||||||
diagnosticsEnabledAtCapture = true,
|
|
||||||
)
|
|
||||||
if (resolved != report) store.write(resolved)
|
|
||||||
if (
|
|
||||||
!preferencesRepository.preferences.first().diagnosticsEnabled ||
|
|
||||||
capturePolicy.load().diagnosticsEnabled == false
|
|
||||||
) {
|
|
||||||
deleteAllPending()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val result = try {
|
|
||||||
transport.sendCrash(resolved)
|
|
||||||
} catch (cancelled: CancellationException) {
|
|
||||||
throw cancelled
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Result.failure(error)
|
|
||||||
}
|
|
||||||
if (result.isSuccess) {
|
|
||||||
store.delete(resolved.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val error = result.exceptionOrNull()
|
|
||||||
if (error?.isPermanentDiagnosticsPayloadRejection() == true) {
|
|
||||||
store.delete(resolved.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteAllPending() {
|
|
||||||
store.list().forEach { report -> store.delete(report.id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val MaxMessageBytes = 2_000
|
|
||||||
private const val MaxStackBytes = 32_000
|
|
||||||
private const val MaxLocalCrashCount = 20
|
|
||||||
private const val LocalRetentionMillis = 30L * 86_400_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class CrashCapturePolicy(
|
|
||||||
val installId: String = "",
|
|
||||||
val diagnosticsEnabled: Boolean? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect fun installPlatformCrashHook(onCrash: (Throwable) -> Unit)
|
|
||||||
@@ -5,83 +5,43 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns diagnostics services for the app process: telemetry, crashes, bug reports.
|
* Owns user-initiated bug reports for the app process.
|
||||||
*
|
*
|
||||||
* When [DiagnosticsBuildConfig.INCLUDED] is false (compile-time), telemetry and
|
* Telemetry and crash auto-reporting were removed; only [bugReports] remains,
|
||||||
* crash auto-reporting are never started; [bugReports] still works for support.
|
* and it only sends when the user submits a report from Settings.
|
||||||
*/
|
*/
|
||||||
class DiagnosticsCoordinator(
|
class DiagnosticsCoordinator(
|
||||||
val preferencesRepository: PreferencesRepository,
|
val preferencesRepository: PreferencesRepository,
|
||||||
val transport: DiagnosticsTransport,
|
val transport: DiagnosticsTransport,
|
||||||
val breadcrumbs: BreadcrumbBuffer,
|
|
||||||
val telemetry: TelemetryRecorder,
|
|
||||||
val crashReporter: CrashReporter,
|
|
||||||
val bugReports: BugReportService,
|
val bugReports: BugReportService,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val included: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
|
||||||
) {
|
) {
|
||||||
fun start() {
|
fun start() {
|
||||||
// Install id is useful for bug-report correlation even without telemetry.
|
// Install id is useful for bug-report correlation.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
preferencesRepository.ensureDiagnosticsInstallId()
|
preferencesRepository.ensureDiagnosticsInstallId()
|
||||||
}
|
}
|
||||||
if (!included) return
|
|
||||||
crashReporter.startObservingPreferences()
|
|
||||||
crashReporter.installUnhandledExceptionHandler()
|
|
||||||
scope.launch {
|
|
||||||
crashReporter.flushPending()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
|
||||||
if (!included) return
|
|
||||||
telemetry.record(name, properties)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun create(
|
fun create(
|
||||||
appDataDir: String,
|
|
||||||
appVersion: String,
|
appVersion: String,
|
||||||
platform: String,
|
platform: String,
|
||||||
preferencesRepository: PreferencesRepository,
|
preferencesRepository: PreferencesRepository,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
|
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
|
||||||
included: Boolean = DiagnosticsBuildConfig.INCLUDED,
|
|
||||||
): DiagnosticsCoordinator {
|
): DiagnosticsCoordinator {
|
||||||
val breadcrumbs = BreadcrumbBuffer()
|
|
||||||
val crashStore = createPendingCrashStore(appDataDir)
|
|
||||||
val telemetry = TelemetryRecorder(
|
|
||||||
preferencesRepository = preferencesRepository,
|
|
||||||
transport = transport,
|
|
||||||
breadcrumbs = breadcrumbs,
|
|
||||||
scope = scope,
|
|
||||||
included = included,
|
|
||||||
)
|
|
||||||
val crashReporter = CrashReporter(
|
|
||||||
store = crashStore,
|
|
||||||
preferencesRepository = preferencesRepository,
|
|
||||||
transport = transport,
|
|
||||||
breadcrumbs = breadcrumbs,
|
|
||||||
appVersion = appVersion,
|
|
||||||
platform = platform,
|
|
||||||
scope = scope,
|
|
||||||
)
|
|
||||||
val bugReports = BugReportService(
|
val bugReports = BugReportService(
|
||||||
preferencesRepository = preferencesRepository,
|
preferencesRepository = preferencesRepository,
|
||||||
transport = transport,
|
transport = transport,
|
||||||
breadcrumbs = breadcrumbs,
|
|
||||||
appVersion = appVersion,
|
appVersion = appVersion,
|
||||||
platform = platform,
|
platform = platform,
|
||||||
)
|
)
|
||||||
return DiagnosticsCoordinator(
|
return DiagnosticsCoordinator(
|
||||||
preferencesRepository = preferencesRepository,
|
preferencesRepository = preferencesRepository,
|
||||||
transport = transport,
|
transport = transport,
|
||||||
breadcrumbs = breadcrumbs,
|
|
||||||
telemetry = telemetry,
|
|
||||||
crashReporter = crashReporter,
|
|
||||||
bugReports = bugReports,
|
bugReports = bugReports,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
included = included,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,85 +8,6 @@ internal object DiagnosticsJson {
|
|||||||
internal const val MaxInstallIdBytes = 80
|
internal const val MaxInstallIdBytes = 80
|
||||||
internal const val MaxAppVersionBytes = 40
|
internal const val MaxAppVersionBytes = 40
|
||||||
internal const val MaxPlatformBytes = 40
|
internal const val MaxPlatformBytes = 40
|
||||||
private const val MaxBreadcrumbsJsonBytes = 16_000
|
|
||||||
private const val MaxBreadcrumbs = 40
|
|
||||||
private const val SizedBatchId = "00000000-0000-4000-8000-000000000000"
|
|
||||||
|
|
||||||
fun eventsBody(
|
|
||||||
batchId: String,
|
|
||||||
installId: String,
|
|
||||||
appVersion: String,
|
|
||||||
platform: String,
|
|
||||||
events: List<TelemetryEvent>,
|
|
||||||
): String = buildString {
|
|
||||||
append('{')
|
|
||||||
appendJsonField("batchId", batchId)
|
|
||||||
append(',')
|
|
||||||
appendJsonField("installId", installId)
|
|
||||||
append(',')
|
|
||||||
appendJsonField("appVersion", appVersion)
|
|
||||||
append(',')
|
|
||||||
appendJsonField("platform", platform)
|
|
||||||
append(',')
|
|
||||||
append("\"events\":[")
|
|
||||||
events.forEachIndexed { index, event ->
|
|
||||||
if (index > 0) append(',')
|
|
||||||
append('{')
|
|
||||||
appendJsonField("name", event.name)
|
|
||||||
append(',')
|
|
||||||
append("\"timestampMillis\":")
|
|
||||||
append(event.timestampMillis)
|
|
||||||
append(',')
|
|
||||||
append("\"schemaVersion\":")
|
|
||||||
append(event.schemaVersion)
|
|
||||||
append(',')
|
|
||||||
append("\"properties\":")
|
|
||||||
appendStringMap(event.properties)
|
|
||||||
append('}')
|
|
||||||
}
|
|
||||||
append("]}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun eventBatchFitsRequest(events: List<TelemetryEvent>): Boolean =
|
|
||||||
eventsBody(
|
|
||||||
batchId = SizedBatchId,
|
|
||||||
installId = "\u0000".repeat(MaxInstallIdBytes),
|
|
||||||
appVersion = "\u0000".repeat(MaxAppVersionBytes),
|
|
||||||
platform = "\u0000".repeat(MaxPlatformBytes),
|
|
||||||
events = events,
|
|
||||||
).encodeToByteArray().size <= MaxRequestBytes
|
|
||||||
|
|
||||||
fun crashBody(report: CrashReport): String = buildString {
|
|
||||||
append('{')
|
|
||||||
appendJsonField("id", report.id)
|
|
||||||
append(',')
|
|
||||||
append("\"timestampMillis\":")
|
|
||||||
append(report.timestampMillis)
|
|
||||||
append(',')
|
|
||||||
appendJsonField("installId", report.installId)
|
|
||||||
append(',')
|
|
||||||
appendJsonField("appVersion", report.appVersion)
|
|
||||||
append(',')
|
|
||||||
appendJsonField("platform", report.platform)
|
|
||||||
append(',')
|
|
||||||
appendJsonField("exceptionType", report.exceptionType)
|
|
||||||
append(',')
|
|
||||||
appendJsonField("exceptionMessage", report.exceptionMessage)
|
|
||||||
append(',')
|
|
||||||
appendJsonField("stackTrace", report.stackTrace)
|
|
||||||
append(',')
|
|
||||||
append("\"diagnosticsEnabledAtCapture\":")
|
|
||||||
append(requireNotNull(report.diagnosticsEnabledAtCapture) {
|
|
||||||
"crash consent must be resolved before delivery"
|
|
||||||
})
|
|
||||||
append(',')
|
|
||||||
append("\"schemaVersion\":")
|
|
||||||
append(report.schemaVersion)
|
|
||||||
append(',')
|
|
||||||
append("\"breadcrumbs\":")
|
|
||||||
appendBreadcrumbs(report.breadcrumbs)
|
|
||||||
append('}')
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bugBody(report: BugReport): String {
|
fun bugBody(report: BugReport): String {
|
||||||
val logs = if (report.includeLogs) report.logs else ""
|
val logs = if (report.includeLogs) report.logs else ""
|
||||||
@@ -149,47 +70,7 @@ internal object DiagnosticsJson {
|
|||||||
appendJsonField("network", report.device.network.orEmpty())
|
appendJsonField("network", report.device.network.orEmpty())
|
||||||
append(',')
|
append(',')
|
||||||
appendJsonField("batteryLevel", report.device.batteryLevel.orEmpty())
|
appendJsonField("batteryLevel", report.device.batteryLevel.orEmpty())
|
||||||
append("},")
|
|
||||||
append("\"breadcrumbs\":")
|
|
||||||
appendBreadcrumbs(report.breadcrumbs)
|
|
||||||
append('}')
|
append('}')
|
||||||
}
|
|
||||||
|
|
||||||
private fun StringBuilder.appendBreadcrumbs(crumbs: List<Breadcrumb>) {
|
|
||||||
append('[')
|
|
||||||
var encodedBytes = 2
|
|
||||||
var appended = 0
|
|
||||||
for (crumb in crumbs) {
|
|
||||||
if (appended == MaxBreadcrumbs) break
|
|
||||||
val name = sanitizeDiagnosticName(crumb.name)
|
|
||||||
if (name.isBlank() || crumb.timestampMillis < 0) continue
|
|
||||||
val encoded = buildString {
|
|
||||||
append('{')
|
|
||||||
appendJsonField("name", name)
|
|
||||||
append(',')
|
|
||||||
append("\"timestampMillis\":")
|
|
||||||
append(crumb.timestampMillis)
|
|
||||||
append(',')
|
|
||||||
append("\"properties\":")
|
|
||||||
appendStringMap(crumb.properties)
|
|
||||||
append('}')
|
|
||||||
}
|
|
||||||
val additionBytes = encoded.encodeToByteArray().size + if (appended == 0) 0 else 1
|
|
||||||
if (encodedBytes + additionBytes > MaxBreadcrumbsJsonBytes) break
|
|
||||||
if (appended > 0) append(',')
|
|
||||||
append(encoded)
|
|
||||||
encodedBytes += additionBytes
|
|
||||||
appended += 1
|
|
||||||
}
|
|
||||||
append(']')
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun StringBuilder.appendStringMap(map: Map<String, String>) {
|
|
||||||
append('{')
|
|
||||||
sanitizeDiagnosticProperties(map).entries.forEachIndexed { index, (key, value) ->
|
|
||||||
if (index > 0) append(',')
|
|
||||||
appendJsonField(key, value)
|
|
||||||
}
|
|
||||||
append('}')
|
append('}')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,40 +5,6 @@ package com.vnidrop.app.diagnostics
|
|||||||
* intentionally abstracted; nothing here assumes a network backend.
|
* intentionally abstracted; nothing here assumes a network backend.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
data class TelemetryEvent(
|
|
||||||
val name: String,
|
|
||||||
val timestampMillis: Long,
|
|
||||||
val properties: Map<String, String> = emptyMap(),
|
|
||||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One idempotent upload unit; [id] remains stable when delivery is retried. */
|
|
||||||
data class TelemetryBatch(
|
|
||||||
val id: String,
|
|
||||||
val events: List<TelemetryEvent>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Breadcrumb(
|
|
||||||
val name: String,
|
|
||||||
val timestampMillis: Long,
|
|
||||||
val properties: Map<String, String> = emptyMap(),
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CrashReport(
|
|
||||||
val id: String,
|
|
||||||
val timestampMillis: Long,
|
|
||||||
val installId: String,
|
|
||||||
val appVersion: String,
|
|
||||||
val platform: String,
|
|
||||||
val exceptionType: String,
|
|
||||||
val exceptionMessage: String,
|
|
||||||
val stackTrace: String,
|
|
||||||
val breadcrumbs: List<Breadcrumb>,
|
|
||||||
/** `null` only while a startup crash is waiting for the persisted preference to load. */
|
|
||||||
val diagnosticsEnabledAtCapture: Boolean?,
|
|
||||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DeviceSnapshot(
|
data class DeviceSnapshot(
|
||||||
val deviceName: String?,
|
val deviceName: String?,
|
||||||
val deviceModel: String?,
|
val deviceModel: String?,
|
||||||
@@ -60,7 +26,6 @@ data class BugReport(
|
|||||||
val includeLogs: Boolean,
|
val includeLogs: Boolean,
|
||||||
val logs: String,
|
val logs: String,
|
||||||
val device: DeviceSnapshot,
|
val device: DeviceSnapshot,
|
||||||
val breadcrumbs: List<Breadcrumb>,
|
|
||||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,11 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
package com.vnidrop.app.diagnostics
|
||||||
|
|
||||||
internal const val MaxDiagnosticProperties = 12
|
|
||||||
internal const val MaxDiagnosticPropertyKeyBytes = 40
|
|
||||||
internal const val MaxDiagnosticPropertyValueBytes = 128
|
|
||||||
internal const val MaxDiagnosticNameBytes = 64
|
|
||||||
|
|
||||||
internal fun sanitizeDiagnosticsInstallId(value: String): String {
|
internal fun sanitizeDiagnosticsInstallId(value: String): String {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
if (trimmed.any { it.code < 0x20 || it.code == 0x7f }) return ""
|
if (trimmed.any { it.code < 0x20 || it.code == 0x7f }) return ""
|
||||||
return trimmed.takeUtf8Bytes(DiagnosticsJson.MaxInstallIdBytes)
|
return trimmed.takeUtf8Bytes(DiagnosticsJson.MaxInstallIdBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun sanitizeDiagnosticName(name: String): String =
|
|
||||||
name.takeUtf8Bytes(MaxDiagnosticNameBytes)
|
|
||||||
|
|
||||||
internal fun sanitizeDiagnosticProperties(properties: Map<String, String>): Map<String, String> {
|
|
||||||
val sanitized = LinkedHashMap<String, String>(minOf(properties.size, MaxDiagnosticProperties))
|
|
||||||
for ((rawKey, rawValue) in properties) {
|
|
||||||
val key = rawKey.takeUtf8Bytes(MaxDiagnosticPropertyKeyBytes)
|
|
||||||
if (key.isEmpty() || key in sanitized) continue
|
|
||||||
sanitized[key] = LogRedactor.redact(rawValue).takeUtf8Bytes(MaxDiagnosticPropertyValueBytes)
|
|
||||||
if (sanitized.size == MaxDiagnosticProperties) break
|
|
||||||
}
|
|
||||||
return sanitized
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun String.takeUtf8Bytes(maxBytes: Int): String {
|
internal fun String.takeUtf8Bytes(maxBytes: Int): String {
|
||||||
require(maxBytes >= 0) { "maxBytes must not be negative" }
|
require(maxBytes >= 0) { "maxBytes must not be negative" }
|
||||||
val encoded = encodeToByteArray()
|
val encoded = encodeToByteArray()
|
||||||
|
|||||||
@@ -1,50 +1,25 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
package com.vnidrop.app.diagnostics
|
||||||
|
|
||||||
/** Network boundary for diagnostics; keep batching and validation client-side. */
|
/** Network boundary for bug reports; keep validation client-side. */
|
||||||
interface DiagnosticsTransport {
|
interface DiagnosticsTransport {
|
||||||
suspend fun sendEvents(batch: TelemetryBatch): Result<Unit>
|
|
||||||
suspend fun sendCrash(report: CrashReport): Result<Unit>
|
|
||||||
suspend fun sendBugReport(report: BugReport): Result<Unit>
|
suspend fun sendBugReport(report: BugReport): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class DiagnosticsUnavailableException : IllegalStateException("diagnostics delivery is not configured")
|
internal class DiagnosticsUnavailableException : IllegalStateException("diagnostics delivery is not configured")
|
||||||
|
|
||||||
internal fun Throwable.isPermanentDiagnosticsPayloadRejection(): Boolean =
|
|
||||||
this is DiagnosticsPayloadException ||
|
|
||||||
(this is DiagnosticsHttpException && statusCode in setOf(400, 413, 415, 422))
|
|
||||||
|
|
||||||
/** Fails delivery without leaving the device. Used until a remote endpoint is configured. */
|
/** Fails delivery without leaving the device. Used until a remote endpoint is configured. */
|
||||||
class NoOpDiagnosticsTransport : DiagnosticsTransport {
|
class NoOpDiagnosticsTransport : DiagnosticsTransport {
|
||||||
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> = unavailable()
|
override suspend fun sendBugReport(report: BugReport): Result<Unit> =
|
||||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> = unavailable()
|
Result.failure(DiagnosticsUnavailableException())
|
||||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> = unavailable()
|
|
||||||
|
|
||||||
private fun unavailable(): Result<Unit> = Result.failure(DiagnosticsUnavailableException())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test double that records calls and can fail on demand.
|
* Test double that records calls and can fail on demand.
|
||||||
*/
|
*/
|
||||||
class RecordingDiagnosticsTransport : DiagnosticsTransport {
|
class RecordingDiagnosticsTransport : DiagnosticsTransport {
|
||||||
val eventBatches = mutableListOf<TelemetryBatch>()
|
|
||||||
val events: List<List<TelemetryEvent>>
|
|
||||||
get() = eventBatches.map(TelemetryBatch::events)
|
|
||||||
val crashes = mutableListOf<CrashReport>()
|
|
||||||
val bugReports = mutableListOf<BugReport>()
|
val bugReports = mutableListOf<BugReport>()
|
||||||
var eventsResult: Result<Unit> = Result.success(Unit)
|
|
||||||
var crashResult: Result<Unit> = Result.success(Unit)
|
|
||||||
var bugResult: Result<Unit> = Result.success(Unit)
|
var bugResult: Result<Unit> = Result.success(Unit)
|
||||||
|
|
||||||
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
|
|
||||||
eventBatches += batch
|
|
||||||
return eventsResult
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
|
|
||||||
crashes += report
|
|
||||||
return crashResult
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
||||||
bugReports += report
|
bugReports += report
|
||||||
return bugResult
|
return bugResult
|
||||||
|
|||||||
@@ -27,33 +27,6 @@ class HttpDiagnosticsTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
|
|
||||||
if (batch.events.isEmpty()) return Result.success(Unit)
|
|
||||||
if (batch.events.size > TelemetryRecorder.MaxEventsPerBatch) {
|
|
||||||
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is too large"))
|
|
||||||
}
|
|
||||||
if (batch.events.any { it.name.isBlank() || it.timestampMillis < 0 }) {
|
|
||||||
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is invalid"))
|
|
||||||
}
|
|
||||||
val installId = sanitizeDiagnosticsInstallId(installIdProvider())
|
|
||||||
val body = DiagnosticsJson.eventsBody(
|
|
||||||
batch.id,
|
|
||||||
installId,
|
|
||||||
appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
|
|
||||||
platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
|
|
||||||
batch.events,
|
|
||||||
)
|
|
||||||
return postJson("/v1/events", body, installId, batch.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
|
|
||||||
if (report.diagnosticsEnabledAtCapture == null) {
|
|
||||||
return Result.failure(DiagnosticsPayloadException("crash consent is unresolved"))
|
|
||||||
}
|
|
||||||
val body = DiagnosticsJson.crashBody(report)
|
|
||||||
return postJson("/v1/crashes", body, report.installId, report.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
||||||
val body = DiagnosticsJson.bugBody(report)
|
val body = DiagnosticsJson.bugBody(report)
|
||||||
return postJson("/v1/bugs", body, report.installId, report.id)
|
return postJson("/v1/bugs", body, report.installId, report.id)
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Durable crash envelopes written during process death and read on next launch.
|
|
||||||
* Encoding is a simple line-oriented format (no kotlinx.serialization dependency).
|
|
||||||
*/
|
|
||||||
interface PendingCrashStore {
|
|
||||||
fun write(report: CrashReport)
|
|
||||||
fun list(): List<CrashReport>
|
|
||||||
fun delete(id: String)
|
|
||||||
fun prune(olderThanTimestampMillis: Long, maxCount: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect fun createPendingCrashStore(appDataDir: String): PendingCrashStore
|
|
||||||
|
|
||||||
internal object CrashReportCodec {
|
|
||||||
private const val FieldSep = "\u001f"
|
|
||||||
private const val RecordSep = "\u001e"
|
|
||||||
private const val Version2Prefix = "vnidrop-crash-v2\n"
|
|
||||||
private const val MaxEncodedChars = 512 * 1024
|
|
||||||
|
|
||||||
fun encode(report: CrashReport): String = buildString {
|
|
||||||
fun field(key: String, value: String) {
|
|
||||||
append(key)
|
|
||||||
append('=')
|
|
||||||
append(value.hexEncode())
|
|
||||||
append('\n')
|
|
||||||
}
|
|
||||||
append(Version2Prefix)
|
|
||||||
field("id", report.id)
|
|
||||||
field("ts", report.timestampMillis.toString())
|
|
||||||
field("install", report.installId)
|
|
||||||
field("app", report.appVersion)
|
|
||||||
field("platform", report.platform)
|
|
||||||
field("type", report.exceptionType)
|
|
||||||
field("message", report.exceptionMessage)
|
|
||||||
field("stack", report.stackTrace)
|
|
||||||
field(
|
|
||||||
"diag",
|
|
||||||
when (report.diagnosticsEnabledAtCapture) {
|
|
||||||
true -> "1"
|
|
||||||
false -> "0"
|
|
||||||
null -> "u"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
field("schema", report.schemaVersion.toString())
|
|
||||||
val breadcrumbs = report.breadcrumbs.take(40)
|
|
||||||
field("crumb.count", breadcrumbs.size.toString())
|
|
||||||
breadcrumbs.forEachIndexed { crumbIndex, crumb ->
|
|
||||||
field("crumb.$crumbIndex.ts", crumb.timestampMillis.toString())
|
|
||||||
field("crumb.$crumbIndex.name", crumb.name)
|
|
||||||
val properties = crumb.properties.entries.take(MaxDiagnosticProperties)
|
|
||||||
field("crumb.$crumbIndex.prop.count", properties.size.toString())
|
|
||||||
properties.forEachIndexed { propertyIndex, (key, value) ->
|
|
||||||
field("crumb.$crumbIndex.prop.$propertyIndex.key", key)
|
|
||||||
field("crumb.$crumbIndex.prop.$propertyIndex.value", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decode(raw: String): CrashReport? {
|
|
||||||
if (raw.isBlank() || raw.length > MaxEncodedChars) return null
|
|
||||||
return if (raw.startsWith(Version2Prefix)) decodeVersion2(raw) else decodeLegacy(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeVersion2(raw: String): CrashReport? {
|
|
||||||
val map = linkedMapOf<String, String>()
|
|
||||||
for (part in raw.removePrefix(Version2Prefix).lineSequence()) {
|
|
||||||
if (part.isEmpty()) continue
|
|
||||||
val eq = part.indexOf('=')
|
|
||||||
if (eq <= 0) continue
|
|
||||||
val key = part.substring(0, eq)
|
|
||||||
val value = part.substring(eq + 1).hexDecode() ?: return null
|
|
||||||
map[key] = value
|
|
||||||
}
|
|
||||||
val crumbCount = map["crumb.count"]?.toIntOrNull()?.takeIf { it in 0..40 } ?: return null
|
|
||||||
val crumbs = buildList {
|
|
||||||
repeat(crumbCount) { crumbIndex ->
|
|
||||||
val timestamp = map["crumb.$crumbIndex.ts"]
|
|
||||||
?.toLongOrNull()
|
|
||||||
?.takeIf { it >= 0 }
|
|
||||||
?: return null
|
|
||||||
val name = map["crumb.$crumbIndex.name"]?.takeIf { it.isNotBlank() } ?: return null
|
|
||||||
val propertyCount = map["crumb.$crumbIndex.prop.count"]
|
|
||||||
?.toIntOrNull()
|
|
||||||
?.takeIf { it in 0..MaxDiagnosticProperties }
|
|
||||||
?: return null
|
|
||||||
val properties = buildMap {
|
|
||||||
repeat(propertyCount) { propertyIndex ->
|
|
||||||
val key = map["crumb.$crumbIndex.prop.$propertyIndex.key"] ?: return null
|
|
||||||
val value = map["crumb.$crumbIndex.prop.$propertyIndex.value"] ?: return null
|
|
||||||
put(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
add(Breadcrumb(name = name, timestampMillis = timestamp, properties = properties))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return reportFromFields(map, crumbs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeLegacy(raw: String): CrashReport? {
|
|
||||||
val map = linkedMapOf<String, String>()
|
|
||||||
for (part in raw.split(FieldSep)) {
|
|
||||||
if (part.isEmpty()) continue
|
|
||||||
val eq = part.indexOf('=')
|
|
||||||
if (eq <= 0) continue
|
|
||||||
val key = part.substring(0, eq)
|
|
||||||
val value = part.substring(eq + 1)
|
|
||||||
.replace("\\n", "\n")
|
|
||||||
.replace("\\r", "\r")
|
|
||||||
map[key] = value
|
|
||||||
}
|
|
||||||
val crumbs = map["crumbs"].orEmpty()
|
|
||||||
.split(RecordSep)
|
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
.mapNotNull { entry ->
|
|
||||||
val pieces = entry.split('|', limit = 3)
|
|
||||||
if (pieces.size < 2) return@mapNotNull null
|
|
||||||
val ts = pieces[0].toLongOrNull()?.takeIf { it >= 0 } ?: return@mapNotNull null
|
|
||||||
val name = pieces[1].takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
|
||||||
val props = if (pieces.size > 2 && pieces[2].isNotBlank()) {
|
|
||||||
pieces[2].split(',').mapNotNull { kv ->
|
|
||||||
val colon = kv.indexOf(':')
|
|
||||||
if (colon <= 0) null
|
|
||||||
else kv.substring(0, colon) to kv.substring(colon + 1)
|
|
||||||
}.toMap()
|
|
||||||
} else {
|
|
||||||
emptyMap()
|
|
||||||
}
|
|
||||||
Breadcrumb(name = name, timestampMillis = ts, properties = props)
|
|
||||||
}
|
|
||||||
return reportFromFields(map, crumbs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reportFromFields(
|
|
||||||
map: Map<String, String>,
|
|
||||||
crumbs: List<Breadcrumb>,
|
|
||||||
): CrashReport? {
|
|
||||||
val id = map["id"]?.takeIf(::isValidDiagnosticId) ?: return null
|
|
||||||
val timestamp = map["ts"]?.toLongOrNull()?.takeIf { it >= 0 } ?: return null
|
|
||||||
val exceptionType = map["type"]?.takeIf { it.isNotBlank() } ?: return null
|
|
||||||
val schemaVersion = map["schema"]?.toIntOrNull()
|
|
||||||
?.takeIf { it == DiagnosticsSchemaVersion }
|
|
||||||
?: return null
|
|
||||||
val diagnosticsEnabled: Boolean? = when (map["diag"]) {
|
|
||||||
"1" -> true
|
|
||||||
"0" -> false
|
|
||||||
"u" -> null
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
return CrashReport(
|
|
||||||
id = id,
|
|
||||||
timestampMillis = timestamp,
|
|
||||||
installId = map["install"].orEmpty(),
|
|
||||||
appVersion = map["app"].orEmpty(),
|
|
||||||
platform = map["platform"].orEmpty(),
|
|
||||||
exceptionType = exceptionType,
|
|
||||||
exceptionMessage = map["message"].orEmpty(),
|
|
||||||
stackTrace = map["stack"].orEmpty(),
|
|
||||||
breadcrumbs = crumbs,
|
|
||||||
diagnosticsEnabledAtCapture = diagnosticsEnabled,
|
|
||||||
schemaVersion = schemaVersion,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun isValidDiagnosticId(id: String): Boolean =
|
|
||||||
DiagnosticIdPattern.matches(id)
|
|
||||||
|
|
||||||
private val DiagnosticIdPattern =
|
|
||||||
Regex("^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
|
|
||||||
|
|
||||||
private fun String.hexEncode(): String {
|
|
||||||
val digits = "0123456789abcdef"
|
|
||||||
return buildString(length * 2) {
|
|
||||||
for (byte in this@hexEncode.encodeToByteArray()) {
|
|
||||||
val value = byte.toInt() and 0xff
|
|
||||||
append(digits[value ushr 4])
|
|
||||||
append(digits[value and 0x0f])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.hexDecode(): String? {
|
|
||||||
if (length % 2 != 0) return null
|
|
||||||
val bytes = ByteArray(length / 2)
|
|
||||||
for (index in bytes.indices) {
|
|
||||||
val high = this[index * 2].digitToIntOrNull(16) ?: return null
|
|
||||||
val low = this[index * 2 + 1].digitToIntOrNull(16) ?: return null
|
|
||||||
bytes[index] = ((high shl 4) or low).toByte()
|
|
||||||
}
|
|
||||||
return runCatching { bytes.decodeToString(throwOnInvalidSequence = true) }.getOrNull()
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
import com.vnidrop.app.logging.platformNowMillis
|
|
||||||
import com.vnidrop.app.preferences.PreferencesRepository
|
|
||||||
import com.vnidrop.app.util.randomUuidString
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import kotlin.concurrent.atomics.AtomicReference
|
|
||||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Product telemetry: sparse events, gated by diagnostics opt-in.
|
|
||||||
* Events are buffered and flushed in batches when transport is available.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalAtomicApi::class)
|
|
||||||
class TelemetryRecorder(
|
|
||||||
private val preferencesRepository: PreferencesRepository,
|
|
||||||
private val transport: DiagnosticsTransport,
|
|
||||||
private val breadcrumbs: BreadcrumbBuffer,
|
|
||||||
private val scope: CoroutineScope,
|
|
||||||
private val maxBufferSize: Int = DefaultMaxBuffer,
|
|
||||||
private val flushThreshold: Int = DefaultFlushThreshold,
|
|
||||||
private val flushIntervalMillis: Long = DefaultFlushIntervalMillis,
|
|
||||||
private val retryBackoffMillis: Long = DefaultRetryBackoffMillis,
|
|
||||||
private val automaticRetryCount: Int = DefaultAutomaticRetryCount,
|
|
||||||
private val included: Boolean = true,
|
|
||||||
) {
|
|
||||||
private val bufferMutex = Mutex()
|
|
||||||
private val state = AtomicReference(TelemetryState())
|
|
||||||
private val flushSignals = Channel<Unit>(Channel.CONFLATED)
|
|
||||||
|
|
||||||
init {
|
|
||||||
require(maxBufferSize > 0) { "maxBufferSize must be positive" }
|
|
||||||
require(flushThreshold > 0) { "flushThreshold must be positive" }
|
|
||||||
require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" }
|
|
||||||
require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" }
|
|
||||||
require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" }
|
|
||||||
if (included) {
|
|
||||||
scope.launch {
|
|
||||||
preferencesRepository.preferences
|
|
||||||
.map { it.diagnosticsEnabled }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { isEnabled ->
|
|
||||||
updateState { current ->
|
|
||||||
if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false)
|
|
||||||
}
|
|
||||||
flushSignals.trySend(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scope.launch { runAutomaticFlushes() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
|
||||||
if (!included) return
|
|
||||||
val sanitizedName = sanitizeDiagnosticName(name)
|
|
||||||
if (sanitizedName.isBlank()) return
|
|
||||||
val sanitizedProperties = sanitizeDiagnosticProperties(properties)
|
|
||||||
breadcrumbs.add(sanitizedName, sanitizedProperties)
|
|
||||||
val event = TelemetryEvent(
|
|
||||||
name = sanitizedName,
|
|
||||||
timestampMillis = platformNowMillis(),
|
|
||||||
properties = sanitizedProperties,
|
|
||||||
)
|
|
||||||
while (true) {
|
|
||||||
val current = state.load()
|
|
||||||
if (current.enabled == false) return
|
|
||||||
val remainingCapacity =
|
|
||||||
(maxBufferSize - current.retryBatch?.events.orEmpty().size).coerceAtLeast(0)
|
|
||||||
val nextBuffer = (current.buffer + event).takeLast(remainingCapacity)
|
|
||||||
if (state.compareAndSet(current, current.copy(buffer = nextBuffer))) {
|
|
||||||
flushSignals.trySend(Unit)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun flush(): Result<Unit> {
|
|
||||||
return bufferMutex.withLock {
|
|
||||||
var discardedFailure: Throwable? = null
|
|
||||||
var outcome: Result<Unit>? = null
|
|
||||||
while (outcome == null) {
|
|
||||||
val current = state.load()
|
|
||||||
if (current.enabled != true) return@withLock Result.success(Unit)
|
|
||||||
val pendingRetry = current.retryBatch
|
|
||||||
val events = if (pendingRetry == null) nextBatchEvents(current.buffer) else emptyList()
|
|
||||||
if (pendingRetry == null && events.isEmpty()) {
|
|
||||||
outcome = discardedFailure?.let { Result.failure(it) } ?: Result.success(Unit)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val batch: TelemetryBatch
|
|
||||||
if (pendingRetry != null) {
|
|
||||||
batch = pendingRetry
|
|
||||||
} else {
|
|
||||||
val prepared = TelemetryBatch(id = randomUuidString(), events = events)
|
|
||||||
val next = current.copy(
|
|
||||||
buffer = current.buffer.drop(events.size),
|
|
||||||
retryBatch = prepared,
|
|
||||||
)
|
|
||||||
if (!state.compareAndSet(current, next)) continue
|
|
||||||
batch = prepared
|
|
||||||
}
|
|
||||||
if (state.load().retryBatch != batch) continue
|
|
||||||
val result = try {
|
|
||||||
transport.sendEvents(batch)
|
|
||||||
} catch (cancelled: CancellationException) {
|
|
||||||
throw cancelled
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Result.failure(error)
|
|
||||||
}
|
|
||||||
if (result.isSuccess) {
|
|
||||||
clearRetryBatch(batch)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val error = result.exceptionOrNull() ?: IllegalStateException("diagnostics event delivery failed")
|
|
||||||
if (error.isPermanentDiagnosticsPayloadRejection()) {
|
|
||||||
clearRetryBatch(batch)
|
|
||||||
discardedFailure = discardedFailure ?: error
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
outcome = result
|
|
||||||
}
|
|
||||||
checkNotNull(outcome)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nextBatchEvents(events: List<TelemetryEvent>): List<TelemetryEvent> {
|
|
||||||
if (events.isEmpty()) return emptyList()
|
|
||||||
var minimum = 1
|
|
||||||
var maximum = minOf(events.size, MaxEventsPerBatch)
|
|
||||||
var accepted = 1
|
|
||||||
while (minimum <= maximum) {
|
|
||||||
val candidateSize = minimum + (maximum - minimum) / 2
|
|
||||||
if (DiagnosticsJson.eventBatchFitsRequest(events.take(candidateSize))) {
|
|
||||||
accepted = candidateSize
|
|
||||||
minimum = candidateSize + 1
|
|
||||||
} else {
|
|
||||||
maximum = candidateSize - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return events.take(accepted)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pendingCount(): Int {
|
|
||||||
val current = state.load()
|
|
||||||
return current.retryBatch?.events.orEmpty().size + current.buffer.size
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runAutomaticFlushes() {
|
|
||||||
while (true) {
|
|
||||||
flushSignals.receive()
|
|
||||||
var retries = 0
|
|
||||||
while (true) {
|
|
||||||
val current = state.load()
|
|
||||||
if (current.enabled != true || pendingCount() == 0) break
|
|
||||||
if (current.retryBatch == null && pendingCount() < flushThreshold) {
|
|
||||||
val signalled = withTimeoutOrNull(flushIntervalMillis) {
|
|
||||||
flushSignals.receive()
|
|
||||||
true
|
|
||||||
} ?: false
|
|
||||||
if (signalled) continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = flush()
|
|
||||||
if (result.isSuccess || pendingCount() == 0) {
|
|
||||||
retries = 0
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val error = result.exceptionOrNull()
|
|
||||||
if (error?.isPermanentDiagnosticsPayloadRejection() == true || retries >= automaticRetryCount) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
retries += 1
|
|
||||||
delay(retryBackoffMillis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearRetryBatch(batch: TelemetryBatch) {
|
|
||||||
while (true) {
|
|
||||||
val current = state.load()
|
|
||||||
if (current.retryBatch != batch) return
|
|
||||||
if (state.compareAndSet(current, current.copy(retryBatch = null))) return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateState(update: (TelemetryState) -> TelemetryState) {
|
|
||||||
while (true) {
|
|
||||||
val current = state.load()
|
|
||||||
if (state.compareAndSet(current, update(current))) return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val DefaultMaxBuffer = 100
|
|
||||||
const val DefaultFlushThreshold = 20
|
|
||||||
const val MaxEventsPerBatch = 50
|
|
||||||
const val DefaultFlushIntervalMillis = 30_000L
|
|
||||||
const val DefaultRetryBackoffMillis = 30_000L
|
|
||||||
const val DefaultAutomaticRetryCount = 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class TelemetryState(
|
|
||||||
val enabled: Boolean? = null,
|
|
||||||
val buffer: List<TelemetryEvent> = emptyList(),
|
|
||||||
val retryBatch: TelemetryBatch? = null,
|
|
||||||
)
|
|
||||||
@@ -6,7 +6,6 @@ import com.vnidrop.app.PlatformEnvironment
|
|||||||
import com.vnidrop.app.AppDependencies
|
import com.vnidrop.app.AppDependencies
|
||||||
import com.vnidrop.app.AppGraph
|
import com.vnidrop.app.AppGraph
|
||||||
import com.vnidrop.app.core.CoreGateway
|
import com.vnidrop.app.core.CoreGateway
|
||||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
|
||||||
import com.vnidrop.app.logging.AppLogger
|
import com.vnidrop.app.logging.AppLogger
|
||||||
import com.vnidrop.app.preferences.PreferencesRepository
|
import com.vnidrop.app.preferences.PreferencesRepository
|
||||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||||
@@ -38,14 +37,12 @@ class AppViewModel(
|
|||||||
private val repository: CoreGateway,
|
private val repository: CoreGateway,
|
||||||
preferencesRepository: PreferencesRepository,
|
preferencesRepository: PreferencesRepository,
|
||||||
private val messages: UiMessageController,
|
private val messages: UiMessageController,
|
||||||
private val diagnostics: DiagnosticsCoordinator? = null,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(AppState())
|
private val _state = MutableStateFlow(AppState())
|
||||||
val state: StateFlow<AppState> = _state.asStateFlow()
|
val state: StateFlow<AppState> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
|
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
|
||||||
diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion))
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val relaySettings = preferencesRepository.preferences.first().relaySettings
|
val relaySettings = preferencesRepository.preferences.first().relaySettings
|
||||||
repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error)
|
repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error)
|
||||||
@@ -59,6 +56,5 @@ class AppViewModel(
|
|||||||
|
|
||||||
fun selectDestination(destination: AppDestination) {
|
fun selectDestination(destination: AppDestination) {
|
||||||
_state.update { it.copy(destination = destination) }
|
_state.update { it.copy(destination = destination) }
|
||||||
diagnostics?.record("nav_select", mapOf("destination" to destination.name))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalUriHandler
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
|
||||||
import com.vnidrop.app.ui.icons.AppIcon
|
import com.vnidrop.app.ui.icons.AppIcon
|
||||||
import com.vnidrop.app.ui.icons.PlatformIcon
|
import com.vnidrop.app.ui.icons.PlatformIcon
|
||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
@@ -46,18 +45,15 @@ import vnidrop.shared.generated.resources.about_privacy_title
|
|||||||
import vnidrop.shared.generated.resources.about_tagline
|
import vnidrop.shared.generated.resources.about_tagline
|
||||||
import vnidrop.shared.generated.resources.about_title
|
import vnidrop.shared.generated.resources.about_title
|
||||||
import vnidrop.shared.generated.resources.device_model_title
|
import vnidrop.shared.generated.resources.device_model_title
|
||||||
import vnidrop.shared.generated.resources.diagnostics_description
|
|
||||||
import vnidrop.shared.generated.resources.diagnostics_title
|
|
||||||
import vnidrop.shared.generated.resources.os_version_title
|
import vnidrop.shared.generated.resources.os_version_title
|
||||||
import vnidrop.shared.generated.resources.value_unavailable
|
import vnidrop.shared.generated.resources.value_unavailable
|
||||||
import vnidrop.shared.generated.resources.version_title
|
import vnidrop.shared.generated.resources.version_title
|
||||||
|
|
||||||
private const val PrivacyPolicyUrl = "https://github.com/vnidrop/vnidrop"
|
private val PrivacyPolicyUrl = com.vnidrop.app.AppConfig.PRIVACY_POLICY_URL
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AboutSettings(
|
internal fun AboutSettings(
|
||||||
state: SettingsState,
|
state: SettingsState,
|
||||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
|
||||||
onReportBug: () -> Unit,
|
onReportBug: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
showBack: Boolean,
|
showBack: Boolean,
|
||||||
@@ -130,17 +126,6 @@ internal fun AboutSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsGroup {
|
SettingsGroup {
|
||||||
if (DiagnosticsBuildConfig.INCLUDED) {
|
|
||||||
SettingsToggleRow(
|
|
||||||
icon = AppIcon.Info,
|
|
||||||
title = stringResource(Res.string.diagnostics_title),
|
|
||||||
description = stringResource(Res.string.diagnostics_description),
|
|
||||||
checked = state.diagnosticsEnabled,
|
|
||||||
enabled = true,
|
|
||||||
onCheckedChange = onDiagnosticsChanged,
|
|
||||||
)
|
|
||||||
SettingsDivider()
|
|
||||||
}
|
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = AppIcon.Bug,
|
icon = AppIcon.Bug,
|
||||||
title = stringResource(Res.string.about_bug_report),
|
title = stringResource(Res.string.about_bug_report),
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
|
|||||||
onResetFolder = viewModel::resetReceiveFolder,
|
onResetFolder = viewModel::resetReceiveFolder,
|
||||||
onNotificationsChanged = viewModel::setNotificationsEnabled,
|
onNotificationsChanged = viewModel::setNotificationsEnabled,
|
||||||
onOpenNotificationSettings = viewModel::openNotificationSettings,
|
onOpenNotificationSettings = viewModel::openNotificationSettings,
|
||||||
onDiagnosticsChanged = viewModel::setDiagnosticsEnabled,
|
|
||||||
onBugWhatChanged = viewModel::setBugWhatHappened,
|
onBugWhatChanged = viewModel::setBugWhatHappened,
|
||||||
onBugExpectedChanged = viewModel::setBugExpected,
|
onBugExpectedChanged = viewModel::setBugExpected,
|
||||||
onBugStepsChanged = viewModel::setBugSteps,
|
onBugStepsChanged = viewModel::setBugSteps,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ fun SettingsScreen(
|
|||||||
onResetFolder: () -> Unit,
|
onResetFolder: () -> Unit,
|
||||||
onNotificationsChanged: (Boolean) -> Unit,
|
onNotificationsChanged: (Boolean) -> Unit,
|
||||||
onOpenNotificationSettings: () -> Unit,
|
onOpenNotificationSettings: () -> Unit,
|
||||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
|
||||||
onBugWhatChanged: (String) -> Unit,
|
onBugWhatChanged: (String) -> Unit,
|
||||||
onBugExpectedChanged: (String) -> Unit,
|
onBugExpectedChanged: (String) -> Unit,
|
||||||
onBugStepsChanged: (String) -> Unit,
|
onBugStepsChanged: (String) -> Unit,
|
||||||
@@ -62,7 +61,6 @@ fun SettingsScreen(
|
|||||||
onResetFolder = onResetFolder,
|
onResetFolder = onResetFolder,
|
||||||
onNotificationsChanged = onNotificationsChanged,
|
onNotificationsChanged = onNotificationsChanged,
|
||||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
|
||||||
onBugWhatChanged = onBugWhatChanged,
|
onBugWhatChanged = onBugWhatChanged,
|
||||||
onBugExpectedChanged = onBugExpectedChanged,
|
onBugExpectedChanged = onBugExpectedChanged,
|
||||||
onBugStepsChanged = onBugStepsChanged,
|
onBugStepsChanged = onBugStepsChanged,
|
||||||
@@ -105,7 +103,6 @@ fun SettingsScreen(
|
|||||||
onResetFolder = onResetFolder,
|
onResetFolder = onResetFolder,
|
||||||
onNotificationsChanged = onNotificationsChanged,
|
onNotificationsChanged = onNotificationsChanged,
|
||||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
|
||||||
onBugWhatChanged = onBugWhatChanged,
|
onBugWhatChanged = onBugWhatChanged,
|
||||||
onBugExpectedChanged = onBugExpectedChanged,
|
onBugExpectedChanged = onBugExpectedChanged,
|
||||||
onBugStepsChanged = onBugStepsChanged,
|
onBugStepsChanged = onBugStepsChanged,
|
||||||
@@ -140,7 +137,6 @@ private fun SettingsSectionContent(
|
|||||||
onResetFolder: () -> Unit,
|
onResetFolder: () -> Unit,
|
||||||
onNotificationsChanged: (Boolean) -> Unit,
|
onNotificationsChanged: (Boolean) -> Unit,
|
||||||
onOpenNotificationSettings: () -> Unit,
|
onOpenNotificationSettings: () -> Unit,
|
||||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
|
||||||
onBugWhatChanged: (String) -> Unit,
|
onBugWhatChanged: (String) -> Unit,
|
||||||
onBugExpectedChanged: (String) -> Unit,
|
onBugExpectedChanged: (String) -> Unit,
|
||||||
onBugStepsChanged: (String) -> Unit,
|
onBugStepsChanged: (String) -> Unit,
|
||||||
@@ -184,7 +180,6 @@ private fun SettingsSectionContent(
|
|||||||
)
|
)
|
||||||
SettingsSection.About -> AboutSettings(
|
SettingsSection.About -> AboutSettings(
|
||||||
state = state,
|
state = state,
|
||||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
|
||||||
onReportBug = { onSectionSelected(SettingsSection.BugReport) },
|
onReportBug = { onSectionSelected(SettingsSection.BugReport) },
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
showBack = showBack,
|
showBack = showBack,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user