tishlang_desktop 1.1.4

cargo:tishlang_desktop / cargo:tishlang_app — cross-device Tish app runtime (Tauri desktop + platform adapters)
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
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use tishlang_core::{value_call, NativeFn, Value};

use crate::broker::{SharedState, SurfaceInfo, SurfaceKind, SurfaceRegistry};
use crate::fs_sandbox::FsWatcher;

pub const PROTOCOL_VERSION: &str = "desktop/v1";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WindowSpec {
    pub label: String,
    /// Surface kind: `webview` (default) or `native` (apple attach when enabled).
    #[serde(default)]
    pub kind: Option<String>,
    /// Optional stable surface id (defaults to `label`).
    #[serde(default)]
    pub id: Option<String>,
    #[serde(default)]
    pub url: Option<String>,
    #[serde(default)]
    pub title: Option<String>,
    #[serde(default = "default_width")]
    pub width: f64,
    #[serde(default = "default_height")]
    pub height: f64,
    #[serde(default = "default_title_bar_style")]
    pub title_bar_style: String,
    #[serde(default)]
    pub hidden_title: bool,
    #[serde(default = "default_true")]
    pub decorations: bool,
    /// Native layout hint: `content` | `sidebar` (apple).
    #[serde(default)]
    pub layout: Option<String>,
}

fn default_width() -> f64 {
    960.0
}
fn default_height() -> f64 {
    640.0
}
fn default_title_bar_style() -> String {
    "transparent".into()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShellAllowEntry {
    pub name: String,
    pub cmd: String,
    #[serde(default)]
    pub args_prefix: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthConfig {
    #[serde(default)]
    pub token_hosts: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginFlags {
    #[serde(default = "default_true")]
    pub dialog: bool,
    #[serde(default = "default_true")]
    pub tray: bool,
    #[serde(default = "default_true")]
    pub menu: bool,
    #[serde(default = "default_true")]
    pub deep_link: bool,
    #[serde(default = "default_true")]
    pub opener: bool,
    #[serde(default = "default_true")]
    pub single_instance: bool,
    #[serde(default = "default_true")]
    pub notification: bool,
    #[serde(default = "default_true")]
    pub clipboard: bool,
    #[serde(default = "default_true")]
    pub global_shortcut: bool,
    #[serde(default = "default_true")]
    pub window_state: bool,
    #[serde(default = "default_true")]
    pub os: bool,
    #[serde(default = "default_true")]
    pub store: bool,
    #[serde(default = "default_true")]
    pub autostart: bool,
    #[serde(default)]
    pub updater: bool,
    #[serde(default = "default_true")]
    pub process: bool,
    #[serde(default)]
    pub shell: bool,
    #[serde(default)]
    pub http: bool,
    #[serde(default = "default_true")]
    pub auth: bool,
}

impl Default for PluginFlags {
    fn default() -> Self {
        Self {
            dialog: true,
            tray: true,
            menu: true,
            deep_link: true,
            opener: true,
            single_instance: true,
            notification: true,
            clipboard: true,
            global_shortcut: true,
            window_state: true,
            os: true,
            store: true,
            autostart: true,
            updater: false,
            process: true,
            shell: false,
            http: false,
            auth: true,
        }
    }
}

fn default_true() -> bool {
    true
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppleAttachConfig {
    /// Hybrid default: false — outer host (Tauri) owns NSApplication.run.
    #[serde(default = "default_false")]
    pub auto_run_event_loop: bool,
    /// Hybrid default: true — skip apple menu/timer/activation clobber.
    #[serde(default = "default_true")]
    pub outer_host: bool,
}

impl Default for AppleAttachConfig {
    fn default() -> Self {
        Self {
            auto_run_event_loop: false,
            outer_host: true,
        }
    }
}

fn default_false() -> bool {
    false
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PlatformAttachConfig {
    #[serde(default)]
    pub apple: Option<AppleAttachConfig>,
}

/// One host-defined `<scheme>://localhost/<abs-path>` resource protocol (see `RunConfig::resource_protocols`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourceProtocol {
    /// URI scheme name, e.g. "app-assets" — served as `<scheme>://localhost/<abs-path>`.
    pub scheme: String,
    /// Trusted absolute-path substring (e.g. "/my-app/assets/"); only files whose decoded path
    /// contains it (and have no `..`) are served. This is the entire allowlist for the scheme.
    pub path_contains: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RunConfig {
    /// Runtime profile: `desktop` | `ios` | `web` (informational + attach defaults).
    #[serde(default)]
    pub profile: Option<String>,
    #[serde(default)]
    pub platform_attach: Option<PlatformAttachConfig>,
    #[serde(default)]
    pub windows: Vec<WindowSpec>,
    #[serde(default)]
    pub plugins: PluginFlags,
    #[serde(default)]
    pub fs_root: Option<String>,
    /// Path to a PNG the app should use for its tray icon and (macOS) dock icon, overriding the
    /// tish-desktop default. Lets a hosting app brand itself without rebuilding the
    /// runtime's bundled icons.
    #[serde(default)]
    pub icon: Option<String>,
    /// A custom URI scheme the hosting app handles as a deep link, registered at runtime so
    /// `<scheme>://…` opens route to `on_open_url` → the `deep-link` event. Packaged builds also
    /// need the scheme in their OWN bundle Info.plist; this covers dev + the running-instance case.
    #[serde(default)]
    pub deep_link_scheme: Option<String>,
    /// Custom `<scheme>://localhost/<abs-path>` resource protocols the hosting app registers to serve
    /// its own local files to sandboxed webview iframes (e.g. an extension's assets). Each is
    /// path-restricted to files whose absolute path contains `path_contains` (plus a `..` traversal
    /// guard). Nothing app-specific lives in the runtime — the scheme + trusted segment come from here.
    #[serde(default)]
    pub resource_protocols: Vec<ResourceProtocol>,
    #[serde(default)]
    pub extensions: Vec<String>,
    #[serde(default, deserialize_with = "deserialize_opt_u64_from_number")]
    pub tick_ms: Option<u64>,
    #[serde(default)]
    pub shell_allow: Vec<ShellAllowEntry>,
    #[serde(default)]
    pub http_allow: Vec<String>,
    #[serde(default)]
    pub auth: Option<AuthConfig>,
}

fn deserialize_opt_u64_from_number<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let v = Option::<serde_json::Value>::deserialize(deserializer)?;
    Ok(match v {
        None | Some(serde_json::Value::Null) => None,
        Some(serde_json::Value::Number(n)) => n
            .as_u64()
            .or_else(|| n.as_i64().map(|i| i.max(0) as u64))
            .or_else(|| n.as_f64().map(|f| f.max(0.0) as u64)),
        Some(_) => None,
    })
}

pub fn permissions_from_plugins(p: &PluginFlags) -> Vec<String> {
    let mut perms = vec!["fs:scoped".into()];
    if p.dialog {
        perms.push("dialog".into());
    }
    if p.tray {
        perms.push("tray".into());
    }
    if p.menu {
        perms.push("menu".into());
    }
    if p.deep_link {
        perms.push("deep-link".into());
    }
    if p.opener {
        perms.push("opener".into());
    }
    if p.notification {
        perms.push("notification".into());
    }
    if p.clipboard {
        perms.push("clipboard".into());
    }
    if p.global_shortcut {
        perms.push("global-shortcut".into());
    }
    if p.window_state {
        perms.push("window-state".into());
    }
    if p.os {
        perms.push("os".into());
    }
    if p.store {
        perms.push("store".into());
    }
    if p.autostart {
        perms.push("autostart".into());
    }
    if p.updater {
        perms.push("updater".into());
    }
    if p.process {
        perms.push("process".into());
    }
    if p.shell {
        perms.push("shell".into());
    }
    if p.http {
        perms.push("http".into());
    }
    if p.auth {
        perms.push("auth".into());
        perms.push("secrets".into());
    }
    perms
}

#[cfg(test)]
mod tests {
    use super::{PluginFlags, RunConfig};

    #[test]
    fn parses_tick_ms_camel_case_from_f64() {
        let v = serde_json::json!({
            "tickMs": 1000.0,
            "plugins": {
                "dialog": true,
                "tray": true,
                "menu": true,
                "deepLink": true,
                "opener": true,
                "singleInstance": true
            }
        });
        let cfg: RunConfig = serde_json::from_value(v).expect("parse");
        assert_eq!(cfg.tick_ms, Some(1000));
        assert!(cfg.plugins.notification);
        assert!(PluginFlags::default().notification);
        assert!(PluginFlags::default().opener);
    }
}

/// In-flight OAuth PKCE session awaiting a redirect callback (loopback or scheme).
#[derive(Debug, Clone)]
pub struct PendingOAuth {
    pub verifier: String,
    pub csrf_state: String,
    pub token_url: String,
    pub client_id: String,
    pub redirect_uri: String,
    /// OIDC nonce when openid / explicit oidc login; checked against `id_token` payload.
    pub nonce: Option<String>,
    pub revocation_endpoint: Option<String>,
}

/// Persisted (in-memory) session metadata after a successful login.
#[derive(Debug, Clone)]
pub struct AuthSession {
    pub client_id: String,
    pub revocation_endpoint: Option<String>,
}

pub struct AppState {
    pub fs_root: Mutex<Option<PathBuf>>,
    pub fs_watcher: FsWatcher,
    pub handlers: Mutex<HashMap<String, NativeFn>>,
    pub permissions: Mutex<Vec<String>>,
    pub extensions: Mutex<Vec<String>>,
    pub config: Mutex<RunConfig>,
    pub tray: Mutex<Option<tauri::tray::TrayIcon>>,
    pub sleep_blocks: Mutex<u32>,
    pub shortcuts: Mutex<HashMap<String, String>>,
    /// Held while `sleep_blocks > 0`; dropping it re-allows the system to sleep.
    pub keepawake_guard: Mutex<Option<keepawake::KeepAwake>>,
    /// In-memory cache of the last-issued OAuth access token: `(token, expires_at_unix_secs)`.
    pub auth_cache: Mutex<Option<(String, u64)>>,
    pub pending_oauth: Mutex<Option<PendingOAuth>>,
    pub auth_session: Mutex<Option<AuthSession>>,
    /// Broker shared microfrontend state (`state.*`).
    pub shared_state: Arc<SharedState>,
    pub surfaces: Arc<SurfaceRegistry>,
}

impl AppState {
    pub fn new(config: RunConfig) -> Self {
        let fs_root = config.fs_root.as_ref().map(PathBuf::from);
        let permissions = permissions_from_plugins(&config.plugins);
        let shared_state = Arc::clone(&crate::broker::GLOBAL_SHARED_STATE);
        let surfaces = Arc::clone(&crate::broker::GLOBAL_SURFACES);
        for w in &config.windows {
            let id = w.id.clone().unwrap_or_else(|| w.label.clone());
            let kind = match w.kind.as_deref() {
                Some("native") => SurfaceKind::Native,
                Some("web") => SurfaceKind::Web,
                _ => SurfaceKind::Webview,
            };
            surfaces.register(SurfaceInfo {
                id,
                kind,
                platform: None,
                label: Some(w.label.clone()),
            });
        }
        Self {
            fs_root: Mutex::new(fs_root),
            fs_watcher: FsWatcher::default(),
            handlers: Mutex::new(HashMap::new()),
            permissions: Mutex::new(permissions),
            extensions: Mutex::new(config.extensions.clone()),
            config: Mutex::new(config),
            tray: Mutex::new(None),
            sleep_blocks: Mutex::new(0),
            shortcuts: Mutex::new(HashMap::new()),
            keepawake_guard: Mutex::new(None),
            auth_cache: Mutex::new(None),
            pending_oauth: Mutex::new(None),
            auth_session: Mutex::new(None),
            shared_state,
            surfaces,
        }
    }

    pub fn register_handler(&self, name: String, f: NativeFn) {
        self.handlers.lock().insert(name, f);
    }

    pub fn call_handler(
        &self,
        name: &str,
        args_json: serde_json::Value,
    ) -> Result<serde_json::Value, String> {
        // Clone the handler Arc OUT of the map and release the lock BEFORE running it. Handlers
        // legitimately call brokerInvoke re-entrantly (e.g. appChrome's set_traffic_light_inset →
        // window.trafficLightInset), which re-enters dispatch → handlers.lock(). Holding the lock
        // across value_call would deadlock — parking_lot::Mutex is NOT re-entrant — and, because the
        // wedged thread never releases it, every later desktop_invoke blocks at its is_handler check,
        // freezing the whole backend (no themes / palette / extensions on boot).
        let f = {
            let handlers = self.handlers.lock();
            match handlers.get(name) {
                Some(f) => Arc::clone(f),
                None => return Err(format!("unknown command: {name}")),
            }
        };
        let arg = crate::value_util::json_to_value(&args_json);
        let result = value_call(&Value::Function(f), &[arg]);
        crate::value_util::value_to_json(&result)
            .ok_or_else(|| "handler returned non-JSON value".into())
    }

    pub fn has_permission(&self, perm: &str) -> bool {
        self.permissions.lock().iter().any(|p| p == perm)
    }
}

pub static PENDING_CONFIG: once_cell::sync::Lazy<Mutex<Option<RunConfig>>> =
    once_cell::sync::Lazy::new(|| Mutex::new(None));

pub static PENDING_HANDLERS: once_cell::sync::Lazy<Mutex<HashMap<String, NativeFn>>> =
    once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new()));