Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 104 additions & 53 deletions android/app/src/main/java/com/skohscripts/kairos/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -33,53 +38,31 @@
*/
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);
webView.setWebViewClient(new WebViewClient() { // navigation interne, pas de navigateur externe
@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() {
Expand Down Expand Up @@ -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
Expand All @@ -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(() -> {
Expand Down
107 changes: 106 additions & 1 deletion app/desktop_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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.
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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/<taille>x<taille>/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
Loading
Loading