diff --git a/android/app/src/main/java/com/skohscripts/kairos/MainActivity.java b/android/app/src/main/java/com/skohscripts/kairos/MainActivity.java index fca01e5..0800d1f 100644 --- a/android/app/src/main/java/com/skohscripts/kairos/MainActivity.java +++ b/android/app/src/main/java/com/skohscripts/kairos/MainActivity.java @@ -3,14 +3,20 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.app.AlertDialog; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; import android.view.View; -import android.view.ViewTreeObserver; import android.webkit.JsResult; import android.webkit.WebChromeClient; import android.webkit.WebView; import android.webkit.WebViewClient; +import android.widget.FrameLayout; +import android.widget.ImageView; import android.window.OnBackInvokedDispatcher; import com.chaquo.python.PyObject; @@ -19,7 +25,6 @@ import java.net.HttpURLConnection; import java.net.URL; -import java.util.concurrent.atomic.AtomicBoolean; /** * Unique activité de Kairos : démarre le serveur local (CPython + uvicorn via @@ -33,40 +38,23 @@ */ public class MainActivity extends Activity { + private static final long STARTUP_OVERLAY_TIMEOUT_MS = 30_000; + private static int serverPort = -1; private WebView webView; + private View startupOverlay; private KairosNotificationBridge notificationBridge; - // Tenu à `false` jusqu'à ce que la première page ait fini de charger dans la - // WebView (voir `onPageFinished` ci-dessous). Sans mécanisme de retenue, le - // splash (natif API 31+, ou simple fond `windowBackground` en dessous) se - // ferme/laisse place à la première frame dessinée dès `setContentView` - // ci-dessous, donc avant même que Python/uvicorn n'ait démarré — l'utilisateur - // voit alors une WebView blanche pendant toute l'attente du serveur. Technique - // retenue : `ViewTreeObserver.OnPreDrawListener` (voir onCreate) — reporter la - // toute première frame reporte de fait la disparition du splash, quelle que - // soit l'API, sans dépendre d'une classe spécifique à l'API 31+ ni d'AndroidX - // (`androidx.core.splashscreen.SplashScreen.setKeepOnScreenCondition`, la - // seule à porter ce nom, est hors périmètre — voir docs/ANDROID_PACKAGING.md). - private final AtomicBoolean uiReady = new AtomicBoolean(false); + // Utilisé uniquement pour le filet de sécurité qui masque l'overlay de + // démarrage si `onPageFinished` n'arrive jamais (page en échec) — voir + // `hideStartupOverlay`. + private final Handler overlayHandler = new Handler(Looper.getMainLooper()); @SuppressLint("SetJavaScriptEnabled") @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (!Python.isStarted()) { - Python.start(new AndroidPlatform(this)); - } - final PyObject boot = Python.getInstance().getModule("kairos_boot"); - if (serverPort < 0) { - serverPort = boot.callAttr("prepare", getFilesDir().getAbsolutePath()).toInt(); - final int port = serverPort; - Thread server = new Thread(() -> boot.callAttr("serve", port), "kairos-uvicorn"); - server.setDaemon(true); - server.start(); - } - webView = new WebView(this); webView.getSettings().setJavaScriptEnabled(true); // chrono vivant, alertes webView.getSettings().setDomStorageEnabled(true); @@ -74,12 +62,7 @@ protected void onCreate(Bundle savedInstanceState) { @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); - uiReady.set(true); - // Force une nouvelle passe de dessin pour que le pre-draw listener - // (voir onCreate) soit ré-évalué maintenant que `uiReady` est vrai — - // sans ça, rien ne garantit qu'une invalidation survienne d'elle-même - // pendant que la WebView est restée vide en attendant le serveur. - view.invalidate(); + hideStartupOverlay(); } }); webView.setWebChromeClient(new WebChromeClient() { @@ -113,29 +96,95 @@ public boolean onJsConfirm(WebView view, String url, String message, JsResult re notificationBridge = new KairosNotificationBridge(this, webView); webView.addJavascriptInterface(notificationBridge, "KairosAndroid"); - setContentView(webView); - - // Reporte le dessin de la toute première frame tant que `uiReady` est faux - // (voir champ `uiReady` et `onPageFinished` ci-dessus) : technique native - // documentée par Android pour ce cas précis (attente d'un chargement - // asynchrone avant la première frame), sans AndroidX ni dépendance de - // version — fonctionne identiquement sur toutes les API, contrairement à - // une éventuelle API dédiée à l'écran de démarrage natif (API 31+ seulement). - final View content = findViewById(android.R.id.content); - content.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - if (!uiReady.get()) { - return false; - } - content.getViewTreeObserver().removeOnPreDrawListener(this); - return true; - } - }); + // Overlay de démarrage applicatif (plutôt que de compter sur le splash + // système, voir `startupOverlay` et `hideStartupOverlay` ci-dessous) : + // WebView en dessous, overlay par-dessus, masqué en fondu une fois la + // première page réellement chargée. + FrameLayout root = new FrameLayout(this); + root.addView(webView, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); + startupOverlay = buildStartupOverlay(); + root.addView(startupOverlay, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); + setContentView(root); registerPredictiveBackCallback(); - loadWhenServerReady(); + // Filet de sécurité : si `onPageFinished` n'arrive jamais (page en échec, + // réseau local qui ne répond jamais), ne pas rester bloqué indéfiniment sur + // le logo — l'utilisateur retrouve au moins la WebView (même vide/en erreur) + // plutôt qu'un écran figé. + overlayHandler.postDelayed(this::hideStartupOverlay, STARTUP_OVERLAY_TIMEOUT_MS); + + // `Python.start()` et surtout `kairos_boot.prepare()` (extraction du paquet + // Python embarqué, première écriture de la base SQLite) peuvent prendre + // plusieurs secondes au tout premier lancement — les exécuter sur le thread + // principal bloquerait tout rendu, y compris celui de l'overlay ci-dessus + // (c'est ce qui, avant ce correctif, produisait un écran blanc : le splash + // système est piloté par ce même thread principal, qu'il ne pouvait pas + // dessiner tant que ce bloc s'exécutait de façon synchrone dans `onCreate`). + new Thread(() -> { + startServerIfNeeded(); + loadWhenServerReady(); + }, "kairos-init").start(); + } + + /** Construit l'overlay plein écran affiché pendant le démarrage : fond uni à la + * couleur de l'app (`@color/kairos_bg`, cohérent avec `themes.xml`) et le logo + * animé déjà utilisé pour le splash natif (`@drawable/kairos_splash_icon`, + * `AnimatedVectorDrawable` — même secteur qui balaie depuis midi). Démarré + * explicitement via `Animatable.start()` : contrairement au splash système, rien + * ne le joue automatiquement ici. */ + private View buildStartupOverlay() { + FrameLayout overlay = new FrameLayout(this); + overlay.setBackgroundColor(getColor(R.color.kairos_bg)); + + ImageView icon = new ImageView(this); + icon.setImageResource(R.drawable.kairos_splash_icon); + FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT); + lp.gravity = Gravity.CENTER; + overlay.addView(icon, lp); + + Drawable drawable = icon.getDrawable(); + if (drawable instanceof Animatable) { + ((Animatable) drawable).start(); + } + return overlay; + } + + /** Masque l'overlay de démarrage en fondu. Appelé depuis `onPageFinished` + * (chemin normal) et depuis le filet de sécurité de `onCreate` (chemin de + * secours) — idempotent : un second appel (page suivante rechargée en plein, + * ou timeout après un `onPageFinished` déjà traité) est un no-op silencieux. */ + private void hideStartupOverlay() { + if (startupOverlay == null || startupOverlay.getVisibility() == View.GONE) { + return; + } + overlayHandler.removeCallbacksAndMessages(null); + startupOverlay.animate() + .alpha(0f) + .setDuration(220) + .withEndAction(() -> startupOverlay.setVisibility(View.GONE)) + .start(); + } + + /** Démarre Python/uvicorn si ce n'est pas déjà fait (voir la docstring de la + * classe : le port et le thread serveur survivent à une recréation d'activité + * tant que le process vit). Appelé depuis le thread `kairos-init` de + * `onCreate`, jamais le thread principal. */ + private void startServerIfNeeded() { + if (!Python.isStarted()) { + Python.start(new AndroidPlatform(this)); + } + PyObject boot = Python.getInstance().getModule("kairos_boot"); + if (serverPort < 0) { + serverPort = boot.callAttr("prepare", getFilesDir().getAbsolutePath()).toInt(); + int port = serverPort; + Thread server = new Thread(() -> boot.callAttr("serve", port), "kairos-uvicorn"); + server.setDaemon(true); + server.start(); + } } /** Geste retour prédictif (API 33+, `android.window` natif — pas AndroidX, même @@ -160,7 +209,9 @@ private void registerPredictiveBackCallback() { } /** Sonde /favicon.ico (toujours 200 sur une instance réelle, même repère que - * le launcher de bureau) puis charge l'agenda du jour. */ + * le launcher de bureau) puis charge l'agenda du jour. Appelé depuis le thread + * `kairos-init` une fois `startServerIfNeeded` revenu (donc `serverPort` posé) — + * spawn son propre thread `kairos-probe`, comme avant ce correctif. */ private void loadWhenServerReady() { final String base = "http://127.0.0.1:" + serverPort; new Thread(() -> { diff --git a/app/desktop_browser.py b/app/desktop_browser.py index 66e4397..e22e71a 100644 --- a/app/desktop_browser.py +++ b/app/desktop_browser.py @@ -11,6 +11,14 @@ ressenti de « web app installée ». Firefox et Safari n'ont pas d'équivalent strict à cet indicateur — ce module ne cible donc que la famille Chromium. +Sans plus, cette fenêtre reste identifiée comme une fenêtre Chrome/Chromium +quelconque (icône du navigateur dans le dock/la barre des tâches, impossible à +épingler comme « Kairos » à part) : `--class=Kairos` (voir `_APP_WINDOW_CLASS`) +fixe la `WM_CLASS` X11 de la fenêtre, et `install_linux_desktop_entry` installe +un fichier `.desktop` + des icônes assortis au même nom, pour qu'un +gestionnaire de fenêtres/bureau Linux les associe et affiche la véritable +icône Kairos. + Ce module reste volontairement séparé de `app/launcher.py` : sa logique (détection d'un binaire, construction des arguments) est pure et se teste sans toucher à uvicorn, aux threads ou au fichier de verrou. `app/launcher.py` @@ -25,10 +33,22 @@ import shutil import subprocess import sys +from pathlib import Path from app.settings_store import data_dir from app.subprocess_env import external_process_env +# Doit être identique des deux côtés (argv Chromium et fichier .desktop, voir +# `launch_app_window`/`_desktop_entry_content`) : c'est ce qui permet au +# gestionnaire de fenêtres Linux d'associer la fenêtre lancée à l'entrée +# installée, donc de lui donner l'icône Kairos plutôt que celle du navigateur. +_APP_WINDOW_CLASS = "Kairos" +_DESKTOP_ENTRY_FILENAME = "kairos.desktop" +_ICON_NAME = "kairos" +# Tailles disponibles dans `static/` (voir `packaging/make_icon.py`), copiées +# vers le thème d'icônes `hicolor` — convention XDG standard. +_ICON_SIZES = (192, 512) + # Ordre de préférence indicatif seulement (le premier trouvé gagne) — pas de # hiérarchie qualitative entre ces navigateurs, juste une liste stable pour # un comportement déterministe d'un poste à l'autre. @@ -118,12 +138,23 @@ def launch_app_window(browser_path: str, url: str) -> bool: fenêtre s'affiche effectivement — fonctionnalité de confort, jamais bloquante), `False` sur tout échec. """ + # Best-effort, avant le lancement : sur Linux, installe/actualise le fichier + # .desktop + les icônes assortis à `--class=` ci-dessous, pour que la fenêtre + # se présente comme une vraie application Kairos (voir `install_linux_ + # desktop_entry`). No-op silencieux ailleurs ou si l'installation échoue. + install_linux_desktop_entry() + # Profil dédié, séparé du profil personnel de l'utilisateur : la fenêtre # d'application ne doit pas se mêler à ses onglets/extensions/sessions du # navigateur habituel, et un profil Chromium ne peut de toute façon pas # être ouvert deux fois simultanément par deux processus distincts. profile_dir = str(data_dir() / _BROWSER_PROFILE_DIRNAME) - argv = [browser_path, f"--user-data-dir={profile_dir}", f"--app={url}"] + argv = [ + browser_path, + f"--user-data-dir={profile_dir}", + f"--class={_APP_WINDOW_CLASS}", + f"--app={url}", + ] kwargs: dict = {} if sys.platform == "win32" and hasattr(subprocess, "DETACHED_PROCESS"): @@ -151,3 +182,77 @@ def launch_app_window(browser_path: str, url: str) -> bool: # l'appelant retombe sur `webbrowser.open`. return False return True + + +def _xdg_data_home() -> Path: + override = os.environ.get("XDG_DATA_HOME") + if override: + return Path(override) + return Path.home() / ".local" / "share" + + +def _desktop_entry_content(executable_path: str) -> str: + """Contenu du fichier `.desktop` XDG pour Kairos. + + Fonction pure (aucune E/S), testable indépendamment de l'installation + réelle sur disque. `Exec` pointe l'exécutable figé lui-même (pas un script + ni `python -m ...` : `install_linux_desktop_entry` ne l'appelle que depuis + un exécutable PyInstaller, où `sys.executable` désigne ce binaire stable + d'un lancement à l'autre). `StartupWMClass` doit rester identique à + `--class=` posé par `launch_app_window` (`_APP_WINDOW_CLASS`) : c'est ce + qui permet au bureau d'associer la fenêtre déjà ouverte à cette entrée. + """ + return ( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=Kairos\n" + f'Exec="{executable_path}"\n' + f"Icon={_ICON_NAME}\n" + f"StartupWMClass={_APP_WINDOW_CLASS}\n" + "Terminal=false\n" + "Categories=Utility;Office;\n" + ) + + +def install_linux_desktop_entry() -> None: + """Installe (ou réinstalle) un fichier `.desktop` + les icônes Kairos dans les + emplacements XDG utilisateur (``~/.local/share/applications``, + ``~/.local/share/icons/hicolor/x/apps``). + + Sans ça, la fenêtre d'application (voir `launch_app_window`) reste une + fenêtre Chromium comme une autre pour le bureau : impossible de l'épingler + comme « Kairos » à part (barre des tâches, dock, recherche d'applications). + Réservé à Linux, et seulement depuis un **exécutable figé** (`sys.frozen`) : + en dev/venv, `sys.executable` est l'interpréteur Python — un `.desktop` + pointant dessus serait faux, donc on n'installe rien dans ce cas. Réécrit à + chaque lancement (idempotent, coût négligeable) : auto-répare le chemin si + l'utilisateur a déplacé l'exécutable entre deux lancements. Best-effort, + comme le reste de ce module : toute erreur est avalée, jamais remontée. + """ + if sys.platform != "linux" or not getattr(sys, "frozen", False): + return + try: + # Import différé : évite de charger `app.main` (FastAPI, SQLAlchemy...) + # pour les tests qui n'exercent que la détection/le lancement du + # navigateur — `app/launcher.py` l'a de toute façon déjà importé avant + # d'appeler ce module en conditions réelles. + from app.main import BASE_DIR + + data_home = _xdg_data_home() + apps_dir = data_home / "applications" + apps_dir.mkdir(parents=True, exist_ok=True) + (apps_dir / _DESKTOP_ENTRY_FILENAME).write_text( + _desktop_entry_content(sys.executable), encoding="utf-8" + ) + + for size in _ICON_SIZES: + src = BASE_DIR / "static" / f"icon-{size}.png" + if not src.is_file(): + continue + icon_dir = data_home / "icons" / "hicolor" / f"{size}x{size}" / "apps" + icon_dir.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, icon_dir / f"{_ICON_NAME}.png") + except Exception: + # Confort en arrière-plan (icône/épinglage) : ne doit jamais empêcher + # le lancement de Kairos. + pass diff --git a/docs/ANDROID_PACKAGING.md b/docs/ANDROID_PACKAGING.md index 7c6a800..5905c9a 100644 --- a/docs/ANDROID_PACKAGING.md +++ b/docs/ANDROID_PACKAGING.md @@ -42,20 +42,28 @@ l'émulateur). Chaîne de démarrage : -1. `MainActivity` (Java, sans AndroidX) démarre Chaquopy et appelle - `kairos_boot.prepare(filesDir)` : le paquet embarqué `kairos_dist` (extrait - de l'APK en vrais fichiers, voir ci-dessous) est ajouté à `sys.path`, - `KAIROS_BASE_DIR` et `KAIROS_PLATFORM=android` sont posés, puis - `app/android_launcher.py` ancre les données dans le stockage privé - (`KAIROS_DATA_DIR`) et choisit un port libre. `KAIROS_PLATFORM` est lu une - seule fois par `app/main.py` (`is_android`) pour la bottom nav de - `templates/base.html` — seule variable d'environnement de ce module - consommée pour distinguer l'APK Android du reste (voir - `docs/spec/accueil-navigation.md`), tout le reste du gabarit/CSS restant - strictement identique entre les trois cibles de packaging. -2. `kairos_boot.serve(port)` lance uvicorn dans un thread dédié. -3. L'activité sonde `/favicon.ico` (même repère que le launcher de bureau) - puis charge `http://127.0.0.1:/kairos` dans la WebView. +1. `MainActivity.onCreate` construit immédiatement la WebView **et** un overlay + de démarrage (fond `@color/kairos_bg` + logo animé) empilés dans un + `FrameLayout`, puis affiche cette hiérarchie (`setContentView`) — voir + « Écran de démarrage » ci-dessous pour le détail et le pourquoi de ce + choix. En parallèle, sur un thread dédié (`kairos-init`, jamais le thread + principal) : démarre Chaquopy et appelle `kairos_boot.prepare(filesDir)` : + le paquet embarqué `kairos_dist` (extrait de l'APK en vrais fichiers, voir + ci-dessous) est ajouté à `sys.path`, `KAIROS_BASE_DIR` et + `KAIROS_PLATFORM=android` sont posés, puis `app/android_launcher.py` ancre + les données dans le stockage privé (`KAIROS_DATA_DIR`) et choisit un port + libre. `KAIROS_PLATFORM` est lu une seule fois par `app/main.py` + (`is_android`) pour la bottom nav de `templates/base.html` — seule + variable d'environnement de ce module consommée pour distinguer l'APK + Android du reste (voir `docs/spec/accueil-navigation.md`), tout le reste + du gabarit/CSS restant strictement identique entre les trois cibles de + packaging. +2. `kairos_boot.serve(port)` lance uvicorn dans un thread dédié (`kairos-uvicorn`). +3. Toujours depuis le thread `kairos-init`, une fois `prepare()` revenu : sonde + `/favicon.ico` (même repère que le launcher de bureau, thread + `kairos-probe`) puis charge `http://127.0.0.1:/kairos` dans la + WebView (`runOnUiThread`). L'overlay de démarrage se masque en fondu dès + que cette page a fini de charger (`WebViewClient.onPageFinished`). Empaquetage du code : la tâche Gradle `stageKairosPython` copie `app/`, `templates/`, `static/` et `README.md` dans un paquet Python unique @@ -79,78 +87,70 @@ Points notables : redémarre au retour, SQLite committe à chaque requête. **Limite v1** : pas de foreground service, un chrono en cours ne survit pas à une mise en veille agressive. -- **Splash screen natif** (revue produit F-Droid/mobile, 2026-07) : - `android/app/src/main/res/values/themes.xml` pose - `android:windowSplashScreenBackground` (API 31+, `tools:targetApi="31"` — - annotation lint, pas un mécanisme de qualification de ressource : un - framework plus ancien ignore silencieusement l'attribut inconnu à la - résolution du thème, même mécanisme déjà en production pour - `windowLightNavigationBar`/`windowOptOutEdgeToEdgeEnforcement` dans ce - fichier) et `android:windowBackground` (toutes API, non gardé) à la couleur - de fond de l'app (`@color/kairos_bg`). Sans `androidx.core:splashscreen` - (voir « pas d'AndroidX » ci-dessus) : l'icône de lanceur adaptative - existante (`ic_launcher_foreground.xml`, statique) s'affiche par défaut, - aucun asset dédié requis. Avant ce correctif, l'API 31+ affichait un fond - générique pendant le démarrage de Python+uvicorn ; `windowBackground` seul - couvre aussi les appareils API 24-30 (minSdk 24, en dessous du seuil - splash-screen natif). - - **Durée du splash retenue jusqu'au premier rendu réel** (constaté sur - appareil : le thème seul ne suffisait pas — le splash disparaissait dès la - première frame dessinée par `setContentView(webView)`, bien avant que - Python/uvicorn n'ait fini de démarrer, laissant place à une WebView - blanche pendant toute l'attente). **Piège documenté ici pour ne pas le - retrancher deux fois** : une première tentative a utilisé - `Activity.getSplashScreen().setKeepOnScreenCondition(...)` — cette méthode - **n'existe pas** sur `android.window.SplashScreen` (la classe **native**, - seule autorisée par la contrainte « pas d'AndroidX » ci-dessus) ; elle - n'existe que sur `androidx.core.splashscreen.SplashScreen`, la bibliothèque - de compatibilité, hors périmètre. Erreur de compilation constatée en CI - (`cannot find symbol: method setKeepOnScreenCondition(...)`), corrigée - avant tout usage réel. Mécanisme retenu à la place : - `ViewTreeObserver.OnPreDrawListener` sur la vue de contenu - (`findViewById(android.R.id.content)`, posé juste après `setContentView`) — - reporter le dessin de la toute première frame de l'activité reporte de - fait la disparition du splash (natif API 31+, ou simplement l'affichage du - contenu sous `windowBackground` en dessous), puisque c'est justement ce - dessin qui déclenche cette disparition. La condition de report est un - champ `uiReady` (`AtomicBoolean`) mis à `true` par - `WebViewClient.onPageFinished` (+ `view.invalidate()` pour forcer une - nouvelle passe de dessin et faire réévaluer le listener) — donc jusqu'à ce - que la première page ait réellement fini de charger dans la WebView, pas - seulement jusqu'à la réponse du serveur (`loadWhenServerReady`/sonde - `/favicon.ico`, qui ne fait que déclencher le `loadUrl`). Technique - documentée par Android pour ce cas d'usage précis (attente d'un - chargement asynchrone avant la première frame) ; ni classe ni attribut - spécifiques à l'API 31+, fonctionne identiquement à toutes les API sans - garde de version. - - **Icône animée** (`android:windowSplashScreenAnimatedIcon`, API 31+) : - plutôt que l'icône de lanceur adaptative statique par défaut, un - `AnimatedVectorDrawable` dédié - (`res/drawable/kairos_splash_icon.xml` + `kairos_splash_icon_base.xml` + +- **Écran de démarrage** (revue produit F-Droid/mobile, 2026-07, corrigé une + seconde fois — voir « Piège tracé » ci-dessous) : un **overlay applicatif**, + pas le splash système d'Android, porte le branding pendant toute l'attente + de Python/uvicorn. + - **Pourquoi pas le splash système (API 31+, `windowSplashScreenBackground`/ + `windowSplashScreenAnimatedIcon` dans `themes.xml`)** : cette API vise des + attentes courtes (elle disparaît dès la première frame dessinée par + l'activité) et plusieurs OEM/AOSP la forcent à disparaître au-delà d'un + court délai — inadaptée à un démarrage de plusieurs secondes (extraction + du paquet Python embarqué, première écriture SQLite). `themes.xml` + conserve ces attributs (`android:windowSplashScreenBackground`, + `android:windowBackground` — les deux à `@color/kairos_bg`, + `tools:targetApi="31"` pour le premier : annotation lint, pas un + mécanisme de qualification de ressource, ignorée sans erreur en dessous + de l'API 31, même mécanisme déjà en production pour + `windowLightNavigationBar`/`windowOptOutEdgeToEdgeEnforcement`) : ils + couvrent gratuitement le tout petit instant de cold-start *avant* + `onCreate`, mais ne sont plus **load-bearing** pour la suite. + - **`MainActivity`** construit dans `onCreate`, synchrone, avant tout appel + Python : un `FrameLayout` empilant la `WebView` (en dessous) et un + overlay plein écran (fond `@color/kairos_bg` + un `ImageView` centré, + au-dessus) — `buildStartupOverlay()`. L'`ImageView` réutilise + l'`AnimatedVectorDrawable` déjà créé pour l'ancien splash système + (`@drawable/kairos_splash_icon` — `kairos_splash_icon_base.xml` + `res/animator/kairos_splash_wedge_sweep.xml`, natif - `android.graphics.drawable`, API 21+, pas AndroidX) anime le secteur - terracotta du cadran d'un balayage nul (« aiguille » collapsée à midi) - jusqu'à sa position finale identique au logo statique (secteur de 80° - depuis midi, mêmes coordonnées que `ic_launcher_foreground.xml`). Le - système détecte l'`Animatable` et joue l'animation automatiquement à - l'affichage du splash, sans code Java dédié — `windowSplashScreenAnimationDuration` - (700ms, plafond système 1000ms) doit couvrir la durée réelle de - l'animator pour que le système ne considère pas l'icône « terminée » - trop tôt. Morph de `pathData` par 5 images-clés (0/20/40/60/80°) plutôt - qu'un simple `valueFrom`/`valueTo` à deux points : l'interpolation d'un - `pathData` déplace les coordonnées de l'arc en ligne droite (corde), - jamais l'angle réel — sur un seul segment de 80°, la corde couperait - jusqu'à ~3.7 sur un rayon de 16 au plus fort du geste (secteur - visiblement aplati à mi-course) ; des images-clés tous les 20° ramènent - cet écart à ~0.24, imperceptible. Le drawable de base porte son propre - fond ivoire + anneau (contrairement à `ic_launcher_foreground.xml`, - pensé pour un masque adaptatif) : affiché tel quel par le splash, sans - masque système. + `android.graphics.drawable`, API 21+, pas AndroidX ; secteur terracotta + balayant depuis midi jusqu'à 80°, 5 images-clés pour éviter l'aplatissement + d'un morph `pathData` à deux points — voir le commentaire du fichier + animator pour le détail géométrique) : appelée explicitement en Java + (`Animatable.start()`), pas automatiquement par le système comme c'était + le cas pour le splash — mais c'est le **même** asset, juste rejoué + autrement. + - **Python/uvicorn démarrent sur un thread dédié** (`kairos-init`), jamais + le thread principal : `Python.start()` et surtout + `kairos_boot.prepare()` peuvent prendre plusieurs secondes au premier + lancement, ce qui bloquait auparavant `onCreate` de bout en bout — + c'est ce blocage qui, dans la version précédente, empêchait le splash + (système ou applicatif) de s'afficher ou de s'animer : le thread qui + aurait dû le dessiner était occupé à extraire le paquet Python. + - **`onPageFinished`** (une fois la première page réellement chargée dans + la WebView, pas seulement une fois le serveur prêt) masque l'overlay en + fondu (`View.animate().alpha(0)`). Filet de sécurité : un `Handler` + masque aussi l'overlay après 30 s même sans `onPageFinished` (page en + échec), pour ne jamais rester bloqué sur le logo. + - **Piège tracé, pour ne pas le retrancher deux fois** : une première + tentative avait retenu le splash *système* via + `Activity.getSplashScreen().setKeepOnScreenCondition(...)` — cette + méthode **n'existe pas** sur `android.window.SplashScreen` (la classe + native, seule autorisée par la contrainte « pas d'AndroidX »), seulement + sur `androidx.core.splashscreen.SplashScreen`, hors périmètre (erreur de + compilation constatée en CI, corrigée avant tout usage réel). Le repli + suivant, `ViewTreeObserver.OnPreDrawListener` pour reporter la première + frame de l'activité, compilait et fonctionnait, mais souffrait du même + problème de fond que le splash système qu'il retenait : tant que + `onCreate` restait bloqué par l'initialisation Python synchrone, rien ne + se dessinait à l'écran, splash retenu ou non. D'où le passage à un + overlay applicatif **et** à une initialisation hors thread principal — + les deux ensemble, pas l'un sans l'autre. - **Geste retour prédictif** (Android 13+/15, même revue) : `AndroidManifest.xml` pose `android:enableOnBackInvokedCallback="true"` au niveau `` (impératif — sans lui, tout enregistrement de callback reste sans effet même sur API 33+). `MainActivity.registerPredictiveBackCallback()` (appelée dans - `onCreate`, juste après `setContentView(webView)`) enregistre un + `onCreate`, juste après `setContentView(root)`, `root` étant le `FrameLayout` + WebView+overlay décrit ci-dessus) enregistre un `OnBackInvokedCallback` (`android.window`, natif, pas AndroidX — même parti pris que `KairosNotificationBridge`) uniquement si `Build.VERSION.SDK_INT >= TIRAMISU` ; même logique que le chemin legacy diff --git a/docs/spec/packaging-lancement.md b/docs/spec/packaging-lancement.md index dfc4d2d..b75035e 100644 --- a/docs/spec/packaging-lancement.md +++ b/docs/spec/packaging-lancement.md @@ -222,9 +222,10 @@ navigateur, construction des arguments de lancement) pure et testable sans touch alors automatiquement sur `webbrowser.open`. - Fonction pure (aucun effet de bord, aucune impression) : testée en monkeypatchant `shutil.which` et les variables d'environnement lues. -- **`launch_app_window(browser_path: str, url: str) -> bool`** : construit - `[browser_path, f"--user-data-dir={profile_dir}", f"--app={url}"]` où - `profile_dir = str(data_dir() / "browser-profile")` (voir +- **`launch_app_window(browser_path: str, url: str) -> bool`** : appelle d'abord + `install_linux_desktop_entry()` (best-effort, voir ci-dessous), puis construit + `[browser_path, f"--user-data-dir={profile_dir}", f"--class={_APP_WINDOW_CLASS}", + f"--app={url}"]` où `profile_dir = str(data_dir() / "browser-profile")` (voir `app/settings_store.py::data_dir` — même dossier de données que le verrou et le journal de crash du launcher), puis lance ce process via `subprocess.Popen` (`stdin`/`stdout`/`stderr` sur `DEVNULL`, `start_new_session=True`, et @@ -251,14 +252,37 @@ navigateur, construction des arguments de lancement) pure et testable sans touch remonter — `_open_browser` retombe alors sur `webbrowser.open`. - Retourne `True` sur un lancement réussi — sans garantie que la fenêtre s'affiche effectivement (le process a démarré, rien de plus n'est vérifié). -- **Icône de la fenêtre d'application** : `--app=URL` affiche l'icône déclarée par - la page elle-même (favicon / manifeste web), pas une icône générique de - navigateur. Le `` des templates de bureau référence - `static/manifest.webmanifest` et les icônes `static/icon-192.png`, - `static/icon-512.png`, `static/apple-touch-icon.png` (générées par - `packaging/make_icon.py`, hors périmètre de ce module) — sans ces fichiers, la - fenêtre d'application s'affiche quand même (repli sur une icône générique de - Chromium), mais sans le rendu « app installée » complet recherché. +- **Identité de la fenêtre d'application (`WM_CLASS` + icône, pas juste le + favicon)** : `--app=URL` seul affiche l'icône déclarée par la page (favicon / + manifeste web — `static/manifest.webmanifest`, `static/icon-192.png`, + `static/icon-512.png`, `static/apple-touch-icon.png`, générées par + `packaging/make_icon.py`) **dans l'onglet/la barre de titre**, mais la fenêtre + reste identifiée par le bureau comme une fenêtre Chromium quelconque (icône du + navigateur dans le dock/la barre des tâches, impossible à l'épingler comme + « Kairos » à part). Deux mécanismes complémentaires corrigent ça : + - **`--class=Kairos`** (constante `_APP_WINDOW_CLASS`) fixe la `WM_CLASS` X11 + de la fenêtre. + - **`install_linux_desktop_entry()`** installe/réinstalle (idempotent, appelé + à chaque lancement) un fichier `.desktop` XDG + (`~/.local/share/applications/kairos.desktop`, ou sous + `$XDG_DATA_HOME` si posé) avec `StartupWMClass=Kairos` (doit rester + identique à `--class=` ci-dessus — c'est ce qui permet au bureau + d'associer la fenêtre déjà ouverte à cette entrée) et + `Exec=""`, plus une copie des PNG déjà embarqués + (`static/icon-192.png`/`icon-512.png`) vers + `~/.local/share/icons/hicolor/x/apps/kairos.png` (thème + d'icônes standard XDG). **Gardé par `sys.platform == "linux"` et + `getattr(sys, "frozen", False)`** : en dev/venv, `sys.executable` est + l'interpréteur Python, un `.desktop` pointant dessus serait faux, donc rien + n'est installé dans ce cas. Le contenu du fichier `.desktop` est généré par + une fonction pure (`_desktop_entry_content`), testée indépendamment de + l'écriture disque ; l'installation elle-même est encadrée d'un + `try/except Exception` (même philosophie que `launch_app_window` : confort + en arrière-plan, jamais bloquant). + - **Windows** : la fenêtre `--app=` obtient déjà sa propre entrée de barre des + tâches avec le favicon de la page — acceptable en l'état, pas d'équivalent + du `.desktop`/`WM_CLASS` tenté (raccourci Menu Démarrer avec + AppUserModelID : hors périmètre pour l'instant). #### `app/android_launcher.py` — lancement Android @@ -422,20 +446,20 @@ cette spec (pas de duplication du reste) : `versionCode = X*10000 + Y*100 + Z`, plancher `1` (Android rejette `0`, ce que donnerait le défaut `0.0.0-dev`) — garantit une valeur strictement croissante d'une release à l'autre pour qu'Android accepte la mise à jour par-dessus. -- **`themes.xml`** : splash screen natif (`android:windowSplashScreenBackground`, - API 31+) et `android:windowBackground` (toutes API) posés à la couleur de fond de - l'app — évite le flash blanc générique pendant le démarrage de Python+uvicorn, - sans dépendance `androidx.core:splashscreen` (voir « pas d'AndroidX » dans - `docs/ANDROID_PACKAGING.md`). Sa durée réelle d'affichage est contrôlée depuis - `MainActivity` par un `ViewTreeObserver.OnPreDrawListener` qui reporte la - toute première frame de l'activité (technique native standard, pas de classe - spécifique à l'API 31+) : sans ce report, le thème seul ne suffit pas à - couvrir l'attente du serveur, le splash se ferme dès la première frame - dessinée. Son icône est un +- **Écran de démarrage** : un overlay applicatif (`MainActivity`, `FrameLayout` + WebView + overlay fond `@color/kairos_bg` + logo animé) porte le branding + pendant toute l'attente de Python/uvicorn — pas le splash système + d'Android (`android:windowSplashScreenBackground`/`windowBackground` dans + `themes.xml`, toujours posés pour le tout petit instant de cold-start avant + `onCreate`, mais plus load-bearing au-delà). Python/uvicorn démarrent sur un + thread dédié (`kairos-init`), jamais le thread principal — c'est ce qui + garantit que l'overlay se dessine et s'anime réellement, y compris sur un + premier lancement long (extraction du paquet Python embarqué). Logo animé : `AnimatedVectorDrawable` dédié (`res/drawable/kairos_splash_icon*.xml` + - `res/animator/kairos_splash_wedge_sweep.xml`, natif, API 21+) : le secteur du - logo balaie depuis midi jusqu'à sa position finale plutôt que d'apparaître - figé. Détail complet dans `docs/ANDROID_PACKAGING.md`. + `res/animator/kairos_splash_wedge_sweep.xml`, natif, API 21+), le même asset + que l'ancien splash système, rejoué explicitement (`Animatable.start()`) + plutôt qu'automatiquement. Détail complet, y compris les deux approches + écartées avant celle-ci, dans `docs/ANDROID_PACKAGING.md`. - **`AndroidManifest.xml`** / **`MainActivity.java`** : geste retour prédictif Android 13+ (`android:enableOnBackInvokedCallback="true"` + `OnBackInvokedDispatcher` natif, `android.window`, pas AndroidX) — chemin @@ -505,10 +529,23 @@ cette spec (pas de duplication du reste) : fidèlement l'état d'avant PyInstaller (variable absente au départ → absente pour le sous-processus), plutôt que de la laisser vide ou undefined de façon incohérente. -- **Icône Windows embarquée, icône Linux absente du binaire** : décision assumée - (pas un oubli) — sur Linux, l'icône de bureau viendrait d'un fichier `.desktop`, - jamais du binaire lui-même ; `icon=` dans `kairos.spec` est ignoré sans erreur sur - cette plateforme. +- **Icône Windows embarquée dans le binaire, icône Linux portée par un `.desktop` + installé à l'exécution plutôt que dans le binaire** : `icon=` dans + `kairos.spec` est ignoré sans erreur sur Linux (`packaging/kairos.spec`) — + ce n'était pas un oubli, mais l'icône de bureau Linux vient désormais + effectivement d'un fichier `.desktop`, installé par + `install_linux_desktop_entry()` (voir plus haut), pas du binaire lui-même. +- **`.desktop`/`WM_CLASS` installés à l'exécution plutôt qu'à l'empaquetage** + (pas de fichier `.desktop` commité dans le dépôt, pas d'étape d'installation + système type paquet `.deb`/`.rpm`) : cohérent avec la distribution actuelle + (un exécutable PyInstaller onefile téléchargé et lancé directement, pas + installé via un gestionnaire de paquets) — `install_linux_desktop_entry()` + s'auto-répare à chaque lancement (idempotent) si l'utilisateur déplace + l'exécutable, sans étape d'installation séparée à documenter ni à maintenir. + Écrit dans les emplacements XDG **utilisateur** + (`~/.local/share/applications`, `~/.local/share/icons/hicolor/...` — jamais + `/usr/share/...`) : aucun privilège root requis, cohérent avec un exécutable + téléchargé et lancé sans installation. ### Invariants et garde-fous diff --git a/tests/test_launcher.py b/tests/test_launcher.py index d87d136..3373f35 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -10,7 +10,12 @@ import sys import threading -from app.desktop_browser import find_app_capable_browser, launch_app_window +from app.desktop_browser import ( + _desktop_entry_content, + find_app_capable_browser, + install_linux_desktop_entry, + launch_app_window, +) from app.launcher import ( _clear_lock, _ensure_std_streams, @@ -292,6 +297,9 @@ def fail_which(name): # pragma: no cover - ne doit jamais être appelé def test_launch_app_window_builds_argv_with_app_and_profile_flags(monkeypatch, tmp_path) -> None: monkeypatch.setattr("app.desktop_browser.data_dir", lambda: tmp_path) + # Hors périmètre de ce test (couvert séparément) : neutralisé pour ne pas + # dépendre de sys.platform/sys.frozen dans l'environnement de test. + monkeypatch.setattr("app.desktop_browser.install_linux_desktop_entry", lambda: None) seen_argv = {} class _FakeProcess: @@ -309,10 +317,29 @@ def fake_popen(argv, **kwargs): argv = seen_argv["argv"] assert argv[0] == "/usr/bin/chromium" assert "--app=http://127.0.0.1:8001" in argv + assert "--class=Kairos" in argv assert any(arg.startswith("--user-data-dir=") for arg in argv) assert any(str(tmp_path) in arg for arg in argv if arg.startswith("--user-data-dir=")) +def test_launch_app_window_attempts_desktop_entry_installation(monkeypatch, tmp_path) -> None: + """`launch_app_window` doit tenter l'installation .desktop/icônes avant de + lancer le navigateur (best-effort — voir `install_linux_desktop_entry`).""" + monkeypatch.setattr("app.desktop_browser.data_dir", lambda: tmp_path) + monkeypatch.setattr("app.desktop_browser.subprocess.Popen", lambda argv, **kwargs: object()) + called = False + + def fake_install(): + nonlocal called + called = True + + monkeypatch.setattr("app.desktop_browser.install_linux_desktop_entry", fake_install) + + launch_app_window("/usr/bin/chromium", "http://127.0.0.1:8001") + + assert called is True + + def test_launch_app_window_returns_false_and_does_not_raise_on_popen_error( monkeypatch, tmp_path ) -> None: @@ -326,3 +353,75 @@ def failing_popen(argv, **kwargs): result = launch_app_window("/usr/bin/chromium", "http://127.0.0.1:8001") assert result is False + + +# --- Identité desktop Linux (.desktop + icônes) -------------------------------- + + +def test_desktop_entry_content_has_required_fields() -> None: + content = _desktop_entry_content("/opt/kairos/kairos") + + assert 'Exec="/opt/kairos/kairos"' in content + assert "Icon=kairos" in content + assert "StartupWMClass=Kairos" in content + assert "Type=Application" in content + assert "Name=Kairos" in content + + +def test_install_linux_desktop_entry_is_noop_outside_linux(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("app.desktop_browser.sys.platform", "win32") + monkeypatch.setattr("app.desktop_browser.sys.frozen", True, raising=False) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + + install_linux_desktop_entry() + + assert list(tmp_path.iterdir()) == [] + + +def test_install_linux_desktop_entry_is_noop_when_not_frozen(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("app.desktop_browser.sys.platform", "linux") + monkeypatch.delattr("app.desktop_browser.sys.frozen", raising=False) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + + install_linux_desktop_entry() + + assert list(tmp_path.iterdir()) == [] + + +def test_install_linux_desktop_entry_writes_desktop_file_and_icons(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("app.desktop_browser.sys.platform", "linux") + monkeypatch.setattr("app.desktop_browser.sys.frozen", True, raising=False) + monkeypatch.setattr("app.desktop_browser.sys.executable", "/opt/kairos/kairos") + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + + fake_base_dir = tmp_path / "base" + (fake_base_dir / "static").mkdir(parents=True) + (fake_base_dir / "static" / "icon-192.png").write_bytes(b"fake-png-192") + (fake_base_dir / "static" / "icon-512.png").write_bytes(b"fake-png-512") + monkeypatch.setattr("app.main.BASE_DIR", fake_base_dir) + + install_linux_desktop_entry() + + desktop_file = tmp_path / "applications" / "kairos.desktop" + assert desktop_file.is_file() + assert "StartupWMClass=Kairos" in desktop_file.read_text(encoding="utf-8") + + icon_192 = tmp_path / "icons" / "hicolor" / "192x192" / "apps" / "kairos.png" + icon_512 = tmp_path / "icons" / "hicolor" / "512x512" / "apps" / "kairos.png" + assert icon_192.read_bytes() == b"fake-png-192" + assert icon_512.read_bytes() == b"fake-png-512" + + +def test_install_linux_desktop_entry_never_raises_on_write_failure(monkeypatch, tmp_path) -> None: + """Best-effort : une erreur d'écriture (dossier en lecture seule, disque + plein...) ne doit jamais remonter à l'appelant.""" + monkeypatch.setattr("app.desktop_browser.sys.platform", "linux") + monkeypatch.setattr("app.desktop_browser.sys.frozen", True, raising=False) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + + def failing_mkdir(*args, **kwargs): + raise OSError("dossier en lecture seule") + + monkeypatch.setattr("app.desktop_browser.Path.mkdir", failing_mkdir) + + install_linux_desktop_entry() # ne doit pas lever