Commit 106c2d6d authored by Kourser's avatar Kourser
Browse files

Application mobile : iOS, jalon M0 côté téléphone

Le volet mobile disait « aucune ligne de code mobile n'est écrite à ce jour ».
Elle l'est maintenant, pour iOS d'abord — Android suit sur la même base.

Ce que l'application fait, et rien d'autre
  Choisir son instance, l'enrôler, et dire si ce téléphone est réellement
  joignable. C'est tout M0 : « le tuyau, avant l'application, éprouvé par une
  application qui n'affiche qu'un écran ». Les six gestes du chemin critique
  viennent à M1 et M2, pas avant, et pas par dérive.

React Native outillé par Expo, comme tranché au §9
  Le paquet partagé apporte les schémas, les types et la validation ; jamais un
  composant d'interface (EM-39). Aucune logique de domaine n'est réécrite sur
  l'appareil : le téléphone transporte une intention, le serveur inscrit
  l'événement (EM-30, EM-40).

Deux défauts trouvés en le faisant tourner, pas en le relisant
  Le heurtoir n'arrivait jamais. La lecture cherchait la charge dans
  « content.data », ce qui marche pour une notification locale — mais vaut null
  pour une poussée APNs réelle, le seul cas qui compte. La bannière
  s'affichait, l'application n'en savait rien. Rien d'autre ne clochait : c'est
  exactement le genre de défaut qu'une relecture ne voit pas.

  Au deuxième lancement, l'écran annonçait « ce téléphone ne sonnera pas » à
  quelqu'un dont le téléphone sonnait. L'autorisation n'était pas relue auprès
  du système, et l'inconnu était affiché comme un refus. Sur un outil de crise,
  une fausse inquiétude fait chercher un problème qui n'existe pas, la nuit, à
  la place du vrai.

  Le premier est désormais une garantie exécutée : la lecture du heurtoir vit
  dans un module sans dépendance au système, et la suite l'éprouve sur une
  charge APNs capturée sur un simulateur. Le second a produit un troisième état
  — « vérification… » — parce que « pas encore su » n'est pas « refusé ».

Ce que la suite vérifie maintenant, sans simulateur ni appareil
  647 garanties, dont 45 nouvelles : aucune adresse d'instance compilée, aucune
  empreinte calculée sur l'appareil, les secrets exclus des sauvegardes du
  système, l'étanchéité des compartiments d'instance, les cibles tactiles, les
  contrastes AA des deux thèmes mesurés paire par paire, et les 79 chaînes
  toutes traduites.

Éprouvé sur un iPhone simulé, contre l'instance de développement : sonde
d'état, enrôlement par code délivré par le serveur, autorisation accordée,
jeton déclaré, heurtoir reçu et affiché — référence opaque et degré d'urgence,
rien d'autre.

Ce qui manque à M0 ne s'écrit pas ici : le relais qui détient la clé APNs, un
compte éditeur Apple, et la demande d'alerte critique — dont l'instruction
commande le calendrier.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent e38ce436
Loading
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ import { verifierLicence } from "./verification/licence.js";
import { verifierInterface } from "./verification/interface.js";
import { verifierAccessibilite } from "./verification/accessibilite.js";
import { verifierMobile } from "./verification/mobile.js";
import { verifierApplicationMobile } from "./verification/application-mobile.js";
import { verifierCanaux } from "./verification/canaux.js";
import { verifierContinuite } from "./verification/continuite.js";
import { verifierVeille } from "./verification/veille.js";
@@ -310,6 +311,9 @@ await verifierAccessibilite(rapport);
// ── Appareils mobiles (volet mobile, M0-M1) ───────────────────────────────
await verifierMobile(sql, app, rapport);

// ── L'application elle-même, relue sans simulateur (EM-41) ────────────────
await verifierApplicationMobile(rapport);

// ── Canaux de diffusion et envois de masse (§6.11) ────────────────────────
await verifierCanaux(sql, app, rapport);

+268 −0
Original line number Diff line number Diff line
import { readdir, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import { contraste } from "./accessibilite.js";
import type { Rapport } from "./rapport.js";

/**
 * Contrôles statiques sur les sources de l'application mobile.
 *
 * EM-41 : « le code mobile vit dans le même dépôt, sous la même licence, avec
 * la même vérification en intégration continue. » Sans cela, l'ouverture
 * annoncée s'arrêterait au serveur.
 *
 * Ce qui se vérifie ici est ce qui se lit dans le code sans simulateur, sans
 * appareil et sans magasin : les frontières que l'application s'est données.
 * Ce sont précisément celles qui s'érodent en silence — un jour quelqu'un
 * ajoute le titre de la crise dans la notification « pour que ce soit plus
 * clair », et le heurtoir devient un message.
 *
 * Ce qui ne se vérifie pas ici, et qu'on ne prétend donc pas couvrir : le
 * comportement réel sur un appareil, la remise effective d'une notification,
 * et tout ce qui dépend d'Apple ou de Google.
 */

const mobile = fileURLToPath(new URL("../../../mobile/", import.meta.url));

async function sources(dossier: string): Promise<string[]> {
  const entrees = await readdir(dossier, { withFileTypes: true });
  const fichiers: string[] = [];
  for (const e of entrees) {
    if (e.name === "node_modules" || e.name === "ios" || e.name === "android") continue;
    const chemin = join(dossier, e.name);
    if (e.isDirectory()) fichiers.push(...await sources(chemin));
    else if (/\.tsx?$/.test(e.name)) fichiers.push(chemin);
  }
  return fichiers;
}

/** Les chaînes soumises au traducteur, avec leurs concaténations recollées. */
function chainesTraduites(texte: string): string[] {
  const trouvees: string[] = [];
  // Le « \b » importe : sans lui, « split( » et « ecrireBrut( » se font passer
  // pour des appels au traducteur, et la couverture devient un mensonge.
  const appel = /\bt\(\s*("(?:[^"\\]|\\.)*"(?:\s*\+\s*"(?:[^"\\]|\\.)*")*)/g;
  for (const m of texte.matchAll(appel)) {
    const morceaux = [...m[1]!.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((x) => x[1]!);
    trouvees.push(morceaux.join(""));
  }
  return trouvees;
}

/** Les clés déclarées dans le dictionnaire anglais, calculées ou non. */
function clesTraduites(texte: string): Set<string> {
  const cles = new Set<string>();
  const entree = /^ {2}\[?("(?:[^"\\]|\\.)*"(?:\s*\n?\s*\+\s*"(?:[^"\\]|\\.)*")*)\]?\s*:/gm;
  for (const m of texte.matchAll(entree)) {
    const morceaux = [...m[1]!.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((x) => x[1]!);
    cles.add(morceaux.join(""));
  }
  return cles;
}

/**
 * Le code seul, sans les commentaires.
 *
 * Les contrôles qui suivent cherchent ce que l'application *fait*. Un exemple
 * d'adresse cité dans un commentaire explicatif n'est pas une adresse
 * compilée, et le compter comme telle apprendrait surtout à ne plus rien
 * expliquer dans les commentaires.
 *
 * Seuls les commentaires occupant une ligne entière sont retirés : découper
 * une ligne mêlant code et commentaire demanderait d'analyser la syntaxe, et
 * un analyseur approximatif rognerait des « https:// » à l'intérieur de
 * chaînes de caractères — c'est-à-dire précisément ce qu'on cherche.
 */
function sansCommentaires(texte: string): string {
  return texte
    .replace(/\/\*[\s\S]*?\*\//g, "")
    .replace(/^\s*(\/\/|\*).*$/gm, "");
}

function palette(texte: string, nom: string): Record<string, string> {
  const i = texte.indexOf(`export const ${nom}: Palette = {`);
  if (i < 0) return {};
  const bloc = texte.slice(i, texte.indexOf("};", i));
  return Object.fromEntries(
    [...bloc.matchAll(/(\w+):\s*"(#[0-9a-fA-F]{3,8})"/g)].map((m) => [m[1]!, m[2]!]));
}

export async function verifierApplicationMobile(r: Rapport): Promise<void> {
  r.titre("Application mobile — contrôles sur les sources");

  const fichiers = (await sources(join(mobile, "src")));
  const lus = new Map<string, string>();
  for (const f of fichiers) lus.set(f.slice(mobile.length), await readFile(f, "utf8"));

  r.verifie("EM-41", `${lus.size} sources d'application mobile relues`, lus.size >= 12);

  // ── EM-03 : la même API que le web, sans point de terminaison réservé ────
  const cheminsHorsApi: string[] = [];
  for (const [nom, texte] of lus) {
    for (const m of texte.matchAll(/chemin:\s*[`"'](\/[^`"'$]*)/g)) {
      if (!m[1]!.startsWith("/api/")) cheminsHorsApi.push(`${nom}${m[1]}`);
    }
    for (const m of texte.matchAll(/\/api\/(mobile|m)\//g)) {
      cheminsHorsApi.push(`${nom} → espace réservé au mobile : ${m[0]}`);
    }
  }
  r.verifie("EM-03", "aucun point de terminaison réservé au mobile",
    cheminsHorsApi.length === 0, cheminsHorsApi.slice(0, 4).join(", "));

  // ── EM-08 : le heurtoir ne transporte rien ──────────────────────────────
  //
  // La charge utile d'une notification ne contient jamais de nom
  // d'organisation, d'intitulé de crise ni de texte de message. Côté serveur
  // le type l'interdit ; côté application, rien n'empêcherait de lire un champ
  // « title » qui n'aurait jamais dû être envoyé — sauf ceci.
  const lecture = sansCommentaires(
    (lus.get("src/appareil/poussee.ts") ?? "") + (lus.get("src/appareil/heurtoir.ts") ?? ""));
  const champsInterdits = ["title", "body", "subtitle", "message", "organisation", "crise"]
    .filter((c) => new RegExp(`content\\.${c}|\\["${c}"\\]|\\.${c}\\b`).test(lecture));
  r.verifie("EM-08", "l'application ne lit aucun contenu dans une notification",
    champsInterdits.length === 0, champsInterdits.join(", "));

  // ── EM-08, EM-09 : le heurtoir est lu là où le système le range ─────────
  //
  // Ce contrôle-ci n'est pas statique : il exécute la lecture réelle sur une
  // charge APNs capturée sur un simulateur. Il existe parce que la première
  // version lisait « content.data », qui vaut null pour une poussée distante —
  // le seul cas qui compte. Tout fonctionnait par ailleurs : la bannière
  // s'affichait, l'application n'en savait rien. Aucun contrôle statique
  // n'aurait vu cela.
  //
  // L'import est dynamique et calculé : le fichier vit hors de ce paquet, et
  // le compilateur du serveur n'a pas à connaître les sources du mobile.
  const heurtoir = new URL("../../../mobile/src/appareil/heurtoir.ts", import.meta.url).href;
  const { lireCharge } = await import(heurtoir) as {
    lireCharge: (sources: readonly unknown[], recu_at?: string)
      => { reference: string; urgence: string; recu_at: string } | null;
  };

  // Relevée telle quelle sur un iPhone simulé, le 24 août 2026.
  const poussseeReelle = {
    payload: {
      aps: { alert: { title: "Kastell" }, sound: "default", "interruption-level": "time-sensitive" },
      urgence: "mobilisation",
      reference: "9f2c1ad4-6b0e-4a7c-9d31-88e0a15c2f77",
    },
    data: null,
  };

  const lu = lireCharge([poussseeReelle.payload, poussseeReelle.data]);
  r.verifie("EM-09", "une poussée APNs réelle est lue malgré un content.data nul",
    lu?.reference === "9f2c1ad4-6b0e-4a7c-9d31-88e0a15c2f77" && lu.urgence === "mobilisation");
  r.verifie("EM-09", "une notification locale, elle, porte sa charge dans content.data",
    lireCharge([undefined, { reference: "abc", urgence: "arbitrage" }])?.urgence === "arbitrage");
  r.verifie("EM-08", "une charge sans référence n'est pas un heurtoir",
    lireCharge([{ urgence: "mobilisation" }]) === null);
  r.verifie("EM-08", "un degré d'urgence inconnu est refusé",
    lireCharge([{ reference: "abc", urgence: "urgentissime" }]) === null);
  r.verifie("EM-08", "rien d'autre que la référence et l'urgence n'est retenu",
    Object.keys(lireCharge([{ reference: "abc", urgence: "information",
      titre: "Fuite de données chez ACME" }]) ?? {}).sort().join(",")
      === "recu_at,reference,urgence");

  // ── EM-30 : aucune preuve n'est produite sur l'appareil ─────────────────
  const preuvesLocales: string[] = [];
  for (const [nom, texte] of lus) {
    if (/createHash|sha256|SHA-256|digestString|signer\(/.test(sansCommentaires(texte))) {
      preuvesLocales.push(nom);
    }
  }
  r.verifie("EM-30", "aucune empreinte ni signature n'est calculée sur l'appareil",
    preuvesLocales.length === 0, preuvesLocales.join(", "));

  // ── EM-75 : aucune adresse d'instance compilée ──────────────────────────
  const adressesEnDur: string[] = [];
  for (const [nom, texte] of lus) {
    for (const m of sansCommentaires(texte).matchAll(/https?:\/\/[a-z0-9.-]+/gi)) {
      const a = m[0]!;
      // L'adresse du dépôt est une offre de code, pas une instance (EM-74) ;
      // les exemples de champ de saisie ne sont pas des adresses compilées.
      if (a.includes("git.cythin.eu") || a.includes("localhost")) continue;
      adressesEnDur.push(`${nom}${a}`);
    }
  }
  r.verifie("EM-75", "aucune adresse d'instance n'est inscrite dans l'application",
    adressesEnDur.length === 0, adressesEnDur.slice(0, 4).join(", "));

  // ── EM-78 : seules les adresses chiffrées sont acceptées ────────────────
  const adresse = lus.get("src/instance/adresse.ts") ?? "";
  r.verifie("EM-78", "une adresse non chiffrée est refusée",
    /non_chiffree/.test(adresse) && /url\.protocol !== "https:"/.test(adresse));
  r.verifie("EM-78", "l'exception locale est bornée aux constructions de développement",
    /if \(!__DEV__\) return false;/.test(adresse));
  r.verifie("EM-79", "un chemin résiduel dans l'adresse est refusé",
    /motif: "chemin"/.test(adresse));

  // ── EM-19, EM-61 : le secret d'appareil ne sort pas du trousseau ────────
  const registre = lus.get("src/instance/registre.ts") ?? "";
  r.verifie("EM-61", "les secrets sont écrits sans jamais quitter cet appareil",
    /WHEN_UNLOCKED_THIS_DEVICE_ONLY/.test(registre));
  r.verifie("EM-80", "chaque instance a son compartiment de stockage",
    /export function prefixe/.test(registre));
  r.verifie("EM-86", "quitter une instance efface toutes ses clés",
    /export async function oublier/.test(registre) && /CLES_COMPARTIMENT/.test(registre));

  // ── EM-64 : cibles tactiles ─────────────────────────────────────────────
  const theme = lus.get("src/theme.ts") ?? "";
  const cible = Number(/cible:\s*(\d+)/.exec(theme)?.[1] ?? 0);
  r.verifie("EM-64", "les cibles tactiles mesurent au moins 48 points",
    cible >= 48, `mesure déclarée : ${cible}`);
  const composants = lus.get("src/ui/composants.tsx") ?? "";
  r.verifie("EM-64", "boutons et champs reprennent cette mesure",
    (composants.match(/minHeight: MESURES\.cible/g) ?? []).length >= 2);

  // ── EM-65 : les deux thèmes tenus à égalité ─────────────────────────────
  const clair = palette(theme, "CLAIR");
  const sombre = palette(theme, "SOMBRE");
  const manquantes = Object.keys(clair).filter((c) => !(c in sombre));
  r.verifie("EM-65", "le thème sombre définit tout ce que définit le thème clair",
    Object.keys(clair).length > 8 && manquantes.length === 0, manquantes.join(", "));

  // Contraste AA sur du texte courant : 4,5. Les paires contrôlées sont celles
  // qu'un écran affiche réellement — un jeton défini mais jamais posé sur ce
  // fond-là ne prouverait rien.
  const paires: [string, string][] = [
    ["texte", "fond"], ["texte", "surface"],
    ["texteAttenue", "fond"], ["texteAttenue", "surface"],
    ["accent", "fond"], ["alerte", "fond"], ["succes", "fond"],
    ["arbitrage", "fond"], ["information", "fond"],
    ["surAccent", "accent"],
  ];
  for (const [avant, arriere] of paires) {
    for (const [nom, p] of [["clair", clair], ["sombre", sombre]] as const) {
      const a = p[avant]; const b = p[arriere];
      if (!a || !b) { r.verifie("EM-64", `${avant} sur ${arriere} (${nom})`, false, "jeton absent"); continue; }
      const ratio = contraste(a, b);
      r.verifie("EM-64", `${avant} sur ${arriere} (${nom})`, ratio >= 4.5, `${ratio.toFixed(2)}:1`);
    }
  }

  // ── EM-66 : bilinguisme, mesuré et non déclaré ──────────────────────────
  const anglais = clesTraduites(
    await readFile(join(mobile, "src/langues/en.ts"), "utf8"));
  const attendues = new Set<string>();
  for (const [nom, texte] of lus) {
    if (nom.startsWith("src/langues/")) continue;
    for (const c of chainesTraduites(texte)) attendues.add(c);
  }
  const absentes = [...attendues].filter((c) => !anglais.has(c));
  r.verifie("EM-66", `${attendues.size} chaînes présentées au traducteur`, attendues.size > 50);
  r.verifie("EM-66", "toutes sont traduites en anglais",
    absentes.length === 0, absentes.slice(0, 3).map((s) => ${s.slice(0, 40)}… »`).join(", "));

  // ── EM-42, EM-74 : la clause réseau ne s'arrête pas au navigateur ───────
  const apropos = lus.get("src/ecrans/APropos.tsx") ?? "";
  r.verifie("EM-74", "l'écran « à propos » affiche la licence et l'adresse du code",
    /licence/i.test(apropos) && /source/.test(apropos));
  r.verifie("EM-42", "il affiche aussi la révision de la construction",
    /revision/.test(apropos));

  const configuration = await readFile(join(mobile, "app.config.ts"), "utf8");
  r.verifie("EM-42", "la révision est inscrite à la construction, pas devinée",
    /git rev-parse HEAD/.test(configuration));
  r.verifie("EM-43", "un plancher de version de système est déclaré",
    /deploymentTarget/.test(configuration) && /minSdkVersion/.test(configuration));
}

apps/mobile/.gitignore

0 → 100644
+10 −0
Original line number Diff line number Diff line
# Dossiers natifs engendrés par « expo prebuild » : ils se reconstruisent, et
# les versionner ferait diverger la configuration déclarée dans app.config.ts
# de ce que la construction produit réellement.
/ios
/android

node_modules/
.expo/
dist/
*.log

apps/mobile/README.md

0 → 100644
+103 −0
Original line number Diff line number Diff line
# Kastell Mobile

Les applications iOS et Android de Kastell. Elles n'emportent pas le cockpit
dans une poche : elles font les gestes qu'un téléphone fait mieux qu'un
ordinateur — réveiller quelqu'un à trois heures du matin, et servir quand il
n'y a plus de réseau d'entreprise.

Le périmètre, les six gestes et les exigences EM-01 à EM-86 sont dans le volet
mobile du cahier des charges.

## Où en est le développement

**Jalon M0 — « le tuyau, avant l'application ».** Côté serveur : enrôlement,
identité d'appareil, révocation, heurtoir, registre — éprouvés par la suite de
garanties. Côté application : choix de l'instance, enrôlement depuis le web,
autorisation de notifier, déclaration du jeton, réception d'un heurtoir.

Ce qui manque encore à M0, et qui ne dépend pas du code de ce dossier :

- le **relais de heurtoir** qui détient la clé APNs et parle à Apple (EM-84) ;
- un **compte éditeur Apple**, sans lequel aucune notification ne part vers un
  appareil réel ;
- la **demande d'alerte critique** (EM-12), instruite par Apple en plusieurs
  semaines — c'est elle qui commande le calendrier, pas le code.

## Faire tourner l'application

Il faut Xcode et CocoaPods. Depuis la racine du dépôt :

```
pnpm install
pnpm --filter @kastell/mobile ios
```

La première exécution engendre le dossier `ios/` et installe les pods ; elle
prend quelques minutes. Les dossiers natifs ne sont pas versionnés : ils se
reconstruisent depuis `app.config.ts`, qui fait foi.

Ensuite, pour ne relancer que le serveur de développement :

```
pnpm --filter @kastell/mobile dev
```

### Se raccorder à une instance locale

L'application n'embarque aucune adresse (EM-75). Saisissez celle de votre
instance ; `localhost:8080` fonctionne depuis le simulateur, qui partage la
pile réseau de la machine. Les adresses non chiffrées ne sont acceptées que
dans une construction de développement, et seulement pour un hôte local
(EM-78).

Le code d'enrôlement se demande depuis une session web complète : **Sécurité**,
puis **Appareils mobiles**. Aucun chemin ne permet d'enrôler un téléphone en
partant du seul téléphone (EM-18).

### Éprouver un heurtoir sur simulateur

Apple ne remet aucune notification réelle à un simulateur, mais le système sait
en simuler une :

```
xcrun simctl push <udid> bzh.kourser.kastell heurtoir.json
```

avec un fichier de la forme :

```json
{
  "Simulator Target Bundle": "bzh.kourser.kastell",
  "aps": { "alert": { "title": "Kastell" }, "sound": "default" },
  "reference": "9f2c1ad4-6b0e-4a7c-9d31-88e0a15c2f77",
  "urgence": "mobilisation"
}
```

La charge ne contient ni intitulé de crise, ni organisation, ni personne : un
identifiant opaque et un degré d'urgence, rien d'autre (EM-08).

## Ce que la vérification couvre

`pnpm verif` relit ces sources et éprouve la lecture d'un heurtoir sur une
charge APNs réelle, sans simulateur ni appareil. Elle contrôle aussi les
contrastes des deux thèmes, la couverture des traductions, les cibles
tactiles, l'absence d'adresse compilée et l'étanchéité des compartiments
d'instance.

Ce qu'elle ne couvre pas, et qu'aucun script ne couvrira : la remise effective
d'une notification par Apple, et le comportement sur un appareil réel.

## Ce qui n'est pas encore de nous

Les icônes de `assets/` sont celles du gabarit Expo. Elles sont là pour que la
construction aboutisse, pas pour représenter le produit : une application de
crise portant le logo de son outillage se reconnaîtrait mal sur un écran
d'accueil, et se soumettrait mal à un magasin.

## Décisions ouvertes

L'identifiant de paquet — `bzh.kourser.kastell` — est une valeur de
développement, pas une décision. Il engage l'éditeur, apparaît dans la demande
d'alerte critique et ne se change plus après la première soumission. Le cahier
des charges le note comme décision à trancher avant M0.
+122 −0
Original line number Diff line number Diff line
import { execSync } from "node:child_process";
import type { ExpoConfig } from "expo/config";

/**
 * Configuration de l'application — Kastell Mobile.
 *
 * Ce fichier est évalué par Node au moment de la construction, jamais sur
 * l'appareil : il peut donc interroger git, ce que le code embarqué ne peut
 * pas faire.
 *
 * Deux exigences le gouvernent.
 *
 *   EM-42 : l'empreinte de la révision est affichée dans l'écran « à propos »,
 *   comme la sonde d'état le fait pour le serveur. Une application libre dont
 *   on ne peut pas dire à quel code correspond le binaire installé ne tient
 *   pas la promesse de l'AGPL.
 *
 *   EM-75 : aucune adresse d'instance n'est compilée dans l'application, y
 *   compris celle de l'offre hébergée. Une application libre qui embarque
 *   l'adresse de son éditeur privilégie son service ; la suggestion, si elle
 *   existe un jour, se règle ici et reste vide par défaut.
 */

function revision(): string | null {
  // La construction peut se faire hors dépôt — une archive, un artefact de
  // magasin. Dans ce cas on ne prétend pas connaître la révision : l'écran
  // « à propos » dira qu'elle est absente, plutôt qu'une valeur inventée.
  if (process.env["KASTELL_REVISION"]?.trim()) return process.env["KASTELL_REVISION"]!.trim();
  try {
    return execSync("git rev-parse HEAD", { stdio: ["ignore", "pipe", "ignore"] })
      .toString().trim() || null;
  } catch {
    return null;
  }
}

/**
 * L'identifiant de paquet engage l'éditeur : il apparaît dans la demande
 * d'alerte critique, dans les certificats de poussée, et il ne se change plus
 * après la première soumission. Le cahier des charges le note comme décision
 * ouverte — « publier sous Kourser, ou sous une entité dédiée ? », à trancher
 * avant M0. Ce qui suit est une valeur de développement, pas une décision.
 */
const paquet = process.env["KASTELL_BUNDLE_ID"]?.trim() || "bzh.kourser.kastell";

const config: ExpoConfig = {
  name: "Kastell",
  slug: "kastell",
  version: "0.1.0",
  orientation: "portrait",
  icon: "./assets/icon.png",
  scheme: "kastell",
  userInterfaceStyle: "automatic",   // EM-65 : le thème suit le système
  newArchEnabled: true,
  // EM-66 : français et anglais dès la première version. Ce champ règle la
  // langue des demandes d'autorisation présentées par le système.
  locales: { fr: "./src/langues/ios-fr.json", en: "./src/langues/ios-en.json" },

  ios: {
    bundleIdentifier: paquet,
    supportsTablet: true,            // EM-72 : une tablette est un grand téléphone
    infoPlist: {
      // EM-19 : le déverrouillage quotidien se fait par biométrie. La phrase
      // est celle que l'utilisateur lit dans la demande du système ; EM-37
      // exige qu'elle dise à quoi la permission sert, en une phrase.
      NSFaceIDUsageDescription:
        "Pour ouvrir Kastell sans taper de mot de passe au réveil.",
      // Le heurtoir arrive quand l'application est fermée : sans ce mode,
      // rien ne va chercher le contenu après le réveil (EM-09).
      UIBackgroundModes: ["remote-notification"],
      ITSAppUsesNonExemptEncryption: false,
    },
    entitlements: {
      // Poussée en environnement de développement. La bascule vers
      // « production » se fait à la construction de distribution.
      "aps-environment": "development",
    },
  },

  android: {
    package: paquet,
    edgeToEdgeEnabled: true,
    adaptiveIcon: {
      foregroundImage: "./assets/android-icon-foreground.png",
      backgroundImage: "./assets/android-icon-background.png",
      monochromeImage: "./assets/android-icon-monochrome.png",
    },
  },

  plugins: [
    "expo-secure-store",
    "expo-localization",
    "expo-web-browser",
    ["expo-notifications", { icon: "./assets/icon.png", color: "#B4211E" }],
    ["expo-build-properties", {
      // EM-43 : « un téléphone de secours qui dort dans un tiroir depuis trois
      // ans doit pouvoir installer l'application. »
      //
      // Le cahier des charges annonce iOS 16 ; Expo SDK 57 refuse en deçà de
      // 16.4. L'écart est assumé plutôt que contourné par un SDK plus ancien :
      // iOS 16.4 est sorti en mars 2023, la mise à jour est gratuite depuis
      // n'importe quel iOS 16, et aucun appareil capable d'iOS 16 n'en est
      // exclu. C'est le plancher réel — il est inscrit ici pour que tout écart
      // se voie à la construction et non à la soumission.
      ios: { deploymentTarget: "16.4" },
      android: { minSdkVersion: 29 },   // Android 10
    }],
  ],

  extra: {
    revision: revision(),
    licence: "AGPL-3.0-or-later",
    source: process.env["KASTELL_SOURCE_URL"]?.trim()
      || "https://git.cythin.eu/cythin/kastell",
    /** Version du protocole que cette application sait parler (EM-77). */
    protocole: 1,
    /** EM-75 : vide par défaut, et c'est délibéré. */
    instanceSuggeree: process.env["KASTELL_INSTANCE_SUGGEREE"]?.trim() || null,
  },
};

export default config;
Loading