diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..e05d2be --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,94 @@ +name: Desktop installers + +# Produit les installeurs de BUREAU (.msi Windows pour l'instant) et les publie +# en tant qu'assets d'une GitHub Release, sur tag `v*`. +# +# Complementaire au pipeline Gitea Actions (.gitea/workflows/release.yml) qui, +# lui, build et pousse les IMAGES Docker. Ici on est sur GitHub car jpackage et +# PyInstaller ne savent PAS cross-compiler : le .msi DOIT etre construit sur un +# runner Windows, et GitHub en fournit gratuitement (windows-latest). +# +# Prerequis : le depot Gitea doit etre mirrore vers GitHub (push mirror, tags +# inclus) pour que le tag declenche ce workflow. +# +# Tag stable vX.Y.Z -> GitHub Release PUBLIQUE avec le .msi attache. +# Tag beta vX.Y.Z-beta* -> AUCUNE publication publique. Le .msi est depose en +# ARTEFACT PRIVE du run (telechargeable seulement par +# toi via l'onglet Actions) ; tu le joins ensuite a un +# post Patreon reserve a un palier. Patreon = la +# barriere d'acces (equivalent du registry prive + +# relais pour les images Docker beta). + +on: + push: + tags: ['v*'] + +permissions: + contents: write # requis pour creer la Release et y attacher le .msi + +jobs: + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + # Apporte jpackage (lanceur d'empaquetage natif) dans le PATH. + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + # Python 3.12 = meme version que l'image Docker du Brain (coherence runtime). + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # jpackage genere le MSI via WiX Toolset v3 (candle.exe/light.exe). WiX 4+ + # ne convient pas (outils renommes). Le paquet choco `wixtoolset` est la + # ligne 3.x et s'ajoute au PATH. + - name: Install WiX Toolset 3 + shell: pwsh + run: choco install wixtoolset -y --no-progress + + # Version de l'installeur = version du tag (numerique, sans suffixe -beta). + - name: Derive version from tag + id: ver + shell: pwsh + run: | + $full = "${{ github.ref_name }}" -replace '^v','' # ex: 0.15.0-beta.1 + $num = ($full -split '-')[0] # ex: 0.15.0 + "version=$num" >> $env:GITHUB_OUTPUT + + - name: Build Windows installer + shell: pwsh + run: .\installers\desktop\build-windows.ps1 -Version ${{ steps.ver.outputs.version }} + + # STABLE uniquement : Release GitHub publique avec le .msi. + - name: Publish installer to GitHub Release (stable) + if: ${{ !contains(github.ref_name, '-beta') }} + uses: softprops/action-gh-release@v2 + with: + files: core/target/dist-out/*.msi + fail_on_unmatched_files: true + generate_release_notes: true + + # BETA uniquement : artefact PRIVE (pas de release publique). A recuperer + # via l'onglet Actions puis a joindre a un post Patreon gate par palier. + - name: Upload installer as private artifact (beta) + if: ${{ contains(github.ref_name, '-beta') }} + uses: actions/upload-artifact@v4 + with: + name: loremind-beta-${{ steps.ver.outputs.version }}-msi + path: core/target/dist-out/*.msi + retention-days: 90 + + # TODO (plus tard) : job `linux` sur ubuntu-latest produisant un AppImage + # (jpackage --type app-image + appimagetool) + PyInstaller Linux du Brain, + # attache a la MEME release. Reutilise la meme matrice / les memes etapes. diff --git a/.gitignore b/.gitignore index 4eea073..a55a13f 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,12 @@ env/ .coverage htmlcov/ +# Artefacts du build bureau (cf. installers/desktop) +.venv-build/ +brain/build/ +brain/dist-embed/ +*.spec + # ============================================================================ # Angular / Node (Web) # ============================================================================ @@ -116,3 +122,4 @@ brain/data/notebooks/5.json # Contient le site premium (sources) + son Worker de gate dans gate/. # ============================================================================ docusaurus/loremind-patreon/ +installers/desktop/README.md diff --git a/brain/app/main.py b/brain/app/main.py index e21db6a..9b76aa6 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="0.14.0-beta", + version="0.15.0", ) logger = logging.getLogger(__name__) diff --git a/brain/run_local.py b/brain/run_local.py new file mode 100644 index 0000000..3cfa2cd --- /dev/null +++ b/brain/run_local.py @@ -0,0 +1,27 @@ +"""Point d'entree LOCAL du Brain (hors Docker). + +Lance le serveur uvicorn sur 127.0.0.1:8000 — l'equivalent autonome de la +commande Docker `uvicorn app.main:app --host 0.0.0.0 --port 8000`, mais en +n'ecoutant QUE sur la boucle locale (mono-utilisateur, jamais expose au reseau). + +Empaquete avec le Python *embeddable* officiel (signe par la PSF) dans +l'application de bureau : on evite ainsi tout executable "gele" type PyInstaller +que les antivirus prennent souvent pour un trojan (bootloader packe). +Le Core le lance via : python\\python.exe run_local.py + +On insere le dossier de CE fichier dans sys.path pour que le package `app` +soit importable quel que soit le repertoire de travail (le Core fixe le cwd +ailleurs, sous ~/.loremind/brain, pour y ecrire le dossier data/). +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import uvicorn # noqa: E402 + +from app.main import app # noqa: E402 + +if __name__ == "__main__": + # host 127.0.0.1 : accessible uniquement depuis le Core sur la meme machine. + uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info") diff --git a/core/pom.xml b/core/pom.xml index fcad91c..db5809c 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.14.0-beta + 0.15.0 LoreMind Core Backend Core - Architecture Hexagonale @@ -60,11 +60,31 @@ runtime - + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + com.h2database h2 - test + runtime @@ -179,4 +199,50 @@ + + + + + desktop + + + ${project.basedir}/../web/dist/web + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-frontend + + prepare-package + + copy-resources + + + ${project.build.outputDirectory}/static + + + ${frontend.dist} + + + + + + + + + + diff --git a/core/src/main/java/com/loremind/LoreMindApplication.java b/core/src/main/java/com/loremind/LoreMindApplication.java index 3aa11a0..c5e6d5d 100644 --- a/core/src/main/java/com/loremind/LoreMindApplication.java +++ b/core/src/main/java/com/loremind/LoreMindApplication.java @@ -1,5 +1,6 @@ package com.loremind; +import com.loremind.infrastructure.desktop.DesktopSingleInstance; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @@ -13,6 +14,23 @@ import org.springframework.scheduling.annotation.EnableScheduling; public class LoreMindApplication { public static void main(String[] args) { - SpringApplication.run(LoreMindApplication.class, args); + // Mode bureau (profil "local") : garde-fou instance unique. Si l'app + // tourne deja, on ouvre juste le navigateur et on sort proprement (code 0) + // au lieu de demarrer un 2e serveur qui echouerait sur le verrou H2 — ce + // qui evite le trompeur « Failed to launch JVM » du launcher jpackage. + boolean local = DesktopSingleInstance.isLocalProfile(args); + if (local && !DesktopSingleInstance.tryAcquire()) { + DesktopSingleInstance.openAppInBrowser(); + return; + } + SpringApplication app = new SpringApplication(LoreMindApplication.class); + if (local) { + // Mode bureau : on a besoin d'AWT (icone de la zone de notification, + // cf. SystemTrayManager). Spring Boot force headless=true par defaut, + // ce qui leverait HeadlessException — on le desactive ici. En mode + // serveur/Docker, on reste en headless (defaut), aucun impact. + app.setHeadless(false); + } + app.run(args); } } diff --git a/core/src/main/java/com/loremind/application/licensing/LicenseService.java b/core/src/main/java/com/loremind/application/licensing/LicenseService.java index 0a437a5..454218d 100644 --- a/core/src/main/java/com/loremind/application/licensing/LicenseService.java +++ b/core/src/main/java/com/loremind/application/licensing/LicenseService.java @@ -40,26 +40,33 @@ public class LicenseService { private final LicenseRelay relay; private final long gracePeriodSeconds; private final long refreshBeforeExpirySeconds; + private final boolean licensingEnabled; public LicenseService( LicenseRepository repository, JwtVerifier jwtVerifier, LicenseRelay relay, @Value("${licensing.grace-period-days:14}") int gracePeriodDays, - @Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays) { + @Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays, + @Value("${licensing.enabled:true}") boolean licensingEnabled) { this.repository = repository; this.jwtVerifier = jwtVerifier; this.relay = relay; this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L; this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L; + this.licensingEnabled = licensingEnabled; } /** - * @return true si le verifier est configure (cle publique presente). - * L'UI peut masquer toute la section Patreon si false. + * @return true si le licensing Patreon est actif : il faut a la fois que la + * feature soit activee ({@code licensing.enabled}, faux en mode + * bureau/local ou le gating par image Docker n'a aucun sens) ET que + * le verifier soit configure (cle publique presente). Faux => l'UI + * masque toute la section Patreon, le daemon de refresh est no-op, + * et le canal beta est desactive. */ public boolean isLicensingEnabled() { - return jwtVerifier.isConfigured(); + return licensingEnabled && jwtVerifier.isConfigured(); } /** diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainSidecar.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainSidecar.java new file mode 100644 index 0000000..5f27c62 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainSidecar.java @@ -0,0 +1,126 @@ +package com.loremind.infrastructure.ai; + +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +/** + * Lance le Brain (service IA Python) comme SOUS-PROCESSUS du Core, en mode + * local-first (application de bureau empaquetee, sans Docker). + *

+ * Cycle de vie calque sur celui du Core : + *

+ *

+ * Tolerance aux pannes : si le Brain ne peut pas etre lance (exe absent, + * commande non configuree...), on LOGGUE sans faire echouer le Core. L'app + * reste utilisable (Lore, Campagnes, Systeme de jeu) ; seules les fonctions IA + * sont indisponibles jusqu'a correction. + */ +@Component +@Profile("local") +public class BrainSidecar { + + private static final Logger log = LoggerFactory.getLogger(BrainSidecar.class); + + private final BrainSidecarProperties props; + private final String internalSecret; + + private volatile Process process; + + public BrainSidecar(BrainSidecarProperties props, + @Value("${brain.internal-secret:}") String internalSecret) { + this.props = props; + this.internalSecret = internalSecret; + } + + @EventListener(ApplicationReadyEvent.class) + public void start() { + if (!props.isEnabled()) { + log.info("[Brain] Sidecar desactive (brain.sidecar.enabled=false)."); + return; + } + if (props.getCommand() == null || props.getCommand().isEmpty()) { + log.warn("[Brain] Aucune commande configuree (brain.sidecar.command) : " + + "le Brain n'est pas lance. Les fonctions IA seront indisponibles."); + return; + } + + try { + ProcessBuilder pb = new ProcessBuilder(props.getCommand()); + pb.redirectErrorStream(true); + pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); + + File workingDir = resolveWorkingDir(); + if (workingDir != null) { + pb.directory(workingDir); + } + + // Secret partage Core <-> Brain : le Brain est fail-closed sans lui. + // (cf. Settings.internal_shared_secret cote Python -> env INTERNAL_SHARED_SECRET) + pb.environment().put("INTERNAL_SHARED_SECRET", internalSecret); + + this.process = pb.start(); + log.info("[Brain] Sidecar demarre (pid={}, cwd={}).", + process.pid(), workingDir != null ? workingDir : ""); + } catch (IOException e) { + log.error("[Brain] Echec du lancement du sidecar (commande={}). " + + "Les fonctions IA seront indisponibles. Cause : {}", + props.getCommand(), e.getMessage()); + } + } + + @PreDestroy + public void stop() { + Process p = this.process; + if (p == null || !p.isAlive()) { + return; + } + log.info("[Brain] Arret du sidecar (pid={})...", p.pid()); + p.destroy(); + try { + if (!p.waitFor(10, TimeUnit.SECONDS)) { + log.warn("[Brain] Arret propre depasse (10s) : kill force."); + p.destroyForcibly(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + p.destroyForcibly(); + } + } + + /** + * Resout (et cree au besoin) le repertoire de travail du Brain. Le Brain y + * ecrit son dossier {@code data/} (index vectoriel, settings.json). + */ + private File resolveWorkingDir() { + String dir = props.getWorkingDir(); + if (dir == null || dir.isBlank()) { + return null; // herite du cwd du Core + } + Path path = Path.of(dir).toAbsolutePath().normalize(); + try { + Files.createDirectories(path); + } catch (IOException e) { + log.warn("[Brain] Impossible de creer le repertoire de travail {} : {}. " + + "Lancement avec le cwd herite.", path, e.getMessage()); + return null; + } + return path.toFile(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainSidecarProperties.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainSidecarProperties.java new file mode 100644 index 0000000..2e69620 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainSidecarProperties.java @@ -0,0 +1,53 @@ +package com.loremind.infrastructure.ai; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Configuration du lancement du Brain (service IA Python) en SIDECAR, c.-a-d. + * comme sous-processus du Core, en mode local-first. + *

+ * En deploiement Docker, le Brain est un conteneur independant : ce mecanisme + * est inactif (profil {@code local} uniquement). En application de bureau + * empaquetee, il n'y a pas de Docker : le Core demarre lui-meme le Brain. + * + * @see BrainSidecar + */ +@Component +@Profile("local") +@ConfigurationProperties(prefix = "brain.sidecar") +public class BrainSidecarProperties { + + /** Active le lancement du Brain par le Core. */ + private boolean enabled = false; + + /** + * Commande de lancement (programme + arguments). Vide = ne rien lancer + * (cas du dev qui demarre le Brain a la main). + *

+ */ + private List command = List.of(); + + /** + * Repertoire de travail du process Brain. Le Brain ecrit ses donnees (index + * vectoriel, settings.json) sous {@code data/} RELATIF a ce dossier : on le + * place donc sous loremind.home pour que tout vive au meme endroit. + */ + private String workingDir; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public List getCommand() { return command; } + public void setCommand(List command) { this.command = command; } + + public String getWorkingDir() { return workingDir; } + public void setWorkingDir(String workingDir) { this.workingDir = workingDir; } +} diff --git a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopBrowserOpener.java b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopBrowserOpener.java new file mode 100644 index 0000000..4e507df --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopBrowserOpener.java @@ -0,0 +1,31 @@ +package com.loremind.infrastructure.desktop; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * En mode bureau (profil "local"), ouvre le navigateur par defaut sur + * l'application des que le serveur est pret. L'app n'ayant pas de fenetre + * native, c'est ce qui donne a l'utilisateur un retour visuel immediat apres + * le double-clic. + *

+ * Concerne uniquement l'instance qui a effectivement demarre le serveur : + * l'instance « perdante » du verrou unique ouvre le navigateur des le {@code main} + * puis sort (cf. {@link DesktopSingleInstance}). + */ +@Component +@Profile("local") +public class DesktopBrowserOpener { + + private static final Logger log = LoggerFactory.getLogger(DesktopBrowserOpener.class); + + @EventListener(ApplicationReadyEvent.class) + public void onReady() { + log.info("[Desktop] Application prete — ouverture du navigateur."); + DesktopSingleInstance.openAppInBrowser(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java new file mode 100644 index 0000000..fe9eaa5 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java @@ -0,0 +1,113 @@ +package com.loremind.infrastructure.desktop; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +/** + * Utilitaires du mode BUREAU (profil "local", application empaquetee jpackage). + *

+ * Resout deux problemes specifiques au lancement par double-clic : + *

    + *
  1. Instance unique : un serveur web n'ouvre pas de fenetre. Un + * utilisateur qui ne voit rien re-double-clique souvent — la 2e instance + * trouvait la base H2 verrouillee et sortait en erreur, ce que le launcher + * jpackage traduit par un trompeur « Failed to launch JVM ». On detecte + * donc tres tot (avant Spring) qu'une instance tourne deja, et on se + * contente d'ouvrir le navigateur puis de sortir proprement (code 0).
  2. + *
  3. Ouverture du navigateur : l'app n'ayant pas de fenetre native, + * on ouvre le navigateur par defaut sur l'URL locale pour que l'utilisateur + * voie l'application immediatement.
  4. + *
+ * Volontairement sans dependance a {@code java.awt.Desktop} : ce module + * ({@code java.desktop}) pourrait etre absent du runtime reduit par jlink. + * On passe donc par la commande systeme d'ouverture d'URL. + */ +public final class DesktopSingleInstance { + + /** Conserve le verrou ouvert pour TOUTE la duree de vie du process (sinon GC = relache). */ + @SuppressWarnings("unused") + private static FileChannel lockChannel; + private static FileLock lock; + + private DesktopSingleInstance() {} + + /** Vrai si le profil Spring actif inclut "local" (cas de l'app de bureau). */ + public static boolean isLocalProfile(String[] args) { + String prop = System.getProperty("spring.profiles.active", ""); + String env = System.getenv().getOrDefault("SPRING_PROFILES_ACTIVE", ""); + if (containsLocal(prop) || containsLocal(env)) return true; + if (args != null) { + for (String a : args) { + if (a.startsWith("--spring.profiles.active=") && containsLocal(a)) return true; + } + } + return false; + } + + private static boolean containsLocal(String s) { + for (String p : s.split("[,=]")) { + if (p.trim().equals("local")) return true; + } + return false; + } + + /** + * Tente de prendre le verrou d'instance unique (fichier {@code .instance.lock} + * sous loremind.home). Retourne {@code true} si on est la PREMIERE instance + * (verrou obtenu, on doit demarrer le serveur), {@code false} si une autre + * instance le detient deja. + *

+ * En cas d'erreur d'E/S inattendue, on retourne {@code true} (degradation + * prudente : mieux vaut tenter de demarrer que bloquer l'app). + */ + public static boolean tryAcquire() { + try { + Path dir = loremindHome(); + Files.createDirectories(dir); + Path lockFile = dir.resolve(".instance.lock"); + lockChannel = FileChannel.open(lockFile, + StandardOpenOption.CREATE, StandardOpenOption.WRITE); + lock = lockChannel.tryLock(); + return lock != null; // null = deja verrouille par une autre instance + } catch (IOException e) { + System.err.println("[Desktop] Verrou d'instance indisponible (" + e.getMessage() + + ") — on tente de demarrer quand meme."); + return true; + } + } + + /** Ouvre le navigateur par defaut sur l'URL de l'application locale. */ + public static void openAppInBrowser() { + openUrl("http://localhost:" + System.getProperty("server.port", "8080") + "/"); + } + + /** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */ + public static void openUrl(String url) { + try { + String os = System.getProperty("os.name", "").toLowerCase(); + ProcessBuilder pb; + if (os.contains("win")) { + // rundll32 : ouverture d'URL fiable sans dependance graphique Java. + pb = new ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", url); + } else if (os.contains("mac")) { + pb = new ProcessBuilder("open", url); + } else { + pb = new ProcessBuilder("xdg-open", url); + } + pb.start(); + } catch (IOException e) { + System.err.println("[Desktop] Impossible d'ouvrir le navigateur sur " + url + + " : " + e.getMessage() + ". Ouvrez-le manuellement."); + } + } + + private static Path loremindHome() { + String home = System.getProperty("loremind.home"); + if (home != null && !home.isBlank()) return Path.of(home); + return Path.of(System.getProperty("user.home"), ".loremind"); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopUpdateService.java b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopUpdateService.java new file mode 100644 index 0000000..78e38a1 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopUpdateService.java @@ -0,0 +1,129 @@ +package com.loremind.infrastructure.desktop; + +import com.fasterxml.jackson.databind.JsonNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.info.BuildProperties; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; +import java.util.Optional; + +/** + * Verification des mises a jour pour l'application de BUREAU (profil "local"). + *

+ * Contrairement au mode Docker (registry + Watchtower, cf. + * {@link com.loremind.infrastructure.updates.UpdateCheckService}), il n'y a pas + * de mise a jour automatique : on interroge l'API GitHub Releases pour la + * derniere release STABLE, on compare a la version courante du binaire, et si + * une version plus recente existe on le signale (via l'icone systray, cf. + * {@link SystemTrayManager}). L'utilisateur telecharge puis lance le nouvel + * installeur (MSI de meme UpgradeCode = mise a jour en place). + *

+ * {@code /releases/latest} ne renvoie que les releases stables (pas les + * prereleases) : les utilisateurs stables ne sont donc pas notifies des betas. + */ +@Service +@Profile("local") +public class DesktopUpdateService { + + private static final Logger log = LoggerFactory.getLogger(DesktopUpdateService.class); + + private final RestTemplate http; + private final boolean enabled; + private final String releasesApiUrl; + /** Version semver du binaire courant (ex: "0.14.0"), ou null en dev sans build-info. */ + private final String currentVersion; + + public DesktopUpdateService( + RestTemplateBuilder builder, + @Value("${desktop.update.enabled:true}") boolean enabled, + @Value("${desktop.update.releases-api-url:https://api.github.com/repos/IGMLcreation/LoreMind/releases/latest}") String releasesApiUrl, + @Nullable BuildProperties buildProperties) { + this.http = builder + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(10)) + .build(); + this.enabled = enabled; + this.releasesApiUrl = releasesApiUrl; + this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null; + } + + /** + * Interroge GitHub Releases. Retourne les infos de mise a jour SI une version + * plus recente que la version courante existe, sinon {@code Optional.empty()} + * (a jour, desactive, ou verification impossible — jamais d'exception propagee). + */ + public Optional checkForUpdate() { + if (!enabled || currentVersion == null) { + return Optional.empty(); + } + try { + HttpHeaders headers = new HttpHeaders(); + // GitHub exige un User-Agent ; l'Accept versionne l'API. + headers.set(HttpHeaders.USER_AGENT, "LoreMind-Desktop"); + headers.set(HttpHeaders.ACCEPT, "application/vnd.github+json"); + + ResponseEntity resp = http.exchange( + releasesApiUrl, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class); + JsonNode body = resp.getBody(); + if (body == null || body.path("tag_name").isMissingNode()) { + return Optional.empty(); + } + String tag = body.path("tag_name").asText(""); // ex: "v0.15.0" + String releaseUrl = body.path("html_url").asText(null); // page de la release + String latest = tag.startsWith("v") ? tag.substring(1) : tag; + + if (!latest.isBlank() && compareSemver(currentVersion, latest) < 0) { + log.info("[Update] Nouvelle version disponible : {} (courante : {})", latest, currentVersion); + return Optional.of(new UpdateInfo(currentVersion, latest, releaseUrl)); + } + log.info("[Update] A jour (courante : {}, derniere release : {}).", currentVersion, latest); + return Optional.empty(); + } catch (Exception e) { + // Hors-ligne, rate-limit GitHub, etc. : non bloquant, on ne notifie juste pas. + log.info("[Update] Verification GitHub Releases impossible : {}", e.getMessage()); + return Optional.empty(); + } + } + + /** Infos d'une mise a jour disponible. */ + public record UpdateInfo(String currentVersion, String latestVersion, String releaseUrl) {} + + /** + * Compare deux versions MAJOR.MINOR.PATCH (suffixe -beta/-rc ignore). + * @return <0 si a<b, 0 si egales, >0 si a>b. + */ + static int compareSemver(String a, String b) { + int[] va = parse(a); + int[] vb = parse(b); + for (int i = 0; i < 3; i++) { + int cmp = Integer.compare(va[i], vb[i]); + if (cmp != 0) return cmp; + } + return 0; + } + + private static int[] parse(String version) { + String core = version.split("[-+]", 2)[0]; // retire -beta, -rc, +build... + String[] parts = core.split("\\."); + int[] out = new int[3]; + for (int i = 0; i < 3 && i < parts.length; i++) { + try { + out[i] = Integer.parseInt(parts[i].trim()); + } catch (NumberFormatException ignored) { + out[i] = 0; + } + } + return out; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/desktop/SystemTrayManager.java b/core/src/main/java/com/loremind/infrastructure/desktop/SystemTrayManager.java new file mode 100644 index 0000000..68193f1 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/desktop/SystemTrayManager.java @@ -0,0 +1,154 @@ +package com.loremind.infrastructure.desktop; + +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.MenuItem; +import java.awt.PopupMenu; +import java.awt.RenderingHints; +import java.awt.SystemTray; +import java.awt.TrayIcon; +import java.awt.image.BufferedImage; + +/** + * Icone dans la zone de notification (barre des taches) en mode bureau + * (profil "local"). Donne a l'utilisateur un controle visible de l'application, + * qui tourne sinon en serveur sans fenetre : impossible autrement de la fermer + * proprement (fermer l'onglet du navigateur laisse le Core et le Brain tourner). + *

+ * Menu : « Ouvrir LoreMind » (ouvre le navigateur) et « Quitter LoreMind » + * (ferme le contexte Spring — ce qui declenche le {@code @PreDestroy} de + * {@link com.loremind.infrastructure.ai.BrainSidecar} et arrete donc aussi le + * Brain — puis termine le process). + *

+ * Necessite que le mode headless soit desactive (cf. LoreMindApplication.main, + * qui appelle {@code setHeadless(false)} en profil local). Le module + * {@code java.desktop} est embarque dans le runtime jpackage. + */ +@Component +@Profile("local") +public class SystemTrayManager { + + private static final Logger log = LoggerFactory.getLogger(SystemTrayManager.class); + + private final ConfigurableApplicationContext context; + private final DesktopUpdateService updateService; + private TrayIcon trayIcon; + private PopupMenu popup; + + public SystemTrayManager(ConfigurableApplicationContext context, + DesktopUpdateService updateService) { + this.context = context; + this.updateService = updateService; + } + + @EventListener(ApplicationReadyEvent.class) + public void install() { + if (!SystemTray.isSupported()) { + log.warn("[Tray] Zone de notification non supportee sur ce systeme — " + + "pas d'icone. Pour quitter : menu de la fenetre console, ou gestionnaire des taches."); + return; + } + try { + popup = new PopupMenu(); + + MenuItem open = new MenuItem("Ouvrir LoreMind"); + open.addActionListener(e -> DesktopSingleInstance.openAppInBrowser()); + popup.add(open); + + popup.addSeparator(); + + MenuItem quit = new MenuItem("Quitter LoreMind"); + quit.addActionListener(e -> quit()); + popup.add(quit); + + trayIcon = new TrayIcon(createIcon(), "LoreMind", popup); + trayIcon.setImageAutoSize(true); + // Double-clic sur l'icone : ouvre l'application dans le navigateur. + trayIcon.addActionListener(e -> DesktopSingleInstance.openAppInBrowser()); + + SystemTray.getSystemTray().add(trayIcon); + log.info("[Tray] Icone installee dans la zone de notification."); + + // Verification de mise a jour en arriere-plan (appel reseau GitHub) : + // ne bloque pas le demarrage ; met a jour le menu/notifie si dispo. + new Thread(this::checkForUpdate, "loremind-update-check").start(); + } catch (Exception e) { + // Echec non bloquant : l'app reste utilisable, seul le confort de l'icone manque. + log.warn("[Tray] Installation de l'icone impossible : {}", e.getMessage()); + } + } + + /** + * Interroge GitHub Releases ; si une version plus recente existe, ajoute un + * item de menu « Telecharger » et affiche une bulle de notification. L'item + * ouvre la page de la release dans le navigateur (telechargement manuel du + * nouvel installeur). + */ + private void checkForUpdate() { + updateService.checkForUpdate().ifPresent(info -> { + String label = "⬇ Telecharger la mise a jour (v" + info.latestVersion() + ")"; + MenuItem update = new MenuItem(label); + update.addActionListener(e -> DesktopSingleInstance.openUrl(info.releaseUrl())); + // En tete de menu pour la visibilite, suivi d'un separateur. + popup.insert(update, 0); + popup.insertSeparator(1); + + trayIcon.displayMessage( + "LoreMind — mise a jour disponible", + "Version " + info.latestVersion() + " disponible (vous avez " + info.currentVersion() + + "). Menu de l'icone → Telecharger.", + TrayIcon.MessageType.INFO); + }); + } + + /** + * Arret propre depuis le menu « Quitter » : on retire l'icone puis on ferme + * le contexte Spring dans un thread dedie (l'action s'execute sur l'EDT AWT ; + * fermer le contexte + arreter Tomcat/Brain depuis l'EDT pourrait le bloquer). + */ + private void quit() { + log.info("[Tray] Demande de fermeture de l'application."); + new Thread(() -> { + int code = SpringApplication.exit(context, () -> 0); + System.exit(code); + }, "loremind-shutdown").start(); + } + + /** Retire l'icone si le contexte se ferme par une autre voie (ex. Ctrl+C). */ + @PreDestroy + public void remove() { + if (trayIcon != null) { + SystemTray.getSystemTray().remove(trayIcon); + } + } + + /** + * Genere une petite icone (carre arrondi violet « L ») sans dependre d'un + * fichier image — robuste quel que soit l'empaquetage. + */ + private Image createIcon() { + int size = 16; + BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(new Color(0x8B, 0x5C, 0xF6)); // violet de marque LoreMind + g.fillRoundRect(0, 0, size, size, 5, 5); + g.setColor(Color.WHITE); + g.setFont(new Font("SansSerif", Font.BOLD, 12)); + g.drawString("L", 4, 13); + g.dispose(); + return img; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/storage/FilesystemImageStorageAdapter.java b/core/src/main/java/com/loremind/infrastructure/storage/FilesystemImageStorageAdapter.java new file mode 100644 index 0000000..532659f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/storage/FilesystemImageStorageAdapter.java @@ -0,0 +1,112 @@ +package com.loremind.infrastructure.storage; + +import com.loremind.domain.images.ports.ImageStorage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +/** + * Adaptateur d'infrastructure : implemente le port ImageStorage en stockant + * les binaires sur le SYSTEME DE FICHIERS local. + *

+ * Pendant a {@link MinioImageStorageAdapter} pour le mode "local-first" + * (application de bureau empaquetee via jpackage, sans Docker ni MinIO). + * Active uniquement quand {@code storage.backend=filesystem} ; en l'absence + * de cette propriete, c'est l'adaptateur MinIO qui prend le relais (defaut). + *

+ * On reutilise EXACTEMENT le meme schema de cle que MinIO ({@code images/UUID.ext}) + * pour que les cles restent interchangeables entre les deux backends : une base + * migree de l'un vers l'autre continue de fonctionner sans reecriture. + */ +@Component +@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem") +public class FilesystemImageStorageAdapter implements ImageStorage { + + private final Path root; + + public FilesystemImageStorageAdapter(@Value("${storage.filesystem.path}") String basePath) { + this.root = Path.of(basePath).toAbsolutePath().normalize(); + } + + @PostConstruct + void ensureRootExists() { + try { + Files.createDirectories(root); + System.out.println("[Storage] Backend filesystem actif — racine : " + root); + } catch (IOException e) { + throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e); + } + } + + @Override + public String upload(String filename, String contentType, InputStream data, long sizeBytes) { + String storageKey = generateStorageKey(filename); + Path target = resolveKey(storageKey); + try { + Files.createDirectories(target.getParent()); + Files.copy(data, target); + return storageKey; + } catch (IOException e) { + throw new UncheckedIOException("Echec de l'ecriture de l'image sur disque : " + target, e); + } + } + + @Override + public InputStream download(String storageKey) { + Path source = resolveKey(storageKey); + if (!Files.exists(source)) { + // Cle orpheline : meme contrat que MinIO (null plutot qu'exception). + return null; + } + try { + return Files.newInputStream(source); + } catch (IOException e) { + throw new UncheckedIOException("Echec de la lecture de l'image : " + source, e); + } + } + + @Override + public void delete(String storageKey) { + try { + Files.deleteIfExists(resolveKey(storageKey)); + } catch (IOException e) { + // Suppression idempotente : on loggue mais on ne propage pas (cf. MinIO). + System.err.println("[Storage] Erreur suppression (non bloquante) : " + e.getMessage()); + } + } + + /** + * Resout une cle opaque en chemin physique, en se premunissant contre la + * traversee de repertoire : le chemin resolu DOIT rester sous {@code root} + * (une cle malveillante du type {@code ../../etc/passwd} est rejetee). + */ + private Path resolveKey(String storageKey) { + Path resolved = root.resolve(storageKey).normalize(); + if (!resolved.startsWith(root)) { + throw new IllegalArgumentException("Cle de stockage invalide (hors racine) : " + storageKey); + } + return resolved; + } + + /** Identique a MinioImageStorageAdapter : cle unique + extension d'origine. */ + private String generateStorageKey(String originalFilename) { + return "images/" + UUID.randomUUID() + extractExtension(originalFilename); + } + + private String extractExtension(String filename) { + if (filename == null) return ""; + int dot = filename.lastIndexOf('.'); + if (dot < 0 || dot == filename.length() - 1) return ""; + String ext = filename.substring(dot).toLowerCase(); + // On n'accepte que les extensions connues pour eviter les injections de path. + return ext.matches("\\.(jpg|jpeg|png|webp|gif)") ? ext : ""; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/storage/MinioConfig.java b/core/src/main/java/com/loremind/infrastructure/storage/MinioConfig.java index 425a4d8..dac2433 100644 --- a/core/src/main/java/com/loremind/infrastructure/storage/MinioConfig.java +++ b/core/src/main/java/com/loremind/infrastructure/storage/MinioConfig.java @@ -5,6 +5,7 @@ import io.minio.MakeBucketArgs; import io.minio.MinioClient; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,8 +15,13 @@ import org.springframework.context.annotation.Configuration; * Expose un bean MinioClient singleton injecte dans MinioImageStorageAdapter. * S'assure au demarrage que le bucket configure existe (filet de securite : * normalement docker-compose/minio-init l'a deja cree). + *

+ * Desactive en mode local-first ({@code storage.backend=filesystem}) : aucun + * client MinIO n'est alors instancie, donc aucune tentative de connexion au + * boot. Defaut = actif (propriete absente ou {@code minio}). */ @Configuration +@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true) public class MinioConfig { @Value("${minio.endpoint}") diff --git a/core/src/main/java/com/loremind/infrastructure/storage/MinioImageStorageAdapter.java b/core/src/main/java/com/loremind/infrastructure/storage/MinioImageStorageAdapter.java index 16c3f20..ee0c2f8 100644 --- a/core/src/main/java/com/loremind/infrastructure/storage/MinioImageStorageAdapter.java +++ b/core/src/main/java/com/loremind/infrastructure/storage/MinioImageStorageAdapter.java @@ -7,6 +7,7 @@ import io.minio.PutObjectArgs; import io.minio.RemoveObjectArgs; import io.minio.errors.ErrorResponseException; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import java.io.InputStream; @@ -17,8 +18,13 @@ import java.util.UUID; * MinIO (compatible S3) comme backend de stockage d'objets. *

* Le domaine ne sait rien de MinIO : il manipule juste des cles opaques. + *

+ * Backend par defaut ({@code storage.backend=minio} ou propriete absente). + * Le mode local-first le remplace par {@link FilesystemImageStorageAdapter} + * via {@code storage.backend=filesystem}. */ @Component +@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true) public class MinioImageStorageAdapter implements ImageStorage { private final MinioClient minioClient; diff --git a/core/src/main/java/com/loremind/infrastructure/web/config/LocalWebConfig.java b/core/src/main/java/com/loremind/infrastructure/web/config/LocalWebConfig.java new file mode 100644 index 0000000..2026863 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/config/LocalWebConfig.java @@ -0,0 +1,86 @@ +package com.loremind.infrastructure.web.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.PathResourceResolver; + +import java.io.IOException; + +/** + * Service du front Angular en statique par le Core, en mode local-first. + *

+ * En deploiement Docker, le front est servi par un conteneur nginx dedie + * (service {@code web}) et le Core reste une API pure. En mode local + * (application de bureau empaquetee), il n'y a pas de nginx : le build Angular + * est copie dans {@code classpath:/static/} (cf. profil Maven de packaging) et + * le Core le sert lui-meme sur la meme origine que l'API. + *

+ * Active uniquement sous le profil {@code local} pour ne RIEN changer au + * comportement du conteneur Core en production. + *

+ * Fallback SPA : toute route qui ne correspond pas a un fichier statique reel + * (et qui n'est pas une route d'API) renvoie {@code index.html}, afin que le + * routing cote Angular (deep links, rechargement de page) fonctionne. + */ +@Configuration +@Profile("local") +public class LocalWebConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + // Fichiers de traduction (assets/i18n/*.json) : nom STABLE (non hashe), + // donc jamais mis en cache, sinon le navigateur ressert un JSON perime + // (voire un 304 sur un corps en cache obsolete) apres une mise a jour. + // Symptome observe : les libelles restent en cles brutes pour une langue. + // Motif plus specifique que "/**" => prioritaire pour ces chemins. + registry.addResourceHandler("/assets/i18n/**") + .addResourceLocations("classpath:/static/assets/i18n/") + .setCacheControl(CacheControl.noStore()); + + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/") + .resourceChain(true) + .addResolver(new PathResourceResolver() { + @Override + protected Resource getResource(String resourcePath, Resource location) throws IOException { + Resource requested = location.createRelative(resourcePath); + if (requested.exists() && requested.isReadable()) { + return requested; + } + // Ne JAMAIS rabattre les routes techniques sur index.html : + // une API inexistante doit rester un 404, pas du HTML. + if (resourcePath.startsWith("api/") || resourcePath.startsWith("actuator/")) { + return null; + } + // Un ASSET manquant (chemin avec extension : .json, .js, .css, + // .png...) doit renvoyer 404 — surtout PAS index.html. Sinon un + // loader JSON (ex. ngx-translate chargeant assets/i18n/fr.json) + // recevrait du HTML en 200 et echouerait au parse, donnant des + // cles brutes a l'ecran. On ne rabat sur la coquille SPA que les + // vraies routes applicatives (sans extension de fichier). + if (hasFileExtension(resourcePath)) { + return null; + } + // Route applicative Angular -> on sert la coquille SPA. + Resource index = new ClassPathResource("/static/index.html"); + return index.exists() ? index : null; + } + }); + } + + /** + * Vrai si le dernier segment du chemin contient un point (donc une extension + * de fichier : {@code assets/i18n/fr.json}, {@code main.js}...). Les routes + * applicatives Angular ({@code settings}, {@code campaigns/42}) n'en ont pas. + */ + private static boolean hasFileExtension(String resourcePath) { + int lastSlash = resourcePath.lastIndexOf('/'); + String lastSegment = resourcePath.substring(lastSlash + 1); + return lastSegment.contains("."); + } +} diff --git a/core/src/main/resources/application-local.properties b/core/src/main/resources/application-local.properties new file mode 100644 index 0000000..8f2da0c --- /dev/null +++ b/core/src/main/resources/application-local.properties @@ -0,0 +1,86 @@ +# ============================================================================ +# Profil "local" — mode local-first (application de bureau, sans Docker) +# ============================================================================ +# Active via : --spring.profiles.active=local (ou SPRING_PROFILES_ACTIVE=local) +# +# Objectif : faire tourner le Core sur le poste de l'utilisateur SANS aucune +# infrastructure externe (ni Postgres, ni MinIO, ni Docker). Toutes les donnees +# vivent sous loremind.home (defaut : ~/.loremind). +# +# Surcharge UNIQUEMENT ce qui differe du profil par defaut (application.properties) : +# le reste (timeouts, multipart, licensing...) est herite tel quel. + +# --- Base de donnees : H2 en mode fichier, compatibilite PostgreSQL --------- +# MODE=PostgreSQL : H2 interprete le SQL PostgreSQL -> les MEMES migrations +# Flyway (ecrites en SQL Postgres) tournent ici comme sur la prod Postgres. +# Aligne aussi la casse des identifiants (minuscules, comme PG). +# DB_CLOSE_ON_EXIT=FALSE : Spring/Hikari pilote la fermeture (pas le shutdown +# hook H2). AUTO_SERVER volontairement absent (interdit avec DB_CLOSE_ON_EXIT, +# et inutile pour un process unique). +# NON_KEYWORDS=VALUE : PostgreSQL accepte `value` comme nom de colonne non-quote +# (colonne de playthrough_flag), mais H2 le reserve par defaut -> on le relache +# pour que le baseline SQL Postgres (non-quote) passe aussi sur H2. Ajouter +# d'autres mots ici si une migration future utilise un identifiant reserve H2. +spring.datasource.url=jdbc:h2:file:${loremind.home}/db/loremind;MODE=PostgreSQL;DB_CLOSE_ON_EXIT=FALSE;NON_KEYWORDS=VALUE +spring.datasource.username=sa +spring.datasource.password= +spring.datasource.driver-class-name=org.h2.Driver +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +# Schema gere par Flyway (cf. application.properties) ; Hibernate valide seulement. +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false + +# --- Stockage des images : systeme de fichiers ----------------------------- +storage.backend=filesystem +storage.filesystem.path=${loremind.home}/images + +# --- Brain : service IA Python lance en sidecar sur le poste --------------- +brain.base-url=http://localhost:8000 +# Secret partage Core <-> Brain. En local il n'a pas de role securitaire fort +# (tout est sur localhost, mono-utilisateur) mais le Brain est fail-closed : +# il DOIT etre defini des deux cotes. Le lanceur de sidecar (BrainSidecar) +# propage cette meme valeur au process Brain via la variable BRAIN_INTERNAL_SECRET. +brain.internal-secret=${BRAIN_INTERNAL_SECRET:loremind-local-secret} + +# --- Brain en sidecar (lance par le Core) ---------------------------------- +# Le Core demarre lui-meme le process Brain (pas de Docker en local). +brain.sidecar.enabled=${BRAIN_SIDECAR_ENABLED:true} +# Le Brain ecrit son dossier data/ (index vectoriel, settings) sous ce dossier. +brain.sidecar.working-dir=${loremind.home}/brain +# Commande de lancement. VIDE par defaut : le dev qui lance le Brain a la main +# n'est pas perturbe. Le packaging (jpackage) la renseigne vers l'exe PyInstaller. +# - Empaquete : chemin de l'exe, ex. C:/Program Files/LoreMind/brain/loremind-brain.exe +# - Dev : python,-m,uvicorn,app.main:app,--host,127.0.0.1,--port,8000 +# (avec brain.sidecar.working-dir pointant sur le dossier brain/) +brain.sidecar.command=${BRAIN_SIDECAR_COMMAND:} + +# --- Admin (localhost uniquement) ------------------------------------------ +# Application mono-utilisateur sur le poste : l'utilisateur EST l'admin. +# Identifiants par defaut surchargables ; non exposes hors de la machine. +admin.username=${ADMIN_USERNAME:admin} +admin.password=${ADMIN_PASSWORD:admin} + +# --- Front servi par le Core (meme origine) -------------------------------- +# Le front Angular est servi en statique par le Core (cf. LocalWebConfig), +# donc tout est sur http://localhost:8080 : CORS sans objet, mais on autorise +# l'origine locale par securite si un dev lance Angular separement. +spring.web.cors.allowed-origins=http://localhost:8080,http://localhost:4200 + +# Mode demo desactive (instance personnelle complete). +app.demo-mode=false + +# --- Licensing / Patreon : desactive en mode bureau ------------------------ +# Le gating par IMAGE Docker (canal beta tire d'un registry prive) n'a aucun +# sens hors Docker. On coupe toute la machinerie : daemon de refresh no-op, +# /api/license/* renvoie enabled=false, et l'UI masque la section Patreon. +# La distribution beta desktop se fait par installeur beta via Patreon, pas +# par une connexion in-app. +licensing.enabled=false + +# --- Verification des mises a jour (bureau) -------------------------------- +# Interroge l'API GitHub Releases (derniere release STABLE) au demarrage ; si +# une version plus recente existe, l'icone systray le signale (bulle + item +# « Telecharger »). Pas de mise a jour auto : l'utilisateur telecharge et lance +# le nouvel installeur. Mettre a false pour desactiver toute requete sortante. +desktop.update.enabled=${DESKTOP_UPDATE_CHECK:true} +desktop.update.releases-api-url=https://api.github.com/repos/IGMLcreation/LoreMind/releases/latest diff --git a/core/src/main/resources/application.properties b/core/src/main/resources/application.properties index cec2dea..0215f36 100644 --- a/core/src/main/resources/application.properties +++ b/core/src/main/resources/application.properties @@ -22,10 +22,26 @@ spring.datasource.driver-class-name=org.postgresql.Driver # Configuration JPA / Hibernate spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect -spring.jpa.hibernate.ddl-auto=update +# Le schema est desormais gere par Flyway (migrations versionnees), plus par +# Hibernate. ddl-auto=validate : Hibernate ne MODIFIE plus le schema, il verifie +# juste au demarrage que les entites correspondent au schema cree par Flyway +# (filet de securite contre les derives entites<->migrations). +spring.jpa.hibernate.ddl-auto=validate spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true +# ============================================================================ +# Flyway : migrations de schema versionnees (src/main/resources/db/migration). +# ============================================================================ +# baseline-on-migrate : sur une base EXISTANTE (prod deja peuplee, sans table +# d'historique Flyway), Flyway l'estampille a la version baseline SANS rejouer +# V1 -> les donnees existantes sont preservees. Sur une base VIDE (install +# neuve), Flyway joue V1__baseline.sql normalement pour creer le schema. +spring.flyway.enabled=true +spring.flyway.baseline-on-migrate=true +spring.flyway.baseline-version=1 +spring.flyway.baseline-description=Baseline (schema pre-Flyway, gere jusque-la par ddl-auto) + # Configuration CORS pour autoriser le Frontend Angular spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200} spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS @@ -48,6 +64,17 @@ brain.internal-secret=${BRAIN_INTERNAL_SECRET:} admin.username=${ADMIN_USERNAME:admin} admin.password=${ADMIN_PASSWORD:} +# Repertoire de base de l'instance (donnees locales hors Docker). +# Utilise par le profil "local" (H2 fichier, stockage images filesystem...). +loremind.home=${LOREMIND_HOME:${user.home}/.loremind} + +# Backend de stockage des binaires d'images (port ImageStorage) : +# - minio : MinIO/S3 (defaut — deploiement Docker/serveur) +# - filesystem : systeme de fichiers local (mode local-first / jpackage) +storage.backend=${STORAGE_BACKEND:minio} +# Racine du stockage filesystem (ignoree si storage.backend=minio). +storage.filesystem.path=${STORAGE_FS_PATH:${loremind.home}/images} + # Configuration MinIO (Shared Kernel images - Object Storage) # Le bucket est cree automatiquement par le service minio-init (docker-compose up -d). # Defaults OK pour dev local ; overrides en prod via env. diff --git a/core/src/main/resources/db/migration/V1__baseline.sql b/core/src/main/resources/db/migration/V1__baseline.sql new file mode 100644 index 0000000..de37b75 --- /dev/null +++ b/core/src/main/resources/db/migration/V1__baseline.sql @@ -0,0 +1,424 @@ + + create table arcs ( + "order" integer not null, + campaign_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + type varchar(16) default 'LINEAR' not null check (type in ('LINEAR','HUB')), + description TEXT, + gm_notes TEXT, + icon varchar(255), + illustration_image_ids TEXT, + map_image_ids TEXT, + name varchar(255) not null, + related_page_ids TEXT, + resolution TEXT, + rewards TEXT, + stakes TEXT, + themes TEXT, + primary key (id) + ); + + create table campaigns ( + arcs_count integer not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + description TEXT, + game_system_id varchar(255), + lore_id varchar(255), + name varchar(255) not null, + primary key (id) + ); + + create table catalog_items ( + position integer not null, + catalog_id bigint not null, + id bigint generated by default as identity, + price varchar(64), + category varchar(128), + description TEXT, + name varchar(255) not null, + primary key (id) + ); + + create table chapters ( + "order" integer not null, + arc_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + description TEXT, + gm_notes TEXT, + icon varchar(255), + illustration_image_ids TEXT, + map_image_ids TEXT, + name varchar(255) not null, + narrative_stakes TEXT, + player_objectives TEXT, + prerequisites TEXT, + related_page_ids TEXT, + primary key (id) + ); + + create table characters ( + "order" integer not null, + campaign_id bigint, + created_at timestamp(6) not null, + id bigint generated by default as identity, + playthrough_id bigint, + updated_at timestamp(6) not null, + field_values TEXT, + header_image_id varchar(255), + image_values TEXT, + key_value_values TEXT, + name varchar(255) not null, + portrait_image_id varchar(255), + primary key (id) + ); + + create table conversation_messages ( + conversation_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + role varchar(16) not null, + content TEXT not null, + primary key (id) + ); + + create table conversations ( + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + campaign_id varchar(255), + entity_id varchar(255), + entity_type varchar(255), + lore_id varchar(255), + title varchar(255) not null, + primary key (id) + ); + + create table enemies ( + "order" integer not null, + campaign_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + field_values TEXT, + folder varchar(255), + header_image_id varchar(255), + image_values TEXT, + key_value_values TEXT, + level varchar(255), + name varchar(255) not null, + portrait_image_id varchar(255), + primary key (id) + ); + + create table game_systems ( + is_public boolean not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + author varchar(255), + character_template TEXT, + description TEXT, + enemy_template TEXT, + name varchar(255) not null, + npc_template TEXT, + rules_markdown TEXT, + primary key (id) + ); + + create table images ( + id bigint generated by default as identity, + size_bytes bigint not null, + uploaded_at timestamp(6) not null, + content_type varchar(255) not null, + filename varchar(255) not null, + storage_key varchar(255) not null unique, + primary key (id) + ); + + create table item_catalogs ( + "order" integer not null, + campaign_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + icon varchar(64), + description TEXT, + name varchar(255) not null, + primary key (id) + ); + + create table licenses ( + beta_channel_enabled boolean not null, + last_refresh_succeeded boolean not null, + created_at timestamp(6) with time zone not null, + expires_at timestamp(6) with time zone not null, + issued_at timestamp(6) with time zone not null, + last_refresh_attempt_at timestamp(6) with time zone, + updated_at timestamp(6) with time zone not null, + id varchar(255) not null, + instance_id varchar(255) not null, + patreon_user_id varchar(255) not null, + raw_jwt TEXT not null, + tier_id varchar(255) not null, + primary key (id) + ); + + create table lore_nodes ( + created_at timestamp(6) not null, + id bigint generated by default as identity, + lore_id bigint not null, + parent_id bigint, + updated_at timestamp(6) not null, + icon varchar(64), + name varchar(255) not null, + primary key (id) + ); + + create table lores ( + node_count integer not null, + page_count integer not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + description TEXT, + name varchar(255) not null, + primary key (id) + ); + + create table notebook_messages ( + archived_at timestamp(6), + created_at timestamp(6) not null, + id bigint generated by default as identity, + notebook_id bigint not null, + role varchar(16) not null, + content TEXT not null, + primary key (id) + ); + + create table notebook_sources ( + chunk_count integer not null, + page_count integer not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + notebook_id bigint not null, + status varchar(16) not null, + filename varchar(255) not null, + primary key (id) + ); + + create table notebooks ( + campaign_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + name varchar(255) not null, + primary key (id) + ); + + create table npcs ( + "order" integer not null, + campaign_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + field_values TEXT, + folder varchar(255), + header_image_id varchar(255), + image_values TEXT, + key_value_values TEXT, + name varchar(255) not null, + portrait_image_id varchar(255), + related_page_ids TEXT, + primary key (id) + ); + + create table pages ( + created_at timestamp(6) not null, + id bigint generated by default as identity, + lore_id bigint not null, + node_id bigint not null, + template_id bigint, + updated_at timestamp(6) not null, + image_values_json TEXT, + key_value_values TEXT, + notes TEXT, + related_page_ids TEXT, + table_values TEXT, + tags TEXT, + title varchar(255) not null, + values_json TEXT, + primary key (id) + ); + + create table playthrough_flag ( + value boolean not null, + id bigint generated by default as identity, + playthrough_id bigint not null, + name varchar(128) not null, + primary key (id), + constraint uk_playthrough_flag_name unique (playthrough_id, name) + ); + + create table playthroughs ( + campaign_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + description TEXT, + name varchar(255) not null, + primary key (id) + ); + + create table quest_progression ( + chapter_id bigint not null, + id bigint generated by default as identity, + playthrough_id bigint not null, + status varchar(16) not null check (status in ('NOT_STARTED','IN_PROGRESS','COMPLETED')), + primary key (id), + constraint uk_quest_progression_unique unique (playthrough_id, chapter_id) + ); + + create table random_table_entries ( + max_roll integer not null, + min_roll integer not null, + position integer not null, + id bigint generated by default as identity, + random_table_id bigint not null, + detail TEXT, + label varchar(255) not null, + primary key (id) + ); + + create table random_tables ( + "order" integer not null, + campaign_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + dice_formula varchar(32) not null, + icon varchar(64), + description TEXT, + name varchar(255) not null, + primary key (id) + ); + + create table scenes ( + "order" integer not null, + chapter_id bigint not null, + created_at timestamp(6) not null, + id bigint generated by default as identity, + updated_at timestamp(6) not null, + atmosphere TEXT, + branches TEXT, + choices_consequences TEXT, + combat_difficulty TEXT, + description TEXT, + enemies TEXT, + enemy_ids TEXT, + gm_secret_notes TEXT, + icon varchar(255), + illustration_image_ids TEXT, + location TEXT, + map_image_ids TEXT, + name varchar(255) not null, + player_narration TEXT, + related_page_ids TEXT, + rooms TEXT, + timing TEXT, + primary key (id) + ); + + create table session_entries ( + created_at timestamp(6) not null, + id bigint generated by default as identity, + occurred_at timestamp(6) not null, + updated_at timestamp(6) not null, + type varchar(32) not null check (type in ('NOTE','EVENT','DICE_ROLL','PLAYER_ACTION')), + content TEXT not null, + session_id varchar(255) not null, + primary key (id) + ); + + create table sessions ( + created_at timestamp(6) not null, + ended_at timestamp(6), + id bigint generated by default as identity, + playthrough_id bigint, + started_at timestamp(6) not null, + updated_at timestamp(6) not null, + campaign_id varchar(255), + name varchar(255) not null, + primary key (id) + ); + + create table templates ( + created_at timestamp(6) not null, + default_node_id bigint, + id bigint generated by default as identity, + lore_id bigint not null, + updated_at timestamp(6) not null, + description TEXT, + fields TEXT, + name varchar(255) not null, + primary key (id) + ); + + create index idx_catalog_items_catalog_id + on catalog_items (catalog_id); + + create index idx_conv_lore_entity + on conversations (lore_id, entity_type, entity_id, updated_at); + + create index idx_conv_campaign_entity + on conversations (campaign_id, entity_type, entity_id, updated_at); + + create index idx_enemies_campaign_id + on enemies (campaign_id); + + create index idx_item_catalogs_campaign_id + on item_catalogs (campaign_id); + + create index idx_notebook_messages_notebook_id + on notebook_messages (notebook_id); + + create index idx_notebook_sources_notebook_id + on notebook_sources (notebook_id); + + create index idx_notebooks_campaign_id + on notebooks (campaign_id); + + create index ix_playthrough_flag_playthrough + on playthrough_flag (playthrough_id); + + create index ix_playthrough_campaign + on playthroughs (campaign_id); + + create index ix_quest_progression_playthrough + on quest_progression (playthrough_id); + + create index idx_random_table_entries_table_id + on random_table_entries (random_table_id); + + create index idx_session_entries_session_id + on session_entries (session_id); + + alter table if exists catalog_items + add constraint FK7tuoq6mwuc00yv0hhpiw4aoni + foreign key (catalog_id) + references item_catalogs; + + alter table if exists conversation_messages + add constraint FKcr8qqgnqnaqq2hw3gr4wtfe2a + foreign key (conversation_id) + references conversations; + + alter table if exists random_table_entries + add constraint FKly4syvpfit2kibfjut67xxrrq + foreign key (random_table_id) + references random_tables; diff --git a/core/src/test/resources/application.properties b/core/src/test/resources/application.properties index 8d90fdb..321c472 100644 --- a/core/src/test/resources/application.properties +++ b/core/src/test/resources/application.properties @@ -14,6 +14,10 @@ spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=false +# Flyway desactive en test : le schema est gere par Hibernate create-drop +# (recree a chaque run), pas par les migrations. Evite tout conflit Flyway/DDL. +spring.flyway.enabled=false + # Pool Hikari volontairement minuscule en test : la suite cree de nombreux # contextes Spring distincts (combinaisons de @MockBean / @TestPropertySource), # tous gardes en cache simultanement. Avec le pool par defaut (10), on epuisait diff --git a/installers/desktop/build-windows.ps1 b/installers/desktop/build-windows.ps1 new file mode 100644 index 0000000..7429eba --- /dev/null +++ b/installers/desktop/build-windows.ps1 @@ -0,0 +1,214 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Construit l'installeur de BUREAU Windows de LoreMind (.msi), sans Docker. + +.DESCRIPTION + Pipeline complet "local-first" : + 1. Build du front Angular (web/ -> web/dist/web) + 2. Prep du Brain (Python embeddable) (brain/ -> dist-embed : python.exe signe PSF + deps + sources) + 3. Build du Core en fat jar + front (core/ -> target/*.jar, profil Maven "desktop") + 4. Assemblage de la charge utile (jar + brain) dans un dossier d'entree jpackage + 5. jpackage -> installeur .msi avec JRE embarque + + L'app resultante se lance d'un double-clic : le Core demarre en profil Spring + "local" (H2 fichier + stockage filesystem) et lance lui-meme le Brain en sidecar. + Aucune dependance externe a installer cote utilisateur (ni Docker, ni Java, ni Python). + L'IA fonctionne via le cloud (1min.ai / Gemini / Mistral...) ou via Ollama si present. + +.PARAMETER Version + Version de l'installeur (X.Y.Z, numerique). Defaut : derivee de core/pom.xml + (le suffixe -beta est retire car les MSI n'acceptent qu'une version numerique). + +.PARAMETER SkipFront / SkipBrain / SkipJar + Sauter une etape (build incrementaux pendant la mise au point). + +.PREREQUIS (sur la machine de build uniquement, PAS chez l'utilisateur final) + - JDK 21+ avec jpackage dans le PATH (Temurin OK). + - WiX Toolset v3 (https://github.com/wixtoolset/wix3/releases) — requis par + jpackage pour produire un .msi sur Windows. + - Node.js + npm (build Angular). + - Python + pip (telecharge les wheels cp312 du Brain ; toute version 3.x convient, + on cible explicitement 3.12 via --python-version) + acces Internet (python.org). + +.NOTES + Projet : LoreMind — assistant pour Maitres de Jeu de JDR + Licence : AGPL-3.0 +#> + +[CmdletBinding()] +param( + [string]$Version, + [switch]$SkipFront, + [switch]$SkipBrain, + [switch]$SkipJar +) + +$ErrorActionPreference = 'Stop' + +function Write-Step($m) { Write-Host "==> $m" -ForegroundColor Cyan } +function Write-Ok($m) { Write-Host " OK $m" -ForegroundColor Green } +function Write-Err($m) { Write-Host " XX $m" -ForegroundColor Red } + +# --- Chemins --------------------------------------------------------------- +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$WebDir = Join-Path $RepoRoot 'web' +$BrainDir = Join-Path $RepoRoot 'brain' +$CoreDir = Join-Path $RepoRoot 'core' +$StageDir = Join-Path $CoreDir 'target\dist-input' # charge utile jpackage +$OutDir = Join-Path $CoreDir 'target\dist-out' # .msi produit + +# --- Version (numerique pour MSI) ------------------------------------------ +if (-not $Version) { + $pom = Get-Content (Join-Path $CoreDir 'pom.xml') -Raw + # IMPORTANT : viser la version DU PROJET, pas le premier du fichier + # (qui est celui du parent spring-boot-starter-parent). On l'ancre sur + # l'artifactId du projet, puis on garde la partie numerique (MSI = X.Y.Z). + if ($pom -match 'loremind-core\s*([0-9]+\.[0-9]+\.[0-9]+)') { + $Version = $Matches[1] + } else { + $Version = '0.0.0' + } +} +Write-Host "" +Write-Host "============================================================" -ForegroundColor Magenta +Write-Host " LoreMind - Build installeur bureau Windows (v$Version)" -ForegroundColor Magenta +Write-Host "============================================================" -ForegroundColor Magenta + +# --- Verif outils ---------------------------------------------------------- +if (-not (Get-Command jpackage -ErrorAction SilentlyContinue)) { + Write-Err "jpackage introuvable dans le PATH. Installez un JDK 21+ (Temurin)." + exit 1 +} + +# --- 1. Front Angular ------------------------------------------------------ +if (-not $SkipFront) { + Write-Step "Build du front Angular" + Push-Location $WebDir + try { + if (-not (Test-Path 'node_modules')) { npm ci } + npm run build + if ($LASTEXITCODE -ne 0) { throw "Echec du build Angular" } + } finally { Pop-Location } + Write-Ok "Front construit (web/dist/web)" +} else { Write-Step "Front : saute (--SkipFront)" } + +# --- 2. Brain (Python embeddable officiel, PAS d'exe gele) ----------------- +# On embarque le Python *embeddable* de python.org (python.exe SIGNE par la PSF) +# + les dependances + les sources .py. Aucun executable "gele" type PyInstaller +# -> plus de faux positif antivirus (le bootloader packe etait pris pour un +# trojan). Le Core lancera : brain\python\python.exe brain\run_local.py +$PyVersion = '3.12.8' # doit matcher python:3.12 du Docker +$PyTag = 'python312' # prefixe du fichier ._pth +$BrainEmbed = Join-Path $BrainDir 'dist-embed' # staging du brain empaquete +if (-not $SkipBrain) { + Write-Step "Preparation du Brain (Python embeddable $PyVersion)" + if (Test-Path $BrainEmbed) { Remove-Item $BrainEmbed -Recurse -Force } + $PyDir = Join-Path $BrainEmbed 'python' + New-Item -ItemType Directory -Force -Path $PyDir | Out-Null + + # a) Telecharger + extraire le Python embeddable (zip officiel). + $zip = Join-Path $BrainEmbed 'python-embed.zip' + $url = "https://www.python.org/ftp/python/$PyVersion/python-$PyVersion-embed-amd64.zip" + Write-Host " Telechargement $url" + Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing + Expand-Archive -Path $zip -DestinationPath $PyDir -Force + Remove-Item $zip -Force + + # b) Activer site-packages dans le ._pth (sinon les deps ne sont pas importees). + $pth = Join-Path $PyDir "$PyTag._pth" + Set-Content -Path $pth -Encoding ascii -Value @( + "$PyTag.zip", '.', 'Lib\site-packages', 'import site' + ) + + # c) Installer les deps en wheels cp312 dans python\Lib\site-packages. + # --python-version 3.12 + --only-binary : on telecharge les roues 3.12 + # meme si le pip courant tourne sous une autre version de Python. + $site = Join-Path $PyDir 'Lib\site-packages' + New-Item -ItemType Directory -Force -Path $site | Out-Null + Push-Location $BrainDir + try { + python -m pip install --upgrade pip *> $null + python -m pip install --target $site --only-binary=:all: --python-version 3.12 -r requirements.txt + if ($LASTEXITCODE -ne 0) { throw "Echec pip install (deps Brain)" } + } finally { Pop-Location } + + # d) Copier les sources du Brain + le point d'entree. + Copy-Item (Join-Path $BrainDir 'app') (Join-Path $BrainEmbed 'app') -Recurse + Copy-Item (Join-Path $BrainDir 'run_local.py') (Join-Path $BrainEmbed 'run_local.py') + + Write-Ok "Brain prepare (brain/dist-embed : python embeddable + deps + sources)" +} else { Write-Step "Brain : saute (--SkipBrain)" } + +# --- 3. Core (fat jar avec front embarque) --------------------------------- +if (-not $SkipJar) { + Write-Step "Build du Core (fat jar, profil desktop)" + Push-Location $CoreDir + try { + # -Pdesktop : copie web/dist/web dans le jar (classpath:/static). + cmd /c "mvn -q -Pdesktop -DskipTests clean package" + if ($LASTEXITCODE -ne 0) { throw "Echec du build Maven" } + } finally { Pop-Location } + Write-Ok "Core construit (core/target)" +} else { Write-Step "Core : saute (--SkipJar)" } + +# --- 4. Assemblage de la charge utile -------------------------------------- +Write-Step "Assemblage de la charge utile jpackage" +if (Test-Path $StageDir) { Remove-Item $StageDir -Recurse -Force } +New-Item -ItemType Directory -Force -Path $StageDir | Out-Null + +# Le jar repackage Spring Boot (executable) — on ignore le *.jar.original. +$jar = Get-ChildItem (Join-Path $CoreDir 'target') -Filter 'loremind-core-*.jar' | + Where-Object { $_.Name -notlike '*.original' } | Select-Object -First 1 +if (-not $jar) { Write-Err "Jar introuvable dans core/target. Lancez sans --SkipJar."; exit 1 } +Copy-Item $jar.FullName (Join-Path $StageDir 'loremind-core.jar') + +# Le Brain (python embeddable + deps + sources) -> stage/brain/ +# stage/brain/python/python.exe , stage/brain/app , stage/brain/run_local.py +if (-not (Test-Path $BrainEmbed)) { Write-Err "Brain introuvable (brain/dist-embed). Lancez sans --SkipBrain."; exit 1 } +$stageBrain = Join-Path $StageDir 'brain' +New-Item -ItemType Directory -Force -Path $stageBrain | Out-Null +Copy-Item (Join-Path $BrainEmbed '*') $stageBrain -Recurse +Write-Ok "Charge utile prete ($StageDir)" + +# --- 5. jpackage -> .msi --------------------------------------------------- +Write-Step "Generation de l'installeur (.msi) via jpackage" +if (Test-Path $OutDir) { Remove-Item $OutDir -Recurse -Force } +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +# $APPDIR est substitue par jpackage au LANCEMENT par le dossier 'app' de +# l'install (qui contient le jar ET le dossier brain copie depuis --input). +# Le Brain se lance via le python embeddable + run_local.py. brain.sidecar.command +# est une liste : les deux chemins separes par une virgule (aucun chemin Windows +# ne contient de virgule) sont bindes en List par Spring. +$brainCmd = '$APPDIR\brain\python\python.exe,$APPDIR\brain\run_local.py' + +# Note : pas de --win-console (app de bureau). Les logs Spring/Brain peuvent +# etre rediriges vers un fichier via une option ulterieure si besoin de debug. +jpackage ` + --type msi ` + --name LoreMind ` + --app-version $Version ` + --vendor 'IGML Creation' ` + --input $StageDir ` + --main-jar loremind-core.jar ` + --main-class org.springframework.boot.loader.launch.JarLauncher ` + --dest $OutDir ` + --java-options '-Dspring.profiles.active=local' ` + --java-options "-Dbrain.sidecar.command=$brainCmd" ` + --win-per-user-install ` + --win-menu --win-menu-group 'LoreMind' ` + --win-shortcut --win-dir-chooser + +if ($LASTEXITCODE -ne 0) { + Write-Err "Echec jpackage. Cause probable : WiX Toolset v3 absent." + Write-Err "Installez-le : https://github.com/wixtoolset/wix3/releases" + exit 1 +} + +$msi = Get-ChildItem $OutDir -Filter '*.msi' | Select-Object -First 1 +Write-Host "" +Write-Host "============================================================" -ForegroundColor Green +Write-Host " Installeur genere !" -ForegroundColor Green +Write-Host " $($msi.FullName)" -ForegroundColor Green +Write-Host "============================================================" -ForegroundColor Green diff --git a/web/package-lock.json b/web/package-lock.json index a88f961..faa5f14 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.14.0-beta", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.14.0-beta", + "version": "0.15.0", "dependencies": { "@angular/animations": "^21.2.16", "@angular/common": "^21.2.16", diff --git a/web/package.json b/web/package.json index c984c28..f660d95 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.14.0-beta", + "version": "0.15.0", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/interceptors/language.interceptor.ts b/web/src/app/interceptors/language.interceptor.ts index b73560a..ee9646b 100644 --- a/web/src/app/interceptors/language.interceptor.ts +++ b/web/src/app/interceptors/language.interceptor.ts @@ -1,6 +1,5 @@ import { HttpInterceptorFn } from '@angular/common/http'; -import { inject } from '@angular/core'; -import { LanguageService } from '../services/language.service'; +import { STORAGE_KEY } from '../services/language.service'; /** * Ajoute l'entête `X-User-Language` (langue choisie dans l'UI : `fr`/`en`) à @@ -9,10 +8,27 @@ import { LanguageService } from '../services/language.service'; * * NB : les appels SSE en `fetch()` (chat, imports, notebooks) ne passent PAS par * les intercepteurs Angular — ils ajoutent l'entête manuellement de leur côté. + * + * IMPORTANT — on lit la langue directement depuis localStorage et NON via + * LanguageService/TranslateService. Injecter ces services ici créerait une + * dépendance circulaire fatale au démarrage : provideTranslateService charge la + * langue de repli (`fr.json`) PENDANT la construction de TranslateService ; cette + * requête passe par cet intercepteur ; si l'intercepteur injectait LanguageService + * (qui injecte TranslateService, en cours de construction) → cycle → la requête + * `fr.json` échoue → ngx-translate marque `fr` comme chargé-mais-vide → toutes les + * clés s'affichent brutes (l'anglais, n'étant pas la langue de repli, se chargeait + * après la construction et fonctionnait). localStorage est de toute façon la + * source de vérité (persistée par LanguageService.use()). */ export const languageInterceptor: HttpInterceptorFn = (req, next) => { - const language = inject(LanguageService); - return next( - req.clone({ setHeaders: { 'X-User-Language': language.current } }) - ); + let language = 'fr'; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + language = stored; + } + } catch { + // localStorage indisponible (mode privé strict) : on garde le défaut. + } + return next(req.clone({ setHeaders: { 'X-User-Language': language } })); }; diff --git a/web/src/app/services/language.service.ts b/web/src/app/services/language.service.ts index 6b73b46..8d73612 100644 --- a/web/src/app/services/language.service.ts +++ b/web/src/app/services/language.service.ts @@ -22,7 +22,13 @@ export interface AppLanguage { flag: string; } -const STORAGE_KEY = 'loremind.lang'; +/** + * Clé localStorage du choix de langue (par appareil). Exportée pour que le + * languageInterceptor lise la langue SANS injecter LanguageService/TranslateService + * (sinon dépendance circulaire au démarrage : le chargement initial de la langue + * de repli passe par l'intercepteur pendant la construction de TranslateService). + */ +export const STORAGE_KEY = 'loremind.lang'; @Injectable({ providedIn: 'root' }) export class LanguageService {