fluxa-core/docs/effect-loop.md
KhooLy 894ca2c81f feat: companion server, FFI cleanup, and pre-release hardening
- Add fluxa-streaming-engine companion server (torrent start/stop,
  ffmpeg transcode/probe, OAuth token exchange) for the web build
- Add SSRF guard restricting transcode/probe url param to http(s)
  loopback hosts, blocking file:// and remote-host abuse via ffmpeg
- Add src/ffi.rs string-routed RPC dispatcher and wasm bindings
- Add headless engine profile module and several FFI surface
  additions (scrobble plans, episode navigation, collections import/export)
- Remove dead runtime::msg/update scaffolding and addon_transport trait
- Remove fluxa_play.rs dev scratch binary (hardcoded scraper, not
  part of the product)
- Reorder dv_rewrite.rs so implementation precedes its test module;
  strip decorative section dividers
- Add docs/, fuzz/ targets, and a rewritten README
- Fix .gitignore to cover fluxa-streaming-engine/target
2026-06-17 20:01:42 +03:00

143 lines
4.6 KiB
Markdown

# The effect loop
The effect loop is how the core communicates with the host platform. The core never performs I/O — it returns a list of effects describing what it needs done, and the host executes them and reports results back.
## Basic cycle
Every interaction follows this pattern:
```
1. Host calls dispatch(handle, actionJson)
2. Core updates state, produces a list of effects
3. Core returns { state, effects }
4. Host executes each effect
5. Host calls completeEffect(handle, { effectId, result })
6. Core may produce more effects in response — goto 4
```
Effects from a single dispatch can chain: completing effect A may produce effects B and C, completing B may produce effect D, and so on until the operation is fully resolved.
## DispatchResult shape
Every `dispatch` and `completeEffect` call returns the same JSON envelope:
```json
{
"state": { ... },
"effects": [
{
"id": "fx-1",
"type": "fetchAddonResource",
"generation": 3,
"payload": { ... }
}
]
}
```
- `state` is the full serialized `EngineState`. Platforms snapshot this and use it to render UI.
- `effects` is the list of work the platform must execute. An empty array means no I/O is needed.
- `id` is an opaque monotonically-increasing string (`"fx-N"`). The platform echoes it back in `completeEffect`.
- `generation` lets the platform discard completions that arrived after a newer dispatch superseded them.
## completeEffect shape
```json
{
"effectId": "fx-1",
"result": { ... }
}
```
The `result` structure is effect-specific. See [effects.md](effects.md) for each type's expected payload.
## Stale effects
Effects that the platform never completes (dropped exceptions, IPC failures) are expired after 5 minutes. The core will stop waiting for them and they will not produce further state changes.
## Actions
Actions are dispatched as tagged JSON objects. The `type` field is camelCase and selects which handler runs. Example:
```json
{
"type": "homeLoadRequested",
"profile": { ... },
"language": "en",
"force": false
}
```
All action types are defined in `src/headless_engine/contracts.rs` (`AppAction` enum). The serde tag is the wire name.
### Common actions
| type | When to dispatch |
|---|---|
| `navigationRequested` | User navigates to a new screen |
| `homeLoadRequested` | Home screen loads or refreshes |
| `detailLoadRequested` | Detail screen opens for a content item |
| `detailStreamsRequested` | Stream list needs to be fetched from addons |
| `playerLoadStreamsRequested` | Player opens and needs stream resolution |
| `playerResolvePlaybackRequested` | A specific URL/stream needs playback resolution |
| `savePlaybackProgressRequested` | User position should be persisted |
| `toggleWatchlistRequested` | User adds/removes an item from watchlist |
| `profileActivated` | Active profile changed |
| `introSegmentsRequested` | Intro skip segments needed for an episode |
| `scrobbleRequested` | Trakt/Simkl scrobble should be sent |
| `homeSearchRequested` | User typed a search query |
| `discoverLoadRequested` | Discover/browse screen loads |
| `libraryHydrateRequested` | Library state should be refreshed |
## Engine lifecycle
The headless engine is accessed through an integer handle. Multiple engine instances can coexist (though typically one exists per app session).
### Via `FluxaCore` (desktop/Tauri)
```rust
let handle = FluxaCore::create_headless_engine(initial_json);
let result = FluxaCore::headless_engine_dispatch_json(handle, action_json);
let result = FluxaCore::headless_engine_complete_effect_json(handle, effect_result_json);
let snapshot = FluxaCore::headless_engine_snapshot_json(handle);
```
### Via `core_invoke` (desktop/Swift)
```json
// Create
core_invoke("engine.create", "<initial_json>")
{ "ok": true, "value": 1 }
// Dispatch
core_invoke("engine.dispatch", { "handle": 1, "action": { "type": "homeLoadRequested" } })
{ "ok": true, "value": { "state": {...}, "effects": [...] } }
// Complete an effect
core_invoke("engine.completeEffect", { "handle": 1, "result": { "effectId": "fx-1", "result": {...} } })
{ "ok": true, "value": { "state": {...}, "effects": [...] } }
// Destroy
core_invoke("engine.destroy", 1)
```
### Via JNI (Android)
Android calls the corresponding `Java_com_fluxa_app_core_rust_FluxaCoreNative_*` native methods from `FluxaCoreNative.kt`. The JNI functions wrap the same underlying `headless_engine::*` functions.
## Error envelope
`core_invoke` always returns a string. On failure:
```json
{
"ok": false,
"error": {
"kind": "unknown_method",
"message": "no such method `foo`",
"method": "foo"
}
}
```
Error kinds: `unknown_method`, `invalid_args`, `not_found`, `internal`.