From 9f60d8d63fa311179524fa71002c44740a9f2e59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 11:06:37 +0000 Subject: [PATCH] =?UTF-8?q?Corrige=20le=20splash=20Android=20(=C3=A9cran?= =?UTF-8?q?=20blanc)=20et=20l'identit=C3=A9=20de=20la=20fen=C3=AAtre=20des?= =?UTF-8?q?ktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android : le splash restait invisible (écran blanc pendant tout le chargement) car Python.start()/kairos_boot.prepare() s'exécutaient de façon synchrone sur le thread principal dans onCreate — ce même thread qui aurait dû dessiner et animer le splash (système ou, avant ce correctif, retenu via OnPreDrawListener) en était empêché tant que ce bloc s'exécutait. Remplace le splash système par un overlay applicatif (FrameLayout WebView + logo animé, réutilisant l'AnimatedVectorDrawable déjà créé) et déplace Python/uvicorn sur un thread dédié (kairos-init) : l'overlay se dessine et s'anime immédiatement, indépendamment de la durée du démarrage Python. L'overlay se masque en fondu à onPageFinished (+ filet de sécurité à 30s). Desktop : la fenêtre d'application (--app=, PR précédente) portait l'identité visuelle de Chrome/Chromium, pas de Kairos — impossible à épingler comme une vraie app séparée. Ajoute --class=Kairos à l'argv Chromium et, sur Linux et uniquement depuis l'exécutable figé, installe/réinstalle (idempotent) un fichier .desktop XDG assorti (StartupWMClass=Kairos, icône copiée depuis les PNG déjà embarqués) dans les emplacements utilisateur — aucun privilège root, aucune étape d'installation séparée à maintenir. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012rz3jgonqVHP4fzQsJh8oP --- .../com/skohscripts/kairos/MainActivity.java | 157 +++++++++++------ app/desktop_browser.py | 107 +++++++++++- docs/ANDROID_PACKAGING.md | 162 +++++++++--------- docs/spec/packaging-lancement.md | 93 +++++++--- tests/test_launcher.py | 101 ++++++++++- 5 files changed, 456 insertions(+), 164 deletions(-) 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