Fondations

18 — Applications desktop et mobiles

Statut : fait · Phase : transverse · Mis à jour : 29/10/2026.

Décidé le 29/10/2026. Tout site construit avec Récit doit pouvoir devenir une application de bureau (Linux, macOS, Windows) puis mobile (Android, iOS), sans code par site : une coquille Tauri 2 commune, configurée par une section app: du recit.config, qui embarque le site prérendu et le sert hors ligne. Le modèle est la mise en place de Tauri du projet sass (/home/deploy/work/sass), reprise là où elle s'applique et retournée là où Récit diffère : sass est le client d'un serveur, un site Récit est un dossier de fichiers.

1. Objectifs et non-objectifs

Objectifs.

  • Un site Récit (sites/*, examples/site-*, le site d'un utilisateur) se construit en application native avec une commande : recit app build, à partir du même dist/ que recit-site build.
  • Hors ligne complet : pages, recherche Pagefind, polices, récits publiés (publish:), diagrammes.
  • Une seule coquille Rust, versionnée dans un paquet publié, jamais copiée dans les sites.
  • La configuration est typée dans @recit/core (zod, z.infer), comme toute section de recit.config, et présente dans le JSON Schema config.
  • Les lots qui n'exigent pas la chaîne Rust (contrat, bundle, paquet, CLI, e2e web, docs) se font et se vérifient sur cette machine ; les lots natifs et mobiles sont marqués toolchain ou CI.

Non-objectifs.

  • Pas de serveur embarqué, pas de SSR dans l'application : le mode --experimental-ssr du framework reste une affaire d'hébergement.
  • Pas d'authentification, de notifications, de stockage sécurisé ni de lien profond au départ : sass en a besoin pour son SaaS, un site de contenu non. Le lien profond reste une option de app: (§4.4) pour plus tard.
  • Pas de publication sur les stores dans ce plan : la question des comptes est ouverte (§10).
  • recit render (MP4) reste une chaîne Node + Chromium, hors application.

2. Ce que fait sass, et ce qui se transfère

2.1 Constats, avec les fichiers

MécanismeOùConstat
Tauri 2apps/desktop/src-tauri/tauri.conf.json:2, Cargo.toml:16-20$schema …/config/2, tauri = "2", plugins notification, deep-link, store, http
Frontend distanttauri.conf.json:6-10devUrl: http://localhost:3000, frontendDist: "https://app.tonsaas.com" : l'app est un client de l'app Next déployée, qui a besoin d'un serveur (routes API, SQLite)
Permissions pour pages distantescapabilities/default.json:6-8, :9-15remote.urls liste les origines qui ont le droit d'appeler les plugins ; sans cela l'app s'ouvre mais les appels sont refusés
Code Rustsrc/lib.rs, src/main.rs, build.rs4 plugins, 2 commandes de démonstration (greet, get_system_info), #[cfg_attr(mobile, tauri::mobile_entry_point)] ; crate-type = ["staticlib","cdylib","rlib"] (Cargo.toml:10), prêt pour le mobile
Une coquille, N applicationspackages/sites/scripts/app.ts:42-61, :63-66, :73un overlay JSON par site (productName, identifier, build.devUrl/frontendDist, fenêtre, capabilities) écrit dans apps/desktop/.site-configs/<site>.json (ignoré par git, apps/desktop/.gitignore:1) et passé à tauri dev|build --config
Réglages par siteapp.ts:20-24, sites/engravingcol/app.json, sites/sass/app.jsonapp.json facultatif : productName, identifier, url ; identifiant par défaut com.tonsaas.<sous-domaine> sans tirets (:44)
Réutilisation du serveur de devapp.ts:29-40, :45sonde du port 3000 ; s'il répond, beforeDevCommand: "" et l'app se branche dessus
Pont JavaScriptpackages/native/src/bridge.ts, useTauri.ts, client.tsisTauriEnvironment() (__TAURI_INTERNALS__ dans window), imports dynamiques des plugins avec repli web ; utilisé par aucun écran (apps/web/src n'importe pas @saas/native)
Mobileicons/android, icons/ios ; apps/docs/pages/sites/application-desktop-et-mobile.md § Mobileicônes générées par tauri icon, mais aucune cible initialisée (pas de gen/) ; Xcode et SDK Android absents de la machine de dev ; rien de vérifié
Storesapps/docs/pages/projet/questions-ouvertes.md:76-78 (Q12)qui publie, avec quel compte : non tranché ; recommandation de ne proposer le mobile qu'à la demande
Lien profondapps/docs/pages/securite/authentification.md:47-49tonsaas://auth/callback?token= prévu, jamais branché
Mise à jour, signature, CI—aucune (pas de .github/, pas d'updater, pas de clé)
Commandespackage.json:13-14, Makefile:38-45desktop:dev, desktop:build, desktop-check (cargo check)

2.2 Ce qui se transfère

Mécanisme de sassDans Récit
Tauri 2, crate-type et point d'entrée mobiletel quel dans la coquille de @recit/app (§4.3)
Une coquille, N apps par overlay --config générétel quel, l'overlay est généré depuis app: du recit.config (§4.4) et non depuis un app.json
Réutilisation d'un serveur de dev déjà lancétel quel dans recit app dev (§4.7)
Icônes de toutes les plateformes par tauri icontel quel, depuis l'icône du site (§4.8)
frontendDist distant, remote.urlssans objet : le bundle prérendu est embarqué, la capability ne nomme aucune origine distante
Plugins notification, store, http, deep-linksans objet au départ ; un seul plugin, opener, pour les liens externes (§4.6) ; deep-link reste une option de app:
Pont JavaScript @saas/nativesans objet : la garde de navigation est côté Rust, zéro code client, @recit/site n'est pas touché
Makefile et scripts desktop:*adapté : sous-commandes recit app …, et un script app:build dans le package.json des sites qui le veulent

3. Prérequis machine

La machine de travail actuelle (Debian, noyau 6.1 cloud, sans affichage) a node 22.22 et pnpm 11.6, et n'a ni rustc, cargo, rustup, ni Java, ni SDK/NDK Android, ni adb, ni les bibliothèques de développement WebKitGTK ; build-essential est présent.

BesoinNiveauCommentSert à
Rust stable (rustc, cargo)utilisateurcurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y, puis rustup default stablecargo check, tout tauri build
Cibles Androidutilisateurrustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-androidAPP7
Cibles iOSutilisateur (Mac)rustup target add aarch64-apple-ios aarch64-apple-ios-simAPP8
CLI Tauriutilisateur (pnpm)@tauri-apps/cli en dépendance de @recit/app, épinglé dans le catalogue de pnpm-workspace.yamlAPP1 et suivants
WebKitGTK et GTK (Linux)apt, sudolibwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libxdo-dev pkg-config file curl wgetAPP5 sur cette machine ; sinon CI (APP6)
JDK 17apt ou utilisateuropenjdk-17-jdk (sudo) ou une archive Temurin dans ~/.jdks (sans sudo), JAVA_HOMEAPP7
SDK Android (cmdline-tools, platform-tools, platforms 34, build-tools) et NDK ≥ 26utilisateursdkmanager dans ~/Android/Sdk, ANDROID_HOME, NDK_HOMEAPP7
Xcode complet, CocoaPodsMacApp Store puis xcode-select --install, brew install cocoapodsAPP8
Un affichage—tauri build lie sans affichage ; tauri dev et un test de fumée demandent Xvfb (xvfb-run) ou un poste avec écranAPP5, APP6

Conséquence sur l'ordre : APP0 à APP4 ne touchent pas à Rust et s'exécutent ici ; APP5 demande rustup et la ligne apt ; APP6 porte tout ce que la machine ne peut pas faire sur des runners GitHub ; APP7 et APP8 sont CI ou poste équipé.

4. Architecture cible

4.1 Vue d'ensemble

Code
recit.config.ts ── app: ──▶ @recit/core/config (AppConfigSchema, resolveAppConfig) [APP0] │ recit app build ──▶ @recit/cli ──▶ @recit/app (scope:tool, Node) [APP2 → APP1] │ ├─ matérialise <site>/.recit/app/ (coquille + tauri.conf.json générés) │ ├─ lance `recit-site build` → <site>/dist/ (le bundle, inchangé) │ └─ lance `tauri build --config …` (frontendDist = ../../dist) ▼ .deb / .AppImage / .dmg / .msi / .apk / .aab / .ipa

Trois règles de 03-packaging.md §8 fixent la place de chaque chose :

  • @recit/app porte scope:tool, comme @recit/paged : Node uniquement, importé par la CLI (scope:distribution) et par personne d'autre. Un outil peut dépendre des fondations ; il n'a besoin que de @recit/core. Aucune nouvelle arête dans scripts/check-package-boundaries.js.
  • Un paquet publié n'importe jamais @recit/site. @recit/app lance recit-site build (le binaire est une dépendance de chaque site) et lit le dist/ ; il n'importe rien du framework.
  • Une seule stack : @tauri-apps/cli est la seule dépendance nouvelle, dans @recit/app seulement ; recit la charge à la demande comme @recit/motion-render (packages/cli/src/commands/motion.ts:200-213, peerDependenciesMeta de packages/cli/package.json). Un utilisateur qui n'en veut pas ne la télécharge pas.

4.2 Le paquet @recit/app

packages/tools/app, publié (ajouté à release.projects de nx.json, vérifié par scripts/__tests__/release-workflow.spec.ts). Forme d'un paquet publié de 03-packaging.md §8 : exports à trois conditions (types, @recit/source, default), construit par tsdown comme packages/formats/motion-render.

API, le contrat que APP2 et APP3 consomment (les types de configuration viennent de @recit/core) :

TypeScriptTypeScript
import type { Diagnostic } from "@recit/core"; import type { ResolvedAppConfig } from "@recit/core/config"; export type ShellTarget = "desktop" | "android" | "ios"; /** tauri.conf.json complet, objet JSON pur, testé par instantané */ export function tauriConfig( app: ResolvedAppConfig, options: { target: ShellTarget; frontendDist: string; devUrl?: string }, ): Record<string, unknown>; /** Écrit la coquille et sa configuration dans <siteDir>/.recit/app ; idempotent */ export function materializeShell(options: { siteDir: string; app: ResolvedAppConfig; target: ShellTarget; devUrl?: string; }): Promise<{ shellDir: string; configFile: string; diagnostics: Diagnostic[] }>; /** Lance @tauri-apps/cli dans la coquille (code de sortie) */ export function runTauri( shellDir: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }, ): Promise<number>; /** Sert dist/ en résolvant les chemins comme le protocole de Tauri (§4.5) */ export function serveLikeTauri( distDir: string, options?: { port?: number }, ): Promise<{ url: string; close(): Promise<void> }>; /** Un serveur répond-il déjà sur ce port ? (sass, app.ts:29-40) */ export function isPortInUse(port: number): Promise<boolean>;

4.3 La coquille

packages/tools/app/shell/, copiée telle quelle dans <site>/.recit/app/ par materializeShell (dossier ignoré par git, comme .site-configs/ de sass ; la ligne .recit/ s'ajoute au .gitignore racine) :

FichierRôle
Cargo.tomltauri = "2", tauri-plugin-opener = "2", serde, serde_json ; [lib] name = "recit_app_lib", crate-type = ["staticlib", "cdylib", "rlib"]
build.rstauri_build::build()
src/main.rsrecit_app_lib::run() avec windows_subsystem = "windows" hors debug (comme sass)
src/lib.rs#[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() : plugin opener, garde on_navigation (§4.6), fenêtre montrée après on_page_load
capabilities/default.jsonwindows: ["*"], permissions: ["core:default", "opener:default"], sans remote
tauri.conf.jsongénéré par tauriConfig() à chaque appel, jamais édité à la main
icons/générés par tauri icon (§4.8)
.gitignoretarget/, gen/, icons/

Le dossier target/ de Cargo vit dans <site>/.recit/app/target par défaut ; RECIT_APP_TARGET_DIR le déplace pour partager le cache entre les sites d'un monorepo.

Le tauri.conf.json généré, pour un site :

JSONJSON
{ "$schema": "https://schema.tauri.app/config/2", "productName": "Récit Docs", "version": "0.93.0", // app.version, sinon version du package.json du site "identifier": "dev.recit.docs", "build": { "devUrl": "http://localhost:3001", // recit app dev seulement "frontendDist": "../../dist", // relatif à .recit/app/ "beforeDevCommand": "", // recit gère le serveur de dev lui-même (§4.7) "beforeBuildCommand": "" // et le build du site }, "app": { "windows": [{ "title": "Récit Docs", "width": 1280, "height": 800, "visible": false }], "security": { "csp": null } }, "bundle": { "active": true, "targets": "all", "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"] } }

bundle.android.minSdkVersion, bundle.iOS.developmentTeam, plugins.deep-link et plugins.updater n'apparaissent que si la section app: les renseigne.

4.4 La section app: du recit.config

Dans packages/core/src/config/validate.ts, à côté de DeckConfigSchema et PublishConfigSchema, et ajoutée à RecitConfigSchema (app: z.optional(AppConfigSchema)). C'est une section Récit : defineConfig la retire des options du site et la range sous RECIT_CONFIG (packages/core/src/config/define.ts:47-57), donc elle n'entre pas dans SITE_CONFIG_KEYS et le test packages/integrations/site-stories/src/__tests__/site-config.spec.ts (« Récit sections never collide with site options ») reste vert sans changement. Le JSON Schema config suit tout seul (packages/core/src/schema/json-schema.ts:25).

TypeScriptTypeScript
export const AppConfigSchema = z.object({ /** Nom de l'application et de sa fenêtre. Défaut : site.title, sinon le dossier du site */ productName: optionalString, /** Identifiant inversé (dev.recit.docs), figé après publication. Défaut : canonicalUrlOrigin inversé */ identifier: optionalString, /** Version affichée. Défaut : version du package.json du site */ version: optionalString, /** Icône source carrée, PNG ≥ 1024 px ou SVG, relative au site. Défaut : metadata.favicon */ icon: optionalString, window: z.optional( z.object({ width: z.optional(positiveInt), height: z.optional(positiveInt), minWidth: z.optional(positiveInt), minHeight: z.optional(positiveInt), resizable: optionalBoolean, }), ), /** Origines que la fenêtre a le droit de charger en plus du bundle ; tout le reste s'ouvre dans le navigateur */ allowedOrigins: z.optional(z.array(z.string())), /** Schéma d'URL qui ouvre l'application (recit-docs://…) */ deepLink: optionalString, android: z.optional(z.object({ minSdkVersion: z.optional(positiveInt) })), ios: z.optional(z.object({ developmentTeam: optionalString })), updater: z.optional(z.object({ endpoints: z.array(z.string()), pubkey: z.string() })), }); export type AppConfig = z.infer<typeof AppConfigSchema>;

resolveAppConfig(config, site: { dir, packageVersion }) rend un ResolvedAppConfig où chaque champ a sa valeur, plus des Diagnostic[] (type de @recit/core) :

  • identifier : au moins deux segments, chacun [a-zA-Z][a-zA-Z0-9_]* (règle Android, pas de tiret ; sass les supprime, app.ts:44). Sans valeur ni canonicalUrlOrigin : erreur, le message donne la règle.
  • basePath renseigné : erreur. Tauri sert frontendDist à la racine, or le routeur (createBrowserRouter, basename, packages/site/src/app/entry.client.tsx:68-70) et Pagefind (PagefindSearch.tsx:37) attendent le préfixe. Les sites Récit n'en ont pas.
  • cdnUrl renseigné : erreur, les assets viendraient du réseau.
  • search.type: "inkeep" : avertissement, la recherche est distante.
  • app absent : erreur de recit app, « ajoutez une section app: », avec l'exemple minimal.

4.5 Le bundle statique

recit-site build produit déjà ce qu'il faut, sans option nouvelle :

  • une page par route en <chemin>.html (packages/site/src/vite/prerender/worker.ts:33), index.html pour /, et un fichier de 85 octets par redirection ;
  • des assets référencés en relatif (renderBuiltUrl rend { relative: true } sans CDN, packages/site/src/vite/config.ts:196-206) ;
  • les polices embarquées (@fontsource-variable/geist, packages/site/src/app/main.css:1-2), Shiki, Mermaid, KaTeX dans le bundle ;
  • l'index Pagefind dans dist/pagefind/ (prerender.ts:216-218), chargé par import dynamique de /pagefind/pagefind.js (PagefindSearch.tsx:36-37) ; pagefind.js charge son .wasm et ses fragments relativement à lui-même ;
  • le dossier server/ supprimé après prérendu (packages/site/src/vite/build.ts:236).

Le protocole de Tauri (tauri://localhost sur macOS, Linux et iOS, http://tauri.localhost sur Windows et Android) résout une requête /a/b en essayant a/b, a/b.html, a/b/index.html puis index.html en repli : un rechargement sur une page profonde trouve son .html, un chemin inconnu tombe sur la route 404 du routeur (packages/site/src/app/notFoundRoute.tsx), le .wasm est servi avec son type MIME. serveLikeTauri() reproduit exactement cette chaîne pour la vérifier en e2e sans Rust (APP3) et pour recit app preview.

Ce qui reste dans le bundle et ne sert à rien dans l'app (sitemap, llms.txt, copies .md de publishMarkdown) pèse quelques centaines de kilo-octets : on ne filtre pas.

4.6 Liens externes et navigation

Un site Récit renvoie vers les autres sites (header.navigation de packages/tools/site-kit/src/config.tsx:100-109), GitHub, et les liens des pages. Dans la fenêtre native, ces navigations doivent partir dans le navigateur du système, pas remplacer l'application. Deux mécanismes, tous deux côté Rust :

  1. tauri::Builder::on_navigation : autorise l'origine du bundle, devUrl en dev et app.allowedOrigins ; pour toute autre URL, appelle tauri_plugin_opener::open_url et refuse la navigation.
  2. window.open et target="_blank" ne passent pas par on_navigation : un script d'initialisation (initialization_script) de dix lignes remplace window.open par un appel à plugin:opener|open_url. À vérifier en APP5 sur les trois systèmes.

Rien n'est ajouté au framework ni aux composants : le site ne sait pas qu'il tourne dans une application. Si un jour une page doit le savoir, "__TAURI_INTERNALS__" in window suffit (sass, bridge.ts:3-5), sans dépendance.

4.7 La boucle de développement

recit app dev [dir] :

  1. lit app: et le port du site (port du config, sinon celui de recit-site dev) ;
  2. sonde le port (isPortInUse) ; s'il répond, réutilise le serveur, sinon lance recit-site dev --port <p> dans le dossier du site ;
  3. matérialise la coquille avec devUrl: http://localhost:<p> et lance tauri dev --config.

Le serveur de dev du framework accepte tout nom d'hôte depuis le 29/10 (allowedHosts: true, packages/site/src/vite/config.ts:130) et écoute sur toutes les interfaces (packages/site/src/vite/dev-server.ts:288) : tauri android dev et tauri ios dev réécrivent devUrl vers l'adresse LAN de la machine pour l'émulateur ou l'appareil, et le serveur répond. Le HMR passe par la même origine que devUrl, il fonctionne dans la webview.

4.8 Icônes et écran de lancement

materializeShell lance tauri icon <source> -o icons/ quand la source est plus récente que icons/icon.png. La source est app.icon, sinon metadata.favicon du site (SVG accepté ; pour les sites Récit c'est brand/logo/q07-icon.svg). Cela produit les tailles desktop, Android (mipmap-*) et iOS (AppIcon-*), comme dans sass (icons/android, icons/ios).

Pas d'écran de lancement sur desktop : la fenêtre naît visible: false et s'affiche à on_page_load, ce qui évite le blanc. Sur Android 12+, le système dérive le splash de l'icône ; sur iOS, gen/apple fournit un LaunchScreen.storyboard par défaut : on le laisse.

4.9 Mise à jour et distribution

  • Desktop : tauri-plugin-updater + tauri-plugin-process, clés tauri signer generate, TAURI_SIGNING_PRIVATE_KEY en secret CI, latest.json et les binaires sur la GitHub Release de l'application. Opt-in par app.updater ; la coquille n'ajoute les plugins que si la section est présente. Lot APP9.
  • Mobile : les stores assurent la mise à jour ; l'updater est ignoré.
  • Signature : macOS (Developer ID, notarisation) et Windows (certificat) sont des secrets CI ; sans eux, APP6 produit des binaires non signés, suffisants pour tester.

4.10 Decks et films

  • Un deck publié par publish: est déjà dans dist/ : il est dans l'application sans rien faire. La télécommande smartphone (--remote, WebSocket du serveur de dev, packages/cli/src/commands/dev.ts:312) n'a pas de serveur dans l'app : hors périmètre. La console présentateur ouvre une seconde fenêtre par window.open : traitée par le script de §4.6, à vérifier en APP5.
  • Un film (StoryEmbed format=motion) se joue dans la webview par @recit/motion-blocks, déjà dans le bundle. recit render reste hors application.
  • Un deck seul (recit app build slides.md) : la sortie de recit build est déjà relative (base = "./", packages/cli/src/render/build-deck.ts:43), la même coquille la prend comme frontendDist. Lot APP10, optionnel, une fois la coquille stable.

5. Lots

Chaque lot nomme ses fichiers possédés (deux agents ne touchent jamais le même fichier), ce dont il dépend, ce qu'il fournit aux autres, et la vérification qui le déclare fini. Les tailles sont S, M, L. Toolchain : exige Rust et la ligne apt de §3 ; CI : s'exécute sur un runner GitHub.

État au 29/10/2026

Exécuté le jour même par des agents en parallèle (notes détaillées dans journal.md, une par lot) :

LotÉtat
APP0Fait et vérifié : section app:, résolveur, 14 tests, golden du JSON Schema
APP1Fait : paquet @recit/app, coquille compilée ici, 94 tests
APP2Fait : recit app build|dev|preview|android|ios|icon, 105 tests avec APP10 et APP11a
APP3Fait : 5 e2e sur le bundle servi comme Tauri, aucune requête externe
APP4Fait : trois pages de doc, opt-in de site-embed, plans 03 et 06
APP5Fait ici : binaires .deb/.rpm/.AppImage, garde de navigation, fenêtre, cache partagé
APP6Écrit, pas encore exécuté : aucun push depuis la machine de travail
APP7Fait ici sauf appareil et émulateur : .apk signé 21,7 Mo, .aab 15,2 Mo
APP8Écrit (code, workflow, doc), non vérifié faute de Mac
APP9Fait ici : updater signé, cycle 0.1.0 → 0.1.1 prouvé en local, app-release.yaml écrit
APP10Fait : deck seul, --remote refusé ; APP10.2 : seconde fenêtre native et BroadcastChannel prouvés
APP11a, APP11bFaits : intégration CLI, @recit/app, cœur, CI, doc, d'après les constats des lots

APP0 — Le contrat de configuration (S), sans Rust

Dépend de : rien. Parallèle avec : rien, il ouvre la voie.

Fichiers possédés : packages/core/src/config/validate.ts, packages/core/src/config/app.ts (nouveau : resolveAppConfig, ResolvedAppConfig), packages/core/src/config/index.ts (une ligne d'export), packages/core/src/__tests__/app-config.spec.ts (nouveau), et le golden ou instantané du JSON Schema config s'il en existe un.

  • APP0.1 — AppConfigSchema et AppConfig de §4.4, app: z.optional(AppConfigSchema) dans RecitConfigSchema.
  • APP0.2 — resolveAppConfig() avec les défauts et les diagnostics de §4.4 ; aucune exception, des Diagnostic[].
  • APP0.3 — tests : défauts, identifiant inversé depuis canonicalUrlOrigin (docs.recit.dev → dev.recit.docs), identifiant invalide, basePath, cdnUrl, section absente ; defineConfig range app sous RECIT_CONFIG et pas dans les options du site.

Fournit : AppConfigSchema, AppConfig, ResolvedAppConfig, resolveAppConfig depuis @recit/core/config. Les lots suivants ne redéfinissent aucun de ces types.

Vérification :

TerminalBash
pnpm exec vitest run packages/core packages/integrations/site-stories/src/__tests__/site-config.spec.ts pnpm -F @recit/core typecheck

APP1 — Le paquet @recit/app (M), sans Rust

Dépend de : APP0. Parallèle avec : APP4.

Fichiers possédés : packages/tools/app/** (nouveau), pnpm-workspace.yaml (ligne de catalogue @tauri-apps/cli), nx.json (release.projects), .nx/version-plans/version-plan-app.md (nouveau), .gitignore racine (ligne .recit/).

  • APP1.1 — squelette sur le modèle de packages/formats/motion-render : package.json (scope:tool, exports à trois conditions, tsdown), tsconfig.json, tsdown.config.ts, LICENSE.md, README.md court.
  • APP1.2 — tauriConfig() et ses instantanés : desktop, Android, iOS, avec et sans deepLink, updater, devUrl.
  • APP1.3 — shell/ (§4.3) : Rust écrit et relu, non compilé ici ; materializeShell() copie, génère tauri.conf.json, appelle tauri icon si la source a changé ; test sur un dossier temporaire (sans lancer tauri icon : injecté).
  • APP1.4 — serveLikeTauri() : la chaîne de résolution de §4.5, types MIME (.wasm), test unitaire sur un dist/ factice (a.html, b/index.html, repli index.html).
  • APP1.5 — runTauri() et isPortInUse().
  • APP1.6 — version plan prerelease (§8), release.projects, catalogue.

Fournit : l'API de §4.2, figée.

Vérification :

TerminalBash
pnpm exec vitest run packages/tools/app scripts/__tests__/release-workflow.spec.ts pnpm exec nx run @recit/app:build && pnpm -F @recit/app typecheck pnpm check && pnpm publint # frontières, version plans, forme du paquet publié

APP2 — La commande recit app (M), sans Rust

Dépend de : APP1. Parallèle avec : APP3.

Fichiers possédés : packages/cli/src/commands/app.ts (nouveau), packages/cli/src/__tests__/app.spec.ts (nouveau), packages/cli/src/index.ts (seulement les lignes d'enregistrement de la commande), packages/cli/package.json (@recit/app en peer optionnel, comme @recit/motion-render).

  • APP2.1 — recit app build [dir] [--target desktop|android|ios] [--skip-site-build] : resolveAppConfig, recit-site build (spawn dans le dossier du site), materializeShell, tauri build --config. Diagnostics imprimés comme ceux de recit build.
  • APP2.2 — recit app dev [dir] (§4.7).
  • APP2.3 — recit app preview [dir] [--port] : serveLikeTauri(dist), imprime l'URL.
  • APP2.4 — recit app android <init|dev|build> [-- …] et recit app ios <init|dev|build> [-- …] : passe-plat vers tauri android … et tauri ios … dans la coquille, les arguments après -- transmis tels quels (--aab, --export-method), avec le message des prérequis de §3 quand l'outil manque. APP7 et APP8 n'ont donc rien à ajouter à la CLI.
  • APP2.5 — recit app icon [source] : régénère les icônes.
  • APP2.6 — @recit/app absent : message « installez @recit/app » (motif de motion.ts:200-213).
  • APP2.7 — tests avec @recit/app injecté (comme loadRenderer) : enchaînement des étapes, options, codes de sortie.

Fournit : les noms de commandes ci-dessus, que APP4 documente.

Vérification :

TerminalBash
pnpm exec vitest run packages/cli pnpm exec nx run @recit/cli:build && pnpm --filter recit-example-site-embed exec recit app --help

APP3 — Le bundle vérifié en e2e (S), sans Rust

Dépend de : APP1. Parallèle avec : APP2.

Fichiers possédés : e2e/site-app-bundle.spec.ts (nouveau), e2e/ports.ts (une constante APP_BUNDLE_PORT, hors de E2E_PORTS dont chaque clé lance recit-site preview, playwright.config.ts:33), playwright.config.ts (une entrée webServer de plus), e2e/package.json (dépendance @recit/app), e2e/support.ts si un helper est nécessaire.

  • APP3.1 — examples/site-embed/dist servi par serveLikeTauri sur APP_BUNDLE_PORT.
  • APP3.2 — tests : la page d'accueil, une navigation client vers /stories/lorem-ipsum, un rechargement sur cette adresse profonde (fichier .html), une recherche Pagefind qui rend des résultats, /nulle-part qui affiche la page 404 par repli index.html.
  • APP3.3 — hors ligne : toutes les requêtes de la session (page.on("request")) ont l'origine du serveur ; une seule requête externe fait échouer le test.

Vérification :

TerminalBash
pnpm build && pnpm exec playwright test e2e/site-app-bundle.spec.ts pnpm test:e2e:linux # la suite complète, en conteneur, comme CI

APP4 — Documentation, plans et opt-in de l'exemple (S), sans Rust

Dépend de : APP0 (la clé app doit exister pour le typecheck de l'exemple). Parallèle avec : APP1. Les noms de commandes viennent de ce plan (§5, APP2), pas du code.

Fichiers possédés : examples/site-embed/recit.config.ts (section app:), sites/docs/pages/documentation/configuration/app.mdx, app-android.mdx et app-ios.mdx (nouveaux) et les lignes qui les ajoutent dans sites/docs/sidebar.ts, sites/plans/pages/03-packaging.md (app dans l'arborescence §2, la liste §6 et la table des tags §8), sites/plans/pages/06-roadmap.md (une ligne dans P5 renvoyant ici). NOTICE.md n'est pas touché : la coquille est le gabarit de Tauri, pas du code de sass.

  • APP4.1 — app: { productName: "Lorem Ipsum Docs", identifier: "com.example.loremipsum" } dans l'exemple ; le round-trip des récits ne lit pas la config, rien d'autre ne bouge.
  • APP4.2 — la page de doc : installer @recit/app, la section app: champ par champ, les commandes, les prérequis desktop, ce qui marche hors ligne, les limites (basePath, cdnUrl, télécommande). Deux pages sœurs, app-android.mdx et app-ios.mdx, créées avec les prérequis de §3 et la mention « en cours » : APP7 et APP8 les remplissent sans toucher au sidebar.
  • APP4.3 — plans : table des tags, roadmap, et le lien depuis index.mdx (fait avec ce plan).

Vérification :

TerminalBash
pnpm check # frontmatter, liens de sites, version plans pnpm exec nx run docs:build && pnpm -F recit-example-site-embed typecheck

APP5 — La coquille compilée et le premier binaire (M), toolchain

Dépend de : APP1, APP2. Parallèle avec : rien ; si la machine ne compile pas, APP6 le remplace et APP5 devient la lecture des journaux du run (§6).

Fichiers possédés : packages/tools/app/shell/** (reprend la propriété après APP1), packages/tools/app/src/lib-rs.spec.ts ou équivalent si un test lit le Rust.

  • APP5.1 — cargo check de la coquille matérialisée pour examples/site-embed.
  • APP5.2 — recit app build sur Linux : .deb et .AppImage ; l'app s'ouvre (sous xvfb-run si la machine n'a pas d'écran) sur la page d'accueil, navigue, recharge, cherche.
  • APP5.3 — garde de navigation (§4.6) : un lien vers GitHub ouvre le navigateur, la fenêtre reste sur le site ; target="_blank" et la console présentateur d'un deck publié.
  • APP5.4 — fenêtre cachée jusqu'à on_page_load, titre et taille de app:.
  • APP5.5 — RECIT_APP_TARGET_DIR partagé entre deux sites : le second build ne recompile pas Tauri.

Vérification :

TerminalBash
cargo check --manifest-path examples/site-embed/.recit/app/Cargo.toml pnpm --filter recit-example-site-embed exec recit app build && ls examples/site-embed/.recit/app/target/release/bundle

APP6 — CI desktop sur trois systèmes (M), CI

Dépend de : APP2, et APP5 quand la machine compile. Parallèle avec : APP9, APP10 quand APP5 a tourné localement.

Fichiers possédés : .github/workflows/app.yaml (nouveau). Rien d'autre : scripts/__tests__/release-workflow.spec.ts lit déjà tout .github/workflows et exige chaque uses: épinglé par SHA (release-workflow.spec.ts:80-90).

  • APP6.1 — workflow_dispatch, matrice ubuntu-24.04 (apt de §3), macos-15, windows-2025 ; dtolnay/rust-toolchain et Swatinem/rust-cache épinglés ; pnpm install, build du site, recit app build, actions/upload-artifact.
  • APP6.2 — un test de fumée par système : le binaire démarre et quitte (xvfb-run sur Linux).
  • APP6.3 — secrets optionnels de signature (macOS, Windows) : le job passe sans eux.

Vérification : un run vert avec trois artefacts téléchargeables ; pnpm exec vitest run scripts/__tests__/release-workflow.spec.ts.

APP7 — Android (L), toolchain / CI

Dépend de : APP6. Parallèle avec : APP8.

Fichiers possédés : packages/tools/app/src/android.ts (nouveau : keystore depuis l'environnement, gen/android/keystore.properties jamais commité), .github/workflows/app-android.yaml (nouveau), sites/docs/pages/documentation/configuration/app-android.mdx (créée par APP4).

  • APP7.1 — recit app android init sur un poste équipé ou en CI (android-actions/setup-android épinglé) ; minSdkVersion depuis app.android.
  • APP7.2 — recit app android dev contre recit-site dev sur le LAN (§4.7), émulateur.
  • APP7.3 — recit app android build -- --aab signé par un keystore de test ; l'.apk s'installe et le site tourne hors ligne (mode avion).
  • APP7.4 — tailles : poids de l'.apk d'examples/site-embed noté dans le journal.

Vérification : run CI vert avec .apk et .aab ; installation manuelle vérifiée une fois.

APP8 — iOS (L), Mac / CI

Dépend de : APP6. Parallèle avec : APP7.

Fichiers possédés : packages/tools/app/src/ios.ts (nouveau : developmentTeam depuis app.ios ou APPLE_DEVELOPMENT_TEAM), .github/workflows/app-ios.yaml (nouveau), sites/docs/pages/documentation/configuration/app-ios.mdx (créée par APP4).

  • APP8.1 — recit app ios init sur Mac ; simulateur avec recit app ios dev.
  • APP8.2 — recit app ios build -- --export-method app-store-connect avec une équipe de test.
  • APP8.3 — LaunchScreen et icônes vérifiés ; hors ligne en mode avion.

Vérification : run CI macOS vert avec un .ipa ; TestFlight une fois les comptes tranchés (§10).

APP9 — Mise à jour automatique desktop (M), toolchain, optionnel

Dépend de : une coquille compilée (APP5 ou APP6). Parallèle avec : APP7, APP8, APP10.

Fichiers possédés : packages/tools/app/shell/Cargo.toml et shell/src/lib.rs (sections updater, conditionnelles ; tauriConfig() sait déjà écrire plugins.updater depuis APP1.2), .github/workflows/app-release.yaml (nouveau : latest.json, signature, GitHub Release), sans toucher à app.yaml.

  • APP9.1 — tauri signer generate, clé publique dans app.updater.pubkey, privée en secret.
  • APP9.2 — vérification au lancement, dialogue natif, redémarrage (tauri-plugin-process).
  • APP9.3 — un cycle complet entre deux versions de test.

Vérification : une app en version n se met à jour en n+1 depuis une GitHub Release de test.

APP10 — Deck seul, console présentateur (M), toolchain, optionnel

Dépend de : une coquille compilée (APP5 ou APP6). Parallèle avec : APP7 à APP9.

Fichiers possédés : packages/cli/src/commands/app.ts (reprend la propriété après APP2 : branche « fichier de récit » de recit app build), packages/tools/app/src/deck.ts (nouveau), les tests correspondants.

  • APP10.1 — recit app build slides.md : recit build puis la coquille avec frontendDist sur la sortie du deck ; app: lue dans le recit.config du deck (même schéma).
  • APP10.2 — console présentateur en seconde fenêtre native.
  • APP10.3 — --remote : diagnostic clair, « pas de télécommande dans l'application ».

Vérification : pnpm exec vitest run packages/cli packages/tools/app ; un deck d'examples/ en .AppImage.

6. Exécution multi-agent

Le plan s'exécute par plusieurs agents en parallèle sur le même arbre de travail, et d'autres sessions peuvent éditer des fichiers en même temps. Règles pour chaque agent :

  • Relire avant d'écrire. Chaque fichier est relu juste avant d'être modifié ; si son contenu a changé entre la lecture et l'écriture, relire et refaire la modification sur le nouveau contenu. Dans journal.md et index.mdx, on ajoute ses lignes, on ne réordonne ni ne réécrit rien, et on ne rétablit jamais un changement qu'on n'a pas fait.
  • Un lot, un agent, ses fichiers. La liste « fichiers possédés » de §5 est exhaustive : un fichier qui manque se demande au coordinateur avant d'y toucher. Les fichiers partagés (packages/cli/src/index.ts, nx.json, pnpm-workspace.yaml, .gitignore, e2e/ports.ts, playwright.config.ts) ont chacun un propriétaire.
  • Jamais de git (commit, push, add, stash, branche, worktree), jamais pnpm fix ni oxfmt sur tout le dépôt : les sources Markdown de sites/*/pages et examples/** en sont exclues et une mise en forme les casse. pnpm check et les vérifications de son lot seulement.
  • Une note dans journal.md à la fin du lot, ajoutée à la fin du fichier sous un titre ### APPn — <titre> — <date> : ce qui est fait, ce qui est mesuré (commandes et résultats), ce qui reste, et tout piège rencontré. Le prochain agent commence par la lire.
  • Le contrat ne bouge pas : un lot qui a besoin d'un champ de app: ou d'une fonction de @recit/app qui n'existe pas l'écrit dans le journal et s'arrête, il ne modifie pas APP0 ni APP1.

Les vagues :

VagueLots en parallèleCondition d'entréeMachine
0APP0—ici
1APP1, APP4APP0 vérifié (vitest core + site-config.spec.ts, typecheck)ici
2APP2, APP3APP1 vérifiéici
3APP5, ou APP6APP2 vérifié ; APP5 si rustup et la ligne apt sont là, sinon APP6 d'embléetoolchain, ou CI
4APP6, APP9, APP10une coquille compilée (APP5 fait ici, ou le premier run vert de APP6)CI / toolchain
5APP7, APP8APP6 vertCI / poste équipé

Si la chaîne Rust n'est pas installée à la vague 3, APP6 compile la coquille pour la première fois sur le runner, et APP5 se réduit à lire les journaux du run et à corriger la coquille ; la vague 4 ne contient plus que APP9 et APP10.

7. Tests et vérification

NiveauQuoiOùRust
Unitaireschéma, défauts, diagnostics de app:packages/core/src/__tests__/app-config.spec.tsnon
UnitairetauriConfig par instantané, materializeShell, serveLikeTauripackages/tools/app/src/__tests__/non
Unitaireenchaînement de recit app … avec @recit/app injectépackages/cli/src/__tests__/app.spec.tsnon
e2ebundle servi comme Tauri : navigation, rechargement profond, recherche, 404, aucune requête externee2e/site-app-bundle.spec.tsnon
Compilationcargo check de la coquille matérialiséeAPP5, puis chaque job de APP6oui
Fuméele binaire démarre et quitte, sur trois systèmesAPP6oui
Manuelliens externes, window.open, mode avion sur mobileAPP5, APP7, APP8, notés dans le journaloui

pnpm verify reste la porte : APP0 à APP4 ne la ralentissent que de leurs tests unitaires ; aucun lot n'ajoute Rust à pnpm verify ni à build.yaml. La compilation native vit dans app.yaml, lancé à la main.

8. Version plan

@recit/app devient un paquet publié : il entre dans release.projects de nx.json (vérifié par scripts/__tests__/release-workflow.spec.ts) et prend un version plan. Tous les paquets partagent une version (projectsRelationship: "fixed") ; en 0.x seul preminor (première release d'une phase) ou prerelease est accepté (CONTRIBUTING.md §5, scripts/check-version-plans.js). Ce plan ne crée pas de phase : .nx/version-plans/version-plan-app.md en prerelease, pour @recit/core (la section app:), @recit/cli (recit app) et @recit/app, écrit par APP1. APP10 ajoute un second plan prerelease si APP1 est déjà publié.

9. Risques

RisqueParade
Pagefind sous le protocole tauri:// : fetch du .wasm et des fragments depuis un schéma personnaliséAPP3 le prouve sous serveLikeTauri (HTTP) ; APP5 le vérifie sous le vrai protocole sur Linux (WebKitGTK) et APP6 sur macOS (WKWebView) et Windows (WebView2) ; en dernier recours, un serveur local embarqué (tauri-plugin-localhost), qui coûte une dépendance
window.open et target="_blank" hors de on_navigationscript d'initialisation (§4.6) ; test manuel APP5.3
Un site avec basePath ou cdnUrlrefusé avec diagnostic dès APP0 ; un support ultérieur passerait par un build du site avec basePath forcé à ""
Poids des binaires (Mermaid, Shiki, KaTeX, polices)mesuré en APP5.2 et APP7.4, noté au journal ; les langages Shiki sont déjà une liste par site (site-kit/src/config.tsx:111-123)
@tauri-apps/cli : binaire natif par plateforme, lourddépendance de @recit/app seulement, chargé à la demande par recit ; pnpm-workspace.yaml l'épingle
Cache Cargo (gigaoctets) dans chaque siteRECIT_APP_TARGET_DIR (§4.3), ignoré par git
Pas d'écran, pas de Rust sur la machine de travail§3 et l'ordre des vagues ; APP6 porte la compilation sur GitHub
Les sites Récit renvoient entre eux par des URL absolues (siteUrl)ouverts dans le navigateur par la garde ; une version « tout en un » (plusieurs sites dans une app) n'est pas prévue
Stores : identifiants figés, comptes, revuesidentifier documenté comme définitif ; question ouverte Q2

10. Questions ouvertes

  1. Quel site en premier ? Proposé : examples/site-embed pour les tests, sites/docs pour la première application distribuée (une doc hors ligne a un sens). plans est interne.
  2. Les stores. Compte Apple Developer et Google Play : ceux de Récit ? Pour quels sites ? La recommandation de sass (Q12) tient : le mobile à la demande, le site est déjà responsive. Décider avant APP7.
  3. Le nom du paquet : @recit/app (proposé) ou @recit/native ; le dossier généré .recit/app/.
  4. La chaîne Rust ici. La ligne apt de §3 demande sudo : l'installer sur cette machine, ou réserver la compilation à la CI (APP6) ?
  5. Place dans la feuille de route. Proposé : APP0 à APP6 pendant P5 et P6 en prerelease ; le mobile (APP7, APP8) après la 1.0 sauf demande. 06-roadmap.md reçoit une ligne en P5 (APP4).
  6. Une app par site, ou une app Récit qui embarque plusieurs sites ? Ce plan fait une app par site, comme sass ; l'autre forme demanderait un routeur multi-sites qui n'existe pas.