lingxia 0.11.1

LingXia - Cross-platform LxApp (lightweight application) framework for Android, iOS, macOS, HarmonyOS, and Windows
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
#![cfg_attr(target_os = "windows", allow(dead_code))]

use lingxia_log::{LogBuilder, LogLevel as LxLogLevel, LogManager, LogMessage, LogTag};
use log::{Level, LevelFilter, Log, Metadata, Record};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};

static LOGGING_INIT: OnceLock<()> = OnceLock::new();
static DOWNSTREAM_LOGGER: OnceLock<Box<dyn Log + Send + Sync>> = OnceLock::new();
static SDK_LOGGER: SdkLogger = SdkLogger;

const SDK_LOG_LEVEL_VERBOSE: i32 = 0;
const SDK_LOG_LEVEL_DEBUG: i32 = 1;
const SDK_LOG_LEVEL_INFO: i32 = 2;
const SDK_LOG_LEVEL_WARN: i32 = 3;
const SDK_LOG_LEVEL_ERROR: i32 = 4;

/// Explicit override for the runtime log threshold, e.g.
/// `LINGXIA_LOG_LEVEL=debug`. When unset, a dev session defaults to `debug`
/// and everything else to `info` (see [`resolve_initial_level`]); when set it
/// pins the level and the dev-session default is skipped.
const LOG_LEVEL_ENV: &str = "LINGXIA_LOG_LEVEL";

/// Single source of truth for the active minimum level, as an SDK level int
/// (0=verbose … 4=error). Both the Rust `log` facade and the host-log path
/// ([`forward_host_log`]) gate on this, so Rust and SDK logs share one policy.
/// Adjustable at runtime via [`set_log_level`] (e.g. the dev server raising it
/// for a session).
static HOST_LOG_LEVEL: AtomicI32 = AtomicI32::new(SDK_LOG_LEVEL_INFO);

/// True when the level was set explicitly (via `LINGXIA_LOG_LEVEL` or
/// [`set_log_level`]), so neither the dev-session default
/// ([`apply_dev_session_level`]) nor a late [`init`] overrides that choice.
static LEVEL_PINNED: AtomicBool = AtomicBool::new(false);

/// Error returned when installing a downstream logger fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownstreamLoggerError {
    /// A downstream logger has already been registered.
    AlreadyRegistered,
}

impl std::fmt::Display for DownstreamLoggerError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::AlreadyRegistered => write!(f, "downstream logger is already registered"),
        }
    }
}

impl std::error::Error for DownstreamLoggerError {}

pub(crate) fn init() {
    if LOGGING_INIT.get().is_some() {
        return;
    }

    // Seed the shared threshold before any record can flow, so the platform
    // sinks and the facade agree on the level from the very first log. A
    // set_log_level() before init() (e.g. an early dev-server hook) already
    // pinned it, so only seed the env/dev default when it hasn't been. On
    // devices the dev-ws-url lives in app config that isn't loaded yet, so this
    // sees only the env signal here; `apply_dev_session_level` re-checks later.
    if !LEVEL_PINNED.load(Ordering::Relaxed) {
        let (level, pinned) = resolve_initial_level();
        LEVEL_PINNED.store(pinned, Ordering::Relaxed);
        HOST_LOG_LEVEL.store(level, Ordering::Relaxed);
    }

    let _ = LogManager::init(|message| {
        platform_logger().write(message);
    });

    if log::set_logger(&SDK_LOGGER).is_ok() {
        log::set_max_level(sdk_level_to_filter(HOST_LOG_LEVEL.load(Ordering::Relaxed)));
    }

    let _ = LOGGING_INIT.set(());
}

/// Raise or lower the active log threshold at runtime (SDK level int: 0=verbose
/// … 4=error). Updates both the host-log gate and the Rust `log` facade so they
/// stay in lock-step — e.g. the dev server raising it to `debug` for a session.
///
/// Note: the Android/Harmony logcat sink's own cap is fixed when first built
/// (from the env level), so a later raise reaches the dev-server stream and the
/// in-memory buffer but not logcat/hilog.
pub fn set_log_level(level: i32) {
    if map_sdk_level(level).is_none() {
        return;
    }
    // Pin so `init`/`apply_dev_session_level` don't clobber an explicit choice.
    LEVEL_PINNED.store(true, Ordering::Relaxed);
    HOST_LOG_LEVEL.store(level, Ordering::Relaxed);
    log::set_max_level(sdk_level_to_filter(level));
}

/// Whether a host log at `level` (SDK int) would be recorded at the current
/// threshold. Host wrappers guard hot-path logs with this to skip building a
/// message (and crossing the FFI) that [`forward_host_log`] would only drop.
pub fn host_log_enabled(level: i32) -> bool {
    map_sdk_level(level).is_some() && level >= HOST_LOG_LEVEL.load(Ordering::Relaxed)
}

/// Initial level: an explicit `LINGXIA_LOG_LEVEL` wins and pins the choice;
/// otherwise a dev session defaults to `debug` and everything else to `info`.
/// Returns `(level, pinned_by_env)`.
fn resolve_initial_level() -> (i32, bool) {
    if let Ok(value) = std::env::var(LOG_LEVEL_ENV)
        && let Some(level) = parse_level(&value)
    {
        return (level, true);
    }
    let level = if lxapp::is_dev_session() {
        SDK_LOG_LEVEL_DEBUG
    } else {
        SDK_LOG_LEVEL_INFO
    };
    (level, false)
}

fn parse_level(value: &str) -> Option<i32> {
    match value.trim().to_ascii_lowercase().as_str() {
        "verbose" | "trace" => Some(SDK_LOG_LEVEL_VERBOSE),
        "debug" => Some(SDK_LOG_LEVEL_DEBUG),
        "info" => Some(SDK_LOG_LEVEL_INFO),
        "warn" | "warning" => Some(SDK_LOG_LEVEL_WARN),
        "error" => Some(SDK_LOG_LEVEL_ERROR),
        _ => None,
    }
}

/// Re-evaluate the dev-session default once app config is loaded — the device
/// dev-ws-url isn't known at [`init`]. Raises to `debug` for a dev session
/// unless `LINGXIA_LOG_LEVEL` pinned an explicit level. The Android/Harmony
/// logcat sink cap is already fixed by then, so this reaches the dev-server
/// stream and buffer (what `lxdev logs` shows) rather than raw logcat/hilog.
pub(crate) fn apply_dev_session_level() {
    if LEVEL_PINNED.load(Ordering::Relaxed) {
        return;
    }
    if lxapp::is_dev_session() && HOST_LOG_LEVEL.load(Ordering::Relaxed) > SDK_LOG_LEVEL_DEBUG {
        set_log_level(SDK_LOG_LEVEL_DEBUG);
    }
}

fn sdk_level_to_filter(level: i32) -> LevelFilter {
    match level {
        SDK_LOG_LEVEL_VERBOSE => LevelFilter::Trace,
        SDK_LOG_LEVEL_DEBUG => LevelFilter::Debug,
        SDK_LOG_LEVEL_INFO => LevelFilter::Info,
        SDK_LOG_LEVEL_WARN => LevelFilter::Warn,
        SDK_LOG_LEVEL_ERROR => LevelFilter::Error,
        _ => LevelFilter::Info,
    }
}

/// Registers an additional logger that receives every Rust log record emitted by LingXia.
///
/// LingXia still keeps its own platform logger and log manager. The downstream
/// logger is an observer hook for host applications that want to mirror records
/// into another sink.
pub fn register_downstream_logger(
    logger: Box<dyn Log + Send + Sync>,
) -> Result<(), DownstreamLoggerError> {
    DOWNSTREAM_LOGGER
        .set(logger)
        .map_err(|_| DownstreamLoggerError::AlreadyRegistered)
}

/// Forward a log record originating in host (non-Rust) code into the Rust log pipeline.
///
/// The `level` value is the raw FFI contract: 0=verbose, 1=debug, 2=info,
/// 3=warn, 4=error. SDK-facing wrappers should hide these integer values
/// behind platform-native enums.
///
/// **Recommended: forward errors and important warnings only.** Records that
/// arrive here are not just shown in `lxdev logs` — they are buffered for cloud
/// upload / crash diagnosis. Routing routine info/debug or high-frequency traces
/// churns that bounded buffer and evicts the errors it exists to preserve (and
/// costs an FFI crossing + eager message build per call). Keep lifecycle/info/
/// debug on the platform logger (os_log / logcat / hilog); send through this
/// path the diagnostics you'd want in an uploaded log bundle.
pub(crate) fn forward_host_log(
    level: i32,
    category: &str,
    appid: &str,
    path: &str,
    message: &str,
) -> bool {
    let level_int = level;
    let Some(level) = map_sdk_level(level_int) else {
        return false;
    };
    // Shared threshold: drop anything below the active level before doing any
    // work, so host logs honour the same policy as Rust `log::*` records
    // (previously host logs bypassed the level entirely).
    if level_int < HOST_LOG_LEVEL.load(Ordering::Relaxed) {
        return false;
    }
    // Before the pipeline is up (early cold-start), fall back to the platform
    // logger directly so bootstrap warnings still reach logcat/os_log/hilog
    // instead of vanishing.
    if LogManager::get().is_none() {
        let mut msg = LogMessage::new(LogTag::Native, message).with_level(level);
        msg.target = Some(category.to_string());
        if !appid.is_empty() {
            msg.appid = Some(appid.to_string());
        }
        if !path.is_empty() {
            msg.path = Some(path.to_string());
        }
        platform_logger().write(&msg);
        return true;
    }

    LogBuilder::new(LogTag::Native, message)
        .with_level(level)
        .with_target(category.to_string())
        .with_appid(appid.to_string())
        .with_path(path.to_string());
    true
}

struct SdkLogger;

impl Log for SdkLogger {
    fn enabled(&self, metadata: &Metadata<'_>) -> bool {
        metadata.level() <= Level::Trace
    }

    fn log(&self, record: &Record<'_>) {
        if !self.enabled(record.metadata()) {
            return;
        }

        LogBuilder::new(LogTag::Native, format!("{}", record.args()))
            .with_level(map_level(record.level()))
            .with_target(record.target().to_string());

        if let Some(logger) = DOWNSTREAM_LOGGER.get()
            && logger.enabled(record.metadata())
        {
            logger.log(record);
        }
    }

    fn flush(&self) {
        if let Some(logger) = DOWNSTREAM_LOGGER.get() {
            logger.flush();
        }
    }
}

fn map_level(level: Level) -> LxLogLevel {
    match level {
        Level::Error => LxLogLevel::Error,
        Level::Warn => LxLogLevel::Warn,
        Level::Info => LxLogLevel::Info,
        Level::Debug => LxLogLevel::Debug,
        Level::Trace => LxLogLevel::Verbose,
    }
}

fn map_sdk_level(level: i32) -> Option<LxLogLevel> {
    match level {
        SDK_LOG_LEVEL_VERBOSE => Some(LxLogLevel::Verbose),
        SDK_LOG_LEVEL_DEBUG => Some(LxLogLevel::Debug),
        SDK_LOG_LEVEL_INFO => Some(LxLogLevel::Info),
        SDK_LOG_LEVEL_WARN => Some(LxLogLevel::Warn),
        SDK_LOG_LEVEL_ERROR => Some(LxLogLevel::Error),
        _ => None,
    }
}

fn format_log_message(message: &LogMessage) -> String {
    let mut prefix = String::from("[");
    prefix.push_str(message.tag.as_str());
    if let Some(appid) = message.appid.as_deref()
        && !appid.is_empty()
    {
        prefix.push(':');
        prefix.push_str(appid);
    }
    if let Some(path) = message.path.as_deref()
        && !path.is_empty()
    {
        prefix.push(':');
        prefix.push_str(path);
    }
    prefix.push(']');
    if let Some(target) = message.target.as_deref()
        && !target.is_empty()
        && target != "lingxia.lxapp"
    {
        prefix.push('[');
        prefix.push_str(target);
        prefix.push(']');
    }
    format!("{prefix} {}", message.message)
}

struct PlatformLogger {
    #[cfg(target_os = "android")]
    android: android_logger::AndroidLogger,
    #[cfg(target_env = "ohos")]
    harmony: ohos_hilog::OhosLogger,
    #[cfg(any(target_os = "ios", target_os = "macos"))]
    apple: oslog::OsLog,
}

impl PlatformLogger {
    fn new() -> Self {
        // Match the logcat/hilog cap to the resolved threshold so a dev build
        // (LINGXIA_LOG_LEVEL=debug) actually surfaces migrated debug/verbose
        // records instead of the old hard Info cap silently dropping them.
        #[cfg(any(target_os = "android", target_env = "ohos"))]
        let sink_filter = sdk_level_to_filter(HOST_LOG_LEVEL.load(Ordering::Relaxed));
        Self {
            #[cfg(target_os = "android")]
            android: android_logger::AndroidLogger::new(
                android_logger::Config::default()
                    .with_max_level(sink_filter)
                    .with_tag("Rust"),
            ),
            #[cfg(target_env = "ohos")]
            harmony: ohos_hilog::OhosLogger::new(
                ohos_hilog::Config::default()
                    .with_max_level(sink_filter)
                    .with_tag("LingXia.Rust"),
            ),
            #[cfg(any(target_os = "ios", target_os = "macos"))]
            apple: oslog::OsLog::new("LingXia.Rust", "sdk"),
        }
    }

    fn write(&self, message: &LogMessage) {
        let formatted = format_log_message(message);
        #[cfg(target_os = "android")]
        {
            let target = message.target.as_deref().unwrap_or("lingxia");
            let args = format_args!("{formatted}");
            let record = Record::builder()
                .args(args)
                .level(map_sdk_level_to_log_level(message.level))
                .target(target)
                .module_path(Some(target))
                .build();
            self.android.log(&record);
        }

        #[cfg(target_env = "ohos")]
        {
            let target = message.target.as_deref().unwrap_or("lingxia");
            let args = format_args!("{formatted}");
            let record = Record::builder()
                .args(args)
                .level(map_sdk_level_to_log_level(message.level))
                .target(target)
                .module_path(Some(target))
                .build();
            self.harmony.log(&record);
        }

        #[cfg(any(target_os = "ios", target_os = "macos"))]
        {
            use oslog::Level as OsLevel;
            let level = match message.level {
                LxLogLevel::Verbose | LxLogLevel::Debug => OsLevel::Debug,
                LxLogLevel::Info => OsLevel::Info,
                // Warn -> Default (notice: still persisted to the unified log),
                // Error -> Error. Avoid Fault, which crash tooling reserves for
                // unrecoverable system faults — mapping SDK errors to it inflates
                // severity and misleads iOS/macOS triage.
                LxLogLevel::Warn => OsLevel::Default,
                LxLogLevel::Error => OsLevel::Error,
            };
            self.apple.with_level(level, &formatted);
        }

        #[cfg(not(any(
            target_os = "android",
            target_os = "ios",
            target_os = "macos",
            target_env = "ohos"
        )))]
        {
            eprintln!("{formatted}");
        }
    }
}

#[cfg(any(target_os = "android", target_env = "ohos"))]
fn map_sdk_level_to_log_level(level: LxLogLevel) -> Level {
    match level {
        LxLogLevel::Verbose => Level::Trace,
        LxLogLevel::Debug => Level::Debug,
        LxLogLevel::Info => Level::Info,
        LxLogLevel::Warn => Level::Warn,
        LxLogLevel::Error => Level::Error,
    }
}

fn platform_logger() -> &'static PlatformLogger {
    static PLATFORM_LOGGER: OnceLock<PlatformLogger> = OnceLock::new();
    PLATFORM_LOGGER.get_or_init(PlatformLogger::new)
}