Merge feature/ticket74-web-transport-bundle into develop
Intègre les tickets #68 (packaging des assets web en ressource Tauri) et #74 (le serveur embarqué servait le bundle desktop au navigateur). Le frontend produit désormais deux artefacts Vite distincts : dist (transport tauri, build.frontendDist) et dist-web (transport http, packagé en ressource « web/ »). Le garde-fou de build échoue si un bundle résout le mauvais transport. Les deux tickets ferment ensemble : le packaging #68 servait le bundle cassé, il ne pouvait pas être déclaré vert sans le fix #74. Validation live utilisateur verte derrière reverse proxy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@ -64,3 +64,4 @@
|
||||
- [idea-distribution-strategy-desktop-vs-docker](idea-distribution-strategy-desktop-vs-docker.md) — memory note idea-distribution-strategy-desktop-vs-docker
|
||||
- [web-client-is-single-column-no-desktop-shell](web-client-is-single-column-no-desktop-shell.md) — memory note web-client-is-single-column-no-desktop-shell
|
||||
- [sandbox-eperm-bind-false-green-web-server](sandbox-eperm-bind-false-green-web-server.md) — memory note sandbox-eperm-bind-false-green-web-server
|
||||
- [ticket74-f1-web-bundle-transport-seam](ticket74-f1-web-bundle-transport-seam.md) — Deux artefacts Vite (dist/dist-web), mode `web` portable Windows, et le câblage anti-divergence de la constante de transport.
|
||||
|
||||
18
.ideai/memory/ticket74-f1-web-bundle-transport-seam.md
Normal file
18
.ideai/memory/ticket74-f1-web-bundle-transport-seam.md
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
name: ticket74-f1-web-bundle-transport-seam
|
||||
description: Deux artefacts Vite (dist/dist-web), mode `web` portable Windows, et le câblage anti-divergence de la constante de transport.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
Le frontend produit **deux** artefacts Vite depuis les mêmes sources :
|
||||
- `frontend/dist` → transport `tauri` (desktop, `build.frontendDist`) — `npm run build`.
|
||||
- `frontend/dist-web` → transport `http` (packagé en ressource Tauri `web/`) — `npm run build:web`.
|
||||
- `npm run build:bundle` produit les deux : c'est le point d'entrée du packaging Tauri.
|
||||
|
||||
**Le mode web passe par `--mode web` + `frontend/.env.web` (`VITE_TRANSPORT=http`), jamais par un préfixe inline `VITE_TRANSPORT=http vite build`** : non portable Windows/NSIS, cible active du bundle. `.env.web` est versionné (non gitignoré) ; `dist-web/` est ignoré.
|
||||
|
||||
**Anti-divergence de `__IDEA_TRANSPORT__`** : `frontend/src/app/transport.ts` expose `transportFromEnv(value)`, importé à la fois par `di.tsx` (`resolveTransport`/`shouldUseHttp`) et par `vite.config.ts`, qui lui passe `VITE_TRANSPORT` via `loadEnv(mode)`. Même variable + même prédicat = une seule source. Modifier le prédicat déplace constante et runtime ensemble. `vitest.config.ts` réplique le `define` via le même helper.
|
||||
|
||||
La constante est **publiée sur `window` dans `main.tsx`** : `define` ne remplace que les occurrences existantes et une constante non référencée serait tree-shakée ; l'affectation est un effet de bord qui survit à la minification. Elle reporte le transport hors mock (`VITE_USE_MOCK` reste un switch distinct).
|
||||
|
||||
Pour identifier le transport d'un bundle bâti : `grep -o '__IDEA_TRANSPORT__="[a-z]*"' dist*/assets/*.js`. **Ne pas se fier à la présence de `__TAURI_INTERNALS__` ni à un nom de symbole minifié** : les deux jeux d'adapters sont présents dans les deux bundles, un grep ne les discrimine pas.
|
||||
6
.ideai/tickets/74/carnet.md
Normal file
6
.ideai/tickets/74/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#74"
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784272247901
|
||||
---
|
||||
67
.ideai/tickets/74/issue.md
Normal file
67
.ideai/tickets/74/issue.md
Normal file
@ -0,0 +1,67 @@
|
||||
---
|
||||
id: "249bb0de-118f-4d6f-b0c4-1d4e2aadd83f"
|
||||
number: 74
|
||||
title: "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined"
|
||||
status: "qa"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: [{"target":"#68","kind":"relatesTo"},{"target":"#66","kind":"relatesTo"},{"target":"#13","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784271086298
|
||||
updatedAt: 1784272247901
|
||||
version: 2
|
||||
---
|
||||
## Symptôme (constaté live, exposition derrière reverse proxy)
|
||||
|
||||
UI chargée dans le navigateur via `https://idea.anthonybouteiller.ovh` (nginx → 192.168.1.75:17373, serveur embarqué desktop, mode `remoteProxyOtherMachine`) :
|
||||
|
||||
- `Error: can't access property "invoke", window.__TAURI_INTERNALS__ is undefined` (x2)
|
||||
- Aucun projet affiché.
|
||||
|
||||
## Cause racine (confirmée)
|
||||
|
||||
`crates/app-tauri/tauri.conf.json` utilise **un seul** `frontend/dist` pour deux besoins incompatibles :
|
||||
|
||||
- `build.frontendDist: "../../frontend/dist"` + `beforeBuildCommand: "npm --prefix ../frontend run build"` → `vite build` **sans** `VITE_TRANSPORT` → bundle **desktop** (transport `tauri`).
|
||||
- `bundle.resources: { "../../frontend/dist": "web" }` → ce **même** bundle desktop est packagé en ressource `web/`.
|
||||
|
||||
Or `resolve_web_root()` (`crates/app-tauri/src/embedded_server.rs:498-517`) sert `resource_dir.join("web")`. Le serveur embarqué sert donc le bundle desktop au navigateur.
|
||||
|
||||
Le transport est figé **au build** par Vite : `resolveTransport()` (`frontend/src/app/di.tsx:39-46`) lit `import.meta.env.VITE_TRANSPORT`, constant-folded à la compilation.
|
||||
|
||||
**Preuve** dans `frontend/dist/assets/index-nl-2Eu6U.js` (build du 2026-07-17 08:00) :
|
||||
|
||||
```js
|
||||
function Cb(){return"tauri"} // resolveTransport() figé sur tauri
|
||||
```
|
||||
|
||||
Les deux jeux d'adapters sont bien présents dans le bundle, mais le sélecteur ne pointera jamais sur `createHttpWsGateways()`.
|
||||
|
||||
## Attendu
|
||||
|
||||
L'AppImage doit packager en ressource `web/` un bundle construit avec `VITE_TRANSPORT=http`, distinct du `frontendDist` desktop. Un seul `dist` ne peut pas servir les deux transports.
|
||||
|
||||
## Contexte
|
||||
|
||||
- `docs/server-client-mode-remote.md` documente déjà que le build web **doit** être produit en `VITE_TRANSPORT=http`, sinon exactement ce symptôme.
|
||||
- Le carnet de #13 avait classé ce symptôme en « erreur de commande de test, pas un bug de code » — vrai à l'époque de `idea --serve --web-root`, mais le packaging AppImage de #66/#68 réintroduit le problème par défaut.
|
||||
- #66 L4 anticipait le besoin (« produire les assets client Vite en `VITE_TRANSPORT=http` … packager dans `/usr/share/idea/web` ») ; la conf actuelle ne le fait pas.
|
||||
- npm, jamais pnpm (mémoire `frontend-uses-npm-not-pnpm`).
|
||||
|
||||
## Workaround live
|
||||
|
||||
Build séparé + `IDEA_WEB_ROOT` (premier candidat de `resolve_web_root`, court-circuite la ressource packagée) :
|
||||
|
||||
```bash
|
||||
cd frontend && VITE_TRANSPORT=http npx vite build --outDir dist-web --emptyOutDir
|
||||
IDEA_WEB_ROOT=…/frontend/dist-web ./IdeA.AppImage
|
||||
```
|
||||
|
||||
## DoD
|
||||
|
||||
- Build AppImage produit une ressource `web/` en transport http, `frontendDist` desktop inchangé.
|
||||
- Vérif automatisable : le bundle servi ne doit pas résoudre le transport sur `tauri`.
|
||||
- Validation live navigateur derrière reverse proxy : UI + projets listés, aucune erreur `__TAURI_INTERNALS__`.
|
||||
- Rebuild AppImage livrable pour test utilisateur.
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 74
|
||||
"nextNumber": 75
|
||||
}
|
||||
@ -768,6 +768,16 @@
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784223044517
|
||||
},
|
||||
{
|
||||
"issueRef": "#74",
|
||||
"path": "74",
|
||||
"title": "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined",
|
||||
"status": "qa",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784272247901
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -162,6 +162,7 @@ impl FsServerExposureSettingsStore {
|
||||
/// Desktop-owned embedded server manager.
|
||||
pub struct EmbeddedServerController {
|
||||
app_data_dir: PathBuf,
|
||||
resource_dir: Option<PathBuf>,
|
||||
store: FsServerExposureSettingsStore,
|
||||
inner: Mutex<EmbeddedServerInner>,
|
||||
}
|
||||
@ -170,9 +171,16 @@ impl EmbeddedServerController {
|
||||
/// Creates the controller.
|
||||
#[must_use]
|
||||
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||
Self::with_resource_dir(app_data_dir, None)
|
||||
}
|
||||
|
||||
/// Creates the controller with the Tauri bundle resource directory.
|
||||
#[must_use]
|
||||
pub fn with_resource_dir(app_data_dir: PathBuf, resource_dir: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
store: FsServerExposureSettingsStore::new(app_data_dir.clone()),
|
||||
app_data_dir,
|
||||
resource_dir,
|
||||
inner: Mutex::new(EmbeddedServerInner::default()),
|
||||
}
|
||||
}
|
||||
@ -237,7 +245,7 @@ impl EmbeddedServerController {
|
||||
let config = match server_config_from_settings(
|
||||
&settings,
|
||||
self.app_data_dir.clone(),
|
||||
resolve_web_root()?,
|
||||
resolve_web_root(self.resource_dir.as_deref())?,
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
@ -487,11 +495,25 @@ fn parse_ip(value: &str, field: &str) -> Result<IpAddr, ErrorDto> {
|
||||
.map_err(|_| invalid_error(format!("{field} must be an IP address")))
|
||||
}
|
||||
|
||||
fn resolve_web_root() -> Result<PathBuf, ErrorDto> {
|
||||
fn resolve_web_root(resource_dir: Option<&Path>) -> Result<PathBuf, ErrorDto> {
|
||||
let explicit_web_root = std::env::var_os("IDEA_WEB_ROOT").map(PathBuf::from);
|
||||
let candidates = web_root_candidates(explicit_web_root, resource_dir);
|
||||
first_web_root(candidates).ok_or_else(|| {
|
||||
invalid_error("web assets not found: build frontend/dist or set IDEA_WEB_ROOT")
|
||||
})
|
||||
}
|
||||
|
||||
fn web_root_candidates(
|
||||
explicit_web_root: Option<PathBuf>,
|
||||
resource_dir: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = std::env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) {
|
||||
if let Some(path) = explicit_web_root {
|
||||
candidates.push(path);
|
||||
}
|
||||
if let Some(resource_dir) = resource_dir {
|
||||
candidates.push(resource_dir.join("web"));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(dir.join("web"));
|
||||
@ -501,12 +523,13 @@ fn resolve_web_root() -> Result<PathBuf, ErrorDto> {
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
candidates.push(cwd.join("frontend").join("dist"));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn first_web_root(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.join("index.html").is_file())
|
||||
.ok_or_else(|| {
|
||||
invalid_error("web assets not found: build frontend/dist or set IDEA_WEB_ROOT")
|
||||
})
|
||||
}
|
||||
|
||||
fn write_atomic_user_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
@ -588,6 +611,36 @@ mod tests {
|
||||
web_root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_root_candidates_keep_packaged_resource_before_exe_fallbacks() {
|
||||
let explicit = tmp_app_data().join("explicit-web-root");
|
||||
let resource_dir = tmp_app_data().join("resources");
|
||||
|
||||
let candidates = web_root_candidates(Some(explicit.clone()), Some(&resource_dir));
|
||||
|
||||
assert_eq!(candidates[0], explicit);
|
||||
assert_eq!(candidates[1], resource_dir.join("web"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_web_root_uses_first_candidate_with_index_html() {
|
||||
let missing = tmp_app_data().join("missing-web-root");
|
||||
let resource_dir = tmp_app_data().join("resources");
|
||||
let resource_web = resource_dir.join("web");
|
||||
std::fs::create_dir_all(&resource_web).unwrap();
|
||||
std::fs::write(
|
||||
resource_web.join("index.html"),
|
||||
"<!doctype html><title>Packaged</title>",
|
||||
)
|
||||
.unwrap();
|
||||
let later = tmp_web_root();
|
||||
|
||||
let resolved = first_web_root([missing, resource_web.clone(), later])
|
||||
.expect("web root should resolve");
|
||||
|
||||
assert_eq!(resolved, resource_web);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_loopback_remote_requires_trusted_proxy() {
|
||||
let settings = ServerExposureSettingsDto {
|
||||
|
||||
@ -96,13 +96,14 @@ pub fn run() {
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("failed to resolve the app data directory");
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
// Point the orchestrator's best-effort diagnostics at a persistent file
|
||||
// (`<app-data>/logs/idea.log`) so inter-agent rendezvous beacons survive a
|
||||
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
|
||||
// if the file can't be opened the beacons simply stay on stderr.
|
||||
application::diag::set_log_path(app_data_dir.join("logs").join("idea.log"));
|
||||
application::diag!("[startup] IdeA launched; diagnostics log armed");
|
||||
let app_state = AppState::build(app_data_dir);
|
||||
let app_state = AppState::build_with_resource_dir(app_data_dir, resource_dir);
|
||||
|
||||
// Wire the domain event bus → Tauri events relay.
|
||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||
|
||||
@ -51,6 +51,13 @@ impl AppState {
|
||||
/// into the backend core.
|
||||
#[must_use]
|
||||
pub fn build(app_data_dir: PathBuf) -> Self {
|
||||
Self::build_with_resource_dir(app_data_dir, None)
|
||||
}
|
||||
|
||||
/// Builds the shared backend core with the Tauri bundle resource directory
|
||||
/// available to desktop-only adapters.
|
||||
#[must_use]
|
||||
pub fn build_with_resource_dir(app_data_dir: PathBuf, resource_dir: Option<PathBuf>) -> Self {
|
||||
let core = Arc::new(BackendCore::build(app_data_dir.clone()));
|
||||
core.ticket_tool_binder
|
||||
.bind(Arc::new(AppTicketToolProvider {
|
||||
@ -69,7 +76,10 @@ impl AppState {
|
||||
core,
|
||||
pty_bridge: Arc::new(PtyBridge::new()),
|
||||
chat_bridge: Arc::new(ChatBridge::new()),
|
||||
embedded_server: Arc::new(EmbeddedServerController::new(app_data_dir)),
|
||||
embedded_server: Arc::new(EmbeddedServerController::with_resource_dir(
|
||||
app_data_dir,
|
||||
resource_dir,
|
||||
)),
|
||||
focused_project: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"build": {
|
||||
"frontendDist": "../../frontend/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm --prefix ../frontend run dev"
|
||||
"beforeDevCommand": "npm --prefix ../frontend run dev",
|
||||
"beforeBuildCommand": "npm --prefix ../frontend run build:bundle"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
@ -24,6 +25,9 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["appimage", "nsis"],
|
||||
"icon": ["icons/icon.png"]
|
||||
"icon": ["icons/icon.png"],
|
||||
"resources": {
|
||||
"../../frontend/dist-web": "web"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/.env.web
Normal file
7
frontend/.env.web
Normal file
@ -0,0 +1,7 @@
|
||||
# Vite mode `web` (`vite build --mode web`, ticket #74 lot F1): the bundle the
|
||||
# embedded server serves to a browser talks HTTP+WS instead of Tauri IPC.
|
||||
#
|
||||
# This lives in a mode env file rather than an inline `VITE_TRANSPORT=http vite
|
||||
# build` prefix because the npm scripts must also run on Windows (NSIS bundle),
|
||||
# where that shell syntax does not exist.
|
||||
VITE_TRANSPORT=http
|
||||
@ -6,6 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build:web": "tsc --noEmit && vite build --mode web --outDir dist-web --emptyOutDir",
|
||||
"build:bundle": "npm run typecheck && vite build --outDir dist --emptyOutDir && vite build --mode web --outDir dist-web --emptyOutDir",
|
||||
"test:bundle-transport": "node scripts/assert-bundle-transport.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
|
||||
78
frontend/scripts/assert-bundle-transport.mjs
Normal file
78
frontend/scripts/assert-bundle-transport.mjs
Normal file
@ -0,0 +1,78 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const EXPECTED_TRANSPORT_BY_DIR = new Map([
|
||||
["dist", "tauri"],
|
||||
["dist-web", "http"],
|
||||
]);
|
||||
|
||||
async function readJavaScriptAssets(buildDir) {
|
||||
const assetsDir = join(process.cwd(), buildDir, "assets");
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(assetsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot read ${assetsDir}. Run npm run build:bundle before this check. ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".js"))
|
||||
.map((entry) => join(assetsDir, entry.name));
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No JavaScript assets found in ${assetsDir}.`);
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
files.map(async (file) => ({
|
||||
file,
|
||||
source: await readFile(file, "utf8"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function transportMarkers(source) {
|
||||
return [...source.matchAll(/__IDEA_TRANSPORT__="([a-z]+)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
}
|
||||
|
||||
async function assertBundleTransport(buildDir, expectedTransport) {
|
||||
const assets = await readJavaScriptAssets(buildDir);
|
||||
const markers = assets.flatMap(({ file, source }) =>
|
||||
transportMarkers(source).map((transport) => ({ file, transport })),
|
||||
);
|
||||
|
||||
if (markers.length === 0) {
|
||||
throw new Error(
|
||||
`${buildDir} does not publish a __IDEA_TRANSPORT__ marker in its built JavaScript assets.`,
|
||||
);
|
||||
}
|
||||
|
||||
const unexpected = markers.filter(
|
||||
({ transport }) => transport !== expectedTransport,
|
||||
);
|
||||
if (unexpected.length > 0) {
|
||||
const details = unexpected
|
||||
.map(({ file, transport }) => `${file}: ${transport}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`${buildDir} resolves the wrong transport; expected ${expectedTransport}.\n${details}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${buildDir}: __IDEA_TRANSPORT__="${expectedTransport}" (${markers.length} marker${markers.length === 1 ? "" : "s"})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [buildDir, expectedTransport] of EXPECTED_TRANSPORT_BY_DIR) {
|
||||
await assertBundleTransport(buildDir, expectedTransport);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@ -16,6 +16,7 @@ import {
|
||||
shouldUseMock,
|
||||
shouldUseHttp,
|
||||
} from "./di";
|
||||
import { transportFromEnv } from "./transport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@ -66,6 +67,28 @@ describe("resolveTransport / web (HTTP+WS) selection (ticket #13, F1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("transportFromEnv (ticket #74, F1)", () => {
|
||||
// The build config derives `__IDEA_TRANSPORT__` from this same predicate, so
|
||||
// pinning it here pins both the runtime transport and the build constant.
|
||||
it("selects http only for the exact opt-in value", () => {
|
||||
expect(transportFromEnv("http")).toBe("http");
|
||||
});
|
||||
|
||||
it.each([undefined, "", "tauri", "HTTP", "http ", "web"])(
|
||||
"falls back to tauri for %o",
|
||||
(value) => {
|
||||
expect(transportFromEnv(value)).toBe("tauri");
|
||||
},
|
||||
);
|
||||
|
||||
it("agrees with shouldUseHttp for the value the build reads", () => {
|
||||
vi.stubEnv("VITE_TRANSPORT", "http");
|
||||
expect(shouldUseHttp()).toBe(transportFromEnv("http") === "http");
|
||||
vi.stubEnv("VITE_TRANSPORT", "");
|
||||
expect(shouldUseHttp()).toBe(transportFromEnv("") === "http");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DIProvider / useGateways", () => {
|
||||
it("provides explicit gateways to consumers", () => {
|
||||
const gateways = resolveGatewaysMock();
|
||||
|
||||
@ -18,11 +18,11 @@ import type { Gateways } from "@/ports";
|
||||
import { createTauriGateways } from "@/adapters";
|
||||
import { createMockGateways } from "@/adapters/mock";
|
||||
import { createHttpWsGateways } from "@/adapters/http";
|
||||
import { transportFromEnv, type Transport } from "./transport";
|
||||
|
||||
const GatewaysContext = createContext<Gateways | null>(null);
|
||||
|
||||
/** The selected transport backing the gateways. */
|
||||
export type Transport = "mock" | "http" | "tauri";
|
||||
export type { Transport };
|
||||
|
||||
/** Whether the mock adapters should be used (env-driven, overridable in tests). */
|
||||
export function shouldUseMock(): boolean {
|
||||
@ -36,14 +36,13 @@ export function shouldUseMock(): boolean {
|
||||
* and is never affected (ticket #13, lot F1).
|
||||
*/
|
||||
export function shouldUseHttp(): boolean {
|
||||
return import.meta.env.VITE_TRANSPORT === "http";
|
||||
return transportFromEnv(import.meta.env.VITE_TRANSPORT) === "http";
|
||||
}
|
||||
|
||||
/** Resolves which transport to use. Mock wins, then explicit web, else Tauri. */
|
||||
export function resolveTransport(): Transport {
|
||||
if (shouldUseMock()) return "mock";
|
||||
if (shouldUseHttp()) return "http";
|
||||
return "tauri";
|
||||
return transportFromEnv(import.meta.env.VITE_TRANSPORT);
|
||||
}
|
||||
|
||||
/** Resolves the gateway set for the current environment. */
|
||||
|
||||
@ -19,6 +19,14 @@ if (!root) {
|
||||
// follows the main window's focused project; otherwise the full app.
|
||||
const viewParams = parseViewWindowParams(window.location.search);
|
||||
|
||||
// Ticket #74, F1: publish the transport this bundle was built for. `define`
|
||||
// inlines it as a literal, and assigning it is a side effect, so it survives
|
||||
// minification and tree-shaking — a built bundle can be identified (by QA, or
|
||||
// from devtools) without grepping for a minified symbol. Both adapter sets ship
|
||||
// in either bundle, so their mere presence proves nothing.
|
||||
(window as unknown as { __IDEA_TRANSPORT__?: string }).__IDEA_TRANSPORT__ =
|
||||
__IDEA_TRANSPORT__;
|
||||
|
||||
// Ticket #13, F2: in web (HTTP) transport mode the browser client renders the
|
||||
// pairing-gated, read-only `WebApp`. Desktop (Tauri) is unchanged — it renders
|
||||
// the full `App` (or a detached view window). Detached windows are desktop-only.
|
||||
|
||||
19
frontend/src/app/transport.ts
Normal file
19
frontend/src/app/transport.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The transport signal, shared by the app and the Vite build config.
|
||||
*
|
||||
* `vite.config.ts` imports {@link transportFromEnv} to compute the
|
||||
* `__IDEA_TRANSPORT__` build constant from the very same `VITE_TRANSPORT`
|
||||
* variable that {@link shouldUseHttp} reads (ticket #74, F1). One predicate,
|
||||
* one variable: the greppable constant and the resolved transport cannot drift.
|
||||
*/
|
||||
|
||||
/** The selected transport backing the gateways. */
|
||||
export type Transport = "mock" | "http" | "tauri";
|
||||
|
||||
/**
|
||||
* The transport a raw `VITE_TRANSPORT` value selects, mock aside. Web is opt-in
|
||||
* (`"http"`) so the desktop path stays the default and is never affected.
|
||||
*/
|
||||
export function transportFromEnv(value: string | undefined): "http" | "tauri" {
|
||||
return value === "http" ? "http" : "tauri";
|
||||
}
|
||||
12
frontend/src/vite-env.d.ts
vendored
12
frontend/src/vite-env.d.ts
vendored
@ -9,3 +9,15 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport this bundle was built for, inlined as a literal by Vite
|
||||
* `define` (ticket #74, F1). It is derived in `vite.config.ts` from the same
|
||||
* `VITE_TRANSPORT` variable and the same `transportFromEnv()` predicate that
|
||||
* `resolveTransport()` uses, so it always agrees with the adapters actually
|
||||
* wired in — mock aside, which is a separate `VITE_USE_MOCK` switch.
|
||||
*
|
||||
* `main.tsx` publishes it on `window`, which makes it survive minification and
|
||||
* lets a built bundle be identified without grepping for a minified symbol.
|
||||
*/
|
||||
declare const __IDEA_TRANSPORT__: "http" | "tauri";
|
||||
|
||||
@ -1,16 +1,33 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
import { transportFromEnv } from "./src/app/transport";
|
||||
|
||||
const root = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
// Vite config tuned for Tauri v2 (fixed dev port, no clearing the terminal).
|
||||
export default defineConfig({
|
||||
//
|
||||
// Two build artefacts (ticket #74): the default mode emits the desktop bundle
|
||||
// (`dist`, Tauri IPC transport), and `--mode web` reads `.env.web` to emit the
|
||||
// browser bundle served by the embedded server (`dist-web`, HTTP+WS transport).
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Same env file resolution Vite applies to `import.meta.env`, so the
|
||||
// `__IDEA_TRANSPORT__` constant below is computed from the exact variable
|
||||
// `resolveTransport()` reads, through the exact same predicate (#74, F1).
|
||||
const env = loadEnv(mode, root);
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__IDEA_TRANSPORT__: JSON.stringify(transportFromEnv(env.VITE_TRANSPORT)),
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5173,
|
||||
@ -20,4 +37,5 @@ export default defineConfig({
|
||||
target: "es2021",
|
||||
outDir: "dist",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@ -3,6 +3,8 @@ import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
import { transportFromEnv } from "./src/app/transport";
|
||||
|
||||
// Vitest config for the frontend hexagonal layers (domain/ports/adapters/app).
|
||||
// jsdom is used so React-Testing-Library can render the DI provider.
|
||||
export default defineConfig({
|
||||
@ -12,6 +14,13 @@ export default defineConfig({
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
// Mirrors the build constant (#74, F1) through the same predicate, so code
|
||||
// reading `__IDEA_TRANSPORT__` stays testable.
|
||||
define: {
|
||||
__IDEA_TRANSPORT__: JSON.stringify(
|
||||
transportFromEnv(process.env.VITE_TRANSPORT),
|
||||
),
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
|
||||
Reference in New Issue
Block a user