lingxia-lxapp 0.18.0

LxApp (lightweight application) container and runtime for LingXia framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
use crate::{error, info, warn};
use rong::{
    JSContext, JSContextService, JSFunc, JSObject, JSResult, RongJSError, error::HostError,
};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::{Rc, Weak};

pub const BROWSER_TAB_CLOSED_EVENT: &str = "__lingxiaBrowserTabClosed";
/// App-scoped event carrying this lxapp's resolved light/dark scheme.
pub const APPEARANCE_CHANGE_EVENT: &str = "AppearanceChange";
/// App-scoped event carrying the product's light/dark preference.
pub const HOST_APPEARANCE_CHANGE_EVENT: &str = "HostAppearanceChange";
/// App-scoped event carrying the host's effective display language.
pub const DISPLAY_LANGUAGE_CHANGE_EVENT: &str = "DisplayLanguageChange";
/// App-scoped event carrying the complete host display-language state.
pub const DISPLAY_LANGUAGE_STATE_CHANGE_EVENT: &str = "DisplayLanguageStateChange";

/// Internal scope marker. The page scope carries the page INSTANCE id, so
/// one instance's teardown can never clear a same-path sibling's handlers.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Scope {
    App,
    PageInstance(String),
}

/// Envelope for a native -> JS event.
#[derive(Clone, Debug)]
pub(crate) struct AppBusEvent {
    pub scope: Scope,
    pub event_name: String,
    pub payload_json: Option<String>,
}

/// Handler registrations owned by one AppService context.
///
/// The map is `Rc` so an unsubscribe handle can reach it without cloning the
/// `JSContext`. A context captured inside a JS-held Rust closure is opaque to
/// the cycle collector — the pair keep each other alive. A weak handle also
/// makes a late `off()` after shutdown a no-op instead of touching a dead
/// context.
#[derive(Default)]
pub(crate) struct EventBusRegistry {
    handlers: Rc<RefCell<HashMap<Scope, Vec<HandlerEntry>>>>,
    next_token: Cell<u64>,
}

/// Unsubscribe handle that does not retain a `JSContext`.
#[derive(Clone)]
pub struct AppHandlerUnsub {
    handlers: Weak<RefCell<HashMap<Scope, Vec<HandlerEntry>>>>,
    event_name: String,
    token: HandlerToken,
}

impl AppHandlerUnsub {
    /// Remove this registration. Safe after context shutdown (no-op).
    pub fn unsubscribe(&self) -> usize {
        let Some(handlers) = self.handlers.upgrade() else {
            return 0;
        };
        unregister_app_token(&handlers, &self.event_name, self.token)
    }
}

/// Build an unsubscribe handle for `token` that does not capture `ctx`.
pub fn app_handler_unsub(
    ctx: &JSContext,
    event_name: impl Into<String>,
    token: HandlerToken,
) -> AppHandlerUnsub {
    AppHandlerUnsub {
        handlers: Rc::downgrade(&registry(ctx).handlers),
        event_name: event_name.into(),
        token,
    }
}

fn unregister_app_token(
    handlers: &RefCell<HashMap<Scope, Vec<HandlerEntry>>>,
    event_name: &str,
    token: HandlerToken,
) -> usize {
    if event_name.trim().is_empty() {
        return 0;
    }
    let mut remaining = 0usize;
    handlers.borrow_mut().retain(|scope, entries| {
        if !matches!(scope, Scope::App) {
            return true;
        }
        entries.retain(|handler| handler.event_name != event_name || handler.token != token);
        remaining += entries
            .iter()
            .filter(|handler| handler.event_name == event_name)
            .count();
        !entries.is_empty()
    });
    remaining
}

/// Identifies one registration. An unsubscribe handle carries its token so it
/// removes exactly the entry it created — registering the same function twice
/// yields two independent subscriptions, and a stale handle can never take out
/// a later one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HandlerToken(u64);

#[derive(Clone)]
struct HandlerEntry {
    token: HandlerToken,
    event_name: String,
    callback: JSFunc,
}

impl JSContextService for EventBusRegistry {}

fn registry(ctx: &JSContext) -> &EventBusRegistry {
    if ctx.get_service::<EventBusRegistry>().is_none() {
        ctx.set_service(EventBusRegistry::default());
    }
    ctx.get_service::<EventBusRegistry>()
        .expect("event bus registry was inserted above")
}

/// Initialize the context registry (idempotent).
pub(crate) fn init(ctx: &JSContext) {
    registry(ctx);
}

/// Remove all handler registrations for a page instance (e.g., on unload).
pub(crate) fn clear_page(ctx: &JSContext, page_instance_id: &str) {
    let registry = registry(ctx);
    registry
        .handlers
        .borrow_mut()
        .retain(|scope, _| match scope {
            Scope::PageInstance(id) => id != page_instance_id,
            _ => true,
        });
}

/// Register an app-scoped handler.
pub fn register_app_handler(
    ctx: &JSContext,
    event_name: &str,
    callback: JSFunc,
) -> JSResult<HandlerToken> {
    if event_name.trim().is_empty() {
        return Err(RongJSError::from(HostError::new(
            rong::error::E_INTERNAL,
            "event_name is required",
        )));
    }

    let registry = registry(ctx);
    let token = HandlerToken(registry.next_token.get());
    registry.next_token.set(token.0 + 1);
    let entry = HandlerEntry {
        token,
        event_name: event_name.to_string(),
        callback,
    };

    registry
        .handlers
        .borrow_mut()
        .entry(Scope::App)
        .or_default()
        .push(entry);
    Ok(token)
}

/// Remove exactly the app-scoped registration `token` created. Returns the
/// remaining handler count for that event, so a caller can tear down its
/// native listener when the last subscription goes.
pub fn unregister_app_handler_token(
    ctx: &JSContext,
    event_name: &str,
    token: HandlerToken,
) -> usize {
    unregister_app_token(&registry(ctx).handlers, event_name, token)
}

/// Unregister an app-scoped handler by event name.
/// When `callback` is `None`, removes all handlers for that event.
/// Returns the remaining handler count for the event.
pub fn unregister_app_handler(
    ctx: &JSContext,
    event_name: &str,
    callback: Option<JSFunc>,
) -> usize {
    if event_name.trim().is_empty() {
        return 0;
    }
    let registry = registry(ctx);
    let mut remaining = 0usize;
    registry.handlers.borrow_mut().retain(|scope, entries| {
        if !matches!(scope, Scope::App) {
            return true;
        }
        if let Some(ref cb) = callback {
            entries.retain(|h| h.event_name != event_name || h.callback != *cb);
        } else {
            entries.retain(|h| h.event_name != event_name);
        }
        remaining += entries
            .iter()
            .filter(|h| h.event_name == event_name)
            .count();
        !entries.is_empty()
    });
    remaining
}

/// Register a page-scoped handler (page_path required).
pub fn register_page_handler(
    ctx: &JSContext,
    page_instance_id: &str,
    event_name: &str,
    callback: JSFunc,
) -> JSResult<()> {
    if event_name.trim().is_empty() {
        return Err(RongJSError::from(HostError::new(
            rong::error::E_INTERNAL,
            "event_name is required",
        )));
    }
    if page_instance_id.trim().is_empty() {
        return Err(RongJSError::from(HostError::new(
            rong::error::E_INTERNAL,
            "page_instance_id is required",
        )));
    }

    let registry = registry(ctx);
    let token = HandlerToken(registry.next_token.get());
    registry.next_token.set(token.0 + 1);
    let entry = HandlerEntry {
        token,
        event_name: event_name.to_string(),
        callback,
    };

    registry
        .handlers
        .borrow_mut()
        .entry(Scope::PageInstance(page_instance_id.to_string()))
        .or_default()
        .push(entry);
    Ok(())
}

/// Unregister page-scoped handlers for a given page + event (removes all matching).
pub fn unregister_page_handler(ctx: &JSContext, page_instance_id: &str, event_name: &str) {
    if page_instance_id.trim().is_empty() || event_name.trim().is_empty() {
        return;
    }
    let instance_id = page_instance_id.to_string();
    let registry = registry(ctx);
    registry.handlers.borrow_mut().retain(|scope, entries| {
        if let Scope::PageInstance(id) = scope
            && id == &instance_id
        {
            entries.retain(|h| h.event_name != event_name);
            return !entries.is_empty();
        }
        true
    });
}

/// Dispatch an app bus event into the correct JS handlers on the JS thread.
pub(crate) async fn dispatch_app_bus_event(ctx: &JSContext, event: &AppBusEvent) -> JSResult<()> {
    match &event.scope {
        Scope::App => {
            emit_to_handlers(
                ctx,
                Scope::App,
                &event.event_name,
                event.payload_json.as_deref(),
            )
            .await
        }
        Scope::PageInstance(path) => {
            emit_to_handlers(
                ctx,
                Scope::PageInstance(path.clone()),
                &event.event_name,
                event.payload_json.as_deref(),
            )
            .await
        }
    }
}

async fn emit_to_handlers(
    ctx: &JSContext,
    scope: Scope,
    event_name: &str,
    payload_json: Option<&str>,
) -> JSResult<()> {
    let registry = registry(ctx);
    let handlers = {
        let map = registry.handlers.borrow();
        map.get(&scope).cloned().unwrap_or_default()
    };

    if handlers.is_empty() {
        return Ok(());
    }

    info!(
        "Dispatching {} scope={:?} handlers={}",
        event_name,
        scope,
        handlers.len()
    );

    let payload_base = if let Some(json) = payload_json {
        JSObject::from_json_string(ctx, json).unwrap_or_else(|_| JSObject::new(ctx))
    } else {
        JSObject::new(ctx)
    };

    for handler in handlers.into_iter().filter(|h| h.event_name == event_name) {
        let payload = payload_base.clone();
        let _ = handler.callback.call_async::<_, ()>(None, (payload,)).await;
    }

    Ok(())
}

/// Emit an app-scoped event into JS.
pub fn publish_app_event(appid: &str, event_name: &str, payload_json: Option<String>) -> bool {
    let Some(lxapp) = crate::try_get(appid) else {
        warn!("publish_app_event: unknown appid {}", appid);
        return false;
    };

    let event = AppBusEvent {
        scope: Scope::App,
        event_name: event_name.to_string(),
        payload_json,
    };

    if let Err(e) = lxapp.executor.dispatch_app_bus_event(lxapp.clone(), event) {
        error!("Failed to dispatch app event: {}", e).with_appid(appid.to_string());
        false
    } else {
        true
    }
}

/// Emit a page-scoped event into JS (page_path required).
pub fn publish_page_event(
    appid: &str,
    page_path: &str,
    event_name: &str,
    payload_json: Option<String>,
) -> bool {
    if page_path.trim().is_empty() {
        warn!("publish_page_event: missing page_path");
        return false;
    }

    let Some(lxapp) = crate::try_get(appid) else {
        warn!("publish_page_event: unknown appid {}", appid);
        return false;
    };

    let Some(instance_id) = lxapp
        .get_page(page_path)
        .map(|page| page.instance_id_string())
    else {
        warn!(
            "publish_page_event: no live page instance for {}",
            page_path
        );
        return false;
    };

    let event = AppBusEvent {
        scope: Scope::PageInstance(instance_id),
        event_name: event_name.to_string(),
        payload_json,
    };

    if let Err(e) = lxapp.executor.dispatch_app_bus_event(lxapp.clone(), event) {
        error!("Failed to dispatch page event: {}", e).with_appid(appid.to_string());
        false
    } else {
        true
    }
}

#[cfg(test)]
mod token_tests {
    use super::*;
    use rong::{JSEngine, RongJS};

    /// Two subscriptions on one function are independent, and a token removes
    /// exactly its own entry — the guarantee an unsubscribe handle makes.
    #[test]
    fn a_token_removes_only_its_own_registration() -> JSResult<()> {
        let runtime = RongJS::runtime();
        let ctx = runtime.context();
        let callback = JSFunc::new(&ctx, || {})?;
        let first = register_app_handler(&ctx, "evt", callback.clone())?;
        let second = register_app_handler(&ctx, "evt", callback.clone())?;
        let other = register_app_handler(&ctx, "other", callback)?;
        assert_ne!(first, second);

        assert_eq!(unregister_app_handler_token(&ctx, "other", first), 1);
        let evt_count = registry(&ctx)
            .handlers
            .borrow()
            .get(&Scope::App)
            .into_iter()
            .flatten()
            .filter(|entry| entry.event_name == "evt")
            .count();
        assert_eq!(evt_count, 2, "an event name mismatch must be inert");

        assert_eq!(
            unregister_app_handler_token(&ctx, "evt", first),
            1,
            "the sibling subscription must survive"
        );
        assert_eq!(unregister_app_handler_token(&ctx, "evt", first), 1);
        assert_eq!(unregister_app_handler_token(&ctx, "evt", second), 0);
        assert_eq!(unregister_app_handler_token(&ctx, "other", other), 0);
        Ok(())
    }

    /// The handle removes its registration without being handed a context.
    /// (That it cannot form the #246 cycle is a property of the type — it
    /// holds a `Weak` to the map, not a `JSContext` — not of this test.)
    #[test]
    fn an_unsub_handle_does_not_need_the_context() -> JSResult<()> {
        let runtime = RongJS::runtime();
        let ctx = runtime.context();
        let callback = JSFunc::new(&ctx, || {})?;
        let token = register_app_handler(&ctx, "evt", callback)?;
        let off = app_handler_unsub(&ctx, "evt", token);
        assert_eq!(off.unsubscribe(), 0);
        assert_eq!(off.unsubscribe(), 0, "a second call is inert");
        Ok(())
    }
}