Skip to main content

tilo_app/framework/
app.rs

1//! Declaring and running a framework app on the toolkit's component runtime.
2
3use std::any::TypeId;
4use std::collections::HashMap;
5use std::rc::Rc;
6use std::sync::{Mutex, OnceLock};
7
8use tilo_resource::{Invoke, Reader, Resource};
9use ui_core::application::{
10    AppComponent, ApplicationDefinition, ComponentBehaviour, HostContext, LaunchTarget,
11    RestoreInput,
12};
13use ui_core::history_mode::checked_files::FileHistorySource;
14use ui_core::i18n::EmbeddedCatalog;
15use ui_core::launch::LaunchError;
16use ui_core::permissions::{ActionKind, LivePermissions, PermissionScope};
17use ui_core::record::{RecordId, RecordKind};
18use ui_core::storage::AppStorage;
19use ui_core::widget::{Element, IconDescriptor};
20
21// Launching (live windows, captures, headless drives, test hosts) discovers
22// native presentation and profiles; a browser host prepares components itself.
23#[cfg(not(target_arch = "wasm32"))]
24use {
25    std::cell::RefCell,
26    ui_core::application::{
27        ApplicationHost, Capture, ComponentRunnerInputs, NativeProcessHost, PreparedComponentInputs,
28    },
29    ui_core::headless::FrameHarness,
30    ui_core::launch::{LaunchArgs, LaunchSpec},
31    ui_core::permissions::{PermissionContext, Role, UserId},
32    ui_core::record::RecordCheckout,
33};
34
35use super::dispatch::Dispatcher;
36use super::effect::Effects;
37use super::launch::{Command, Helper, LaunchInput};
38use super::live::Live;
39use super::root::ResourceRoot;
40use super::scenario::{Fixture, Launched, Run};
41use super::view::ViewCx;
42
43/// A framework app: one resource type and a view of it.
44///
45/// Everything else — storage, sync, collaboration, history, undo,
46/// permissions, the action palette, the agent and script door, search — is
47/// provided. See the [module docs](super).
48pub trait ResourceApp: 'static {
49    /// The resource this app edits.
50    type Resource: Resource;
51    /// The application id, e.g. `"so.tilo.scratchpad"`.
52    const ID: &'static str;
53    /// The window and launcher title.
54    const TITLE: &'static str;
55    /// A named icon; defaults to the app id's last segment.
56    const ICON: Option<&'static str> = None;
57    /// How every window hosting the app treats it: focus after a rebuild,
58    /// live appearance, and whether captures withhold it. An app that shows
59    /// secrets declares `capture_protected` here, so the live launch, the
60    /// shell and every other host of its component protect it alike; an
61    /// on-screen keyboard declares its window an
62    /// [input surface](ui_core::application::WindowRole::InputSurface).
63    const BEHAVIOUR: ComponentBehaviour = ComponentBehaviour::DEFAULT;
64    /// Media types (`type/subtype`, lowercase) of the files the app opens
65    /// when a host hands it one (a file association, a file dropped on its
66    /// icon, a path of that type on its command line); [`Self::launch`]
67    /// receives the file. `"*/*"`
68    /// ([`ANY_MEDIA_TYPE`](ui_core::application::ANY_MEDIA_TYPE)) opens every
69    /// file, a file whose name says nothing of its type as
70    /// `application/octet-stream`.
71    const OPENS: &'static [&'static str] = &[];
72    /// How many arguments (paths, URLs) a launch may name;
73    /// [`Self::launch_record`] and [`Self::launch`] receive them.
74    const ARGUMENTS: usize = 0;
75    /// Whether the resource keeps document state whose versions History
76    /// shows. An app whose state lives outside its document (a measurement,
77    /// a device) sets `false`: it publishes no History actions and its
78    /// window offers no History.
79    const DOCUMENT_HISTORY: bool = true;
80    /// The app keeps one record per room, the room's own, rather than one
81    /// per window: every window of the app in the room opens that record,
82    /// and the first one creates it (and applies [`Self::seed`]). A room's
83    /// conversation is such a record.
84    const ROOM_RECORD: bool = false;
85    /// The app's own command-line options that take a value
86    /// (`--purpose number` or `--purpose=number`): how this window runs
87    /// rather than what it opens. [`Self::launch`] reads them from its
88    /// [`LaunchInput::option`], views and surfaces from
89    /// [`RecordEnv::launch_option`](super::RecordEnv::launch_option).
90    const OPTIONS: &'static [&'static str] = &[];
91    /// The app's own switches, which take no value (`--debug-hit-areas`):
92    /// [`LaunchInput::flag`], [`RecordEnv::launch_flag`](super::RecordEnv::launch_flag).
93    const FLAGS: &'static [&'static str] = &[];
94    /// Whether the app opens private windows (`--private`): a window on a
95    /// profile of its own that is thrown away with the process — its record,
96    /// settings and files never reach the person's — and that attaches to
97    /// no runtime, so nothing it shows syncs or is shared. It holds none of
98    /// the person's own records ([`RecordEnv::person`](super::RecordEnv::person) answers `None`), and its
99    /// view tells with [`RecordEnv::is_private`](super::RecordEnv::is_private).
100    const PRIVATE_WINDOWS: bool = false;
101    /// The history of a file of the mounted filesystem the app shows (the
102    /// file selected in a file manager), a History scope beside the
103    /// resource's own: its versions are read, previewed and restored through
104    /// the filesystem's projection, not the app's document. The app's
105    /// surface names the file with [`SurfaceCx::file_history`](super::SurfaceCx::file_history).
106    const FILE_HISTORY: Option<FileHistory> = None;
107
108    /// Build the view. Called once for the live app, and again for every
109    /// historical version History mode shows (with a read-only context), so
110    /// it must build widgets and nothing else.
111    fn view(cx: &mut ViewCx<Self::Resource>) -> Element;
112
113    /// The label of the open resource in the palette and in history.
114    fn label(state: &<Self::Resource as Resource>::State) -> String {
115        let _ = state;
116        <Self::Resource as Resource>::LABEL.to_owned()
117    }
118
119    /// View and effect operations of this app's presentation, published on
120    /// the same resource as [`Resource::local_operations`]. A shared resource
121    /// type (rich text) does not know about one app's chrome. Document
122    /// mutations do not belong here. History does not either: it is the
123    /// resource's own history, not a toggle beside it.
124    fn local_operations() -> Vec<tilo_actions::ActionSpec> {
125        Vec::new()
126    }
127
128    /// The record a launch naming `input` opens: the file's own record
129    /// ([`RecordId::identify_file`]), a record derived from it
130    /// ([`RecordId::derived`]), a minted one. `None`, the default, opens the
131    /// record the app would open anyway: the one the window showed last, else
132    /// the room's default. Asked by a launcher that picks the record (the
133    /// command line, a test host), before the record is opened, so it must
134    /// not depend on anything but `input`.
135    fn launch_record(input: &LaunchInput<'_>) -> Option<RecordId> {
136        let _ = input;
137        None
138    }
139
140    /// The actions a launch naming `input` takes once its record is open;
141    /// `created` when this launch created the record, so actions that fill
142    /// a new record (the source of a new project) are taken only then. They
143    /// go through the same door as every control: checked against the
144    /// participant's permissions, attributed, undoable and in history. A
145    /// launch that shows something rather than changing the resource (a
146    /// link to one item, a file to look at before acting on it) invokes one
147    /// of the app's effects ([`Invoke::effect`]) through the same door. A
148    /// capture launched on nothing takes none: its [scenario](Self::scenario)
149    /// stages the record.
150    ///
151    /// When the record was already there, [`LaunchInput::record`] reads it
152    /// as it is: a launch on a file can tell that the file changed since
153    /// the record last read or wrote it.
154    fn launch(input: &LaunchInput<'_>, created: bool) -> Vec<Invoke<Self::Resource>> {
155        let _ = (input, created);
156        Vec::new()
157    }
158
159    /// What a record a launch naming `input` creates starts with besides
160    /// the resource's [`initialize`](Resource::initialize): the content of
161    /// the file it was launched on. The actions are written into the
162    /// record's genesis, so they must depend on nothing but `input` — two
163    /// devices that each create the record of one file converge on one
164    /// document only if both write the same genesis. They are nobody's
165    /// edit: no history entry, no undo.
166    fn genesis(input: &LaunchInput<'_>) -> Vec<Invoke<Self::Resource>> {
167        let _ = input;
168        Vec::new()
169    }
170
171    /// The commands the app's binary runs instead of opening a window.
172    fn commands() -> &'static [Command] {
173        &[]
174    }
175
176    /// The helpers the app's binary also is ([`Helper`]), asked in order
177    /// before the app starts.
178    fn helpers() -> &'static [Helper] {
179        &[]
180    }
181
182    /// Whether the app takes part in the workspace runtime of the room it is
183    /// launched in. An app that only runs on this device (an imager on a
184    /// platform without the runtime) says no.
185    const ATTACHES_RUNTIME: bool = true;
186
187    /// The app's message catalog, loaded before its view is built in every
188    /// host that mounts the app, so the view's lookups find its messages.
189    fn catalog() -> Option<&'static EmbeddedCatalog> {
190        None
191    }
192
193    /// Named screenshot scenarios for `--shots --scenario NAME`; `--shots-dir`
194    /// captures every one.
195    fn scenarios() -> &'static [&'static str] {
196        &[]
197    }
198
199    /// The viewport a capture of `scenario` (`None` for a plain `--shots`)
200    /// is drawn at, unless `--viewport` names one: a narrow scenario's
201    /// phone-sized window.
202    fn shot_viewport(scenario: Option<&str>) -> (u32, u32) {
203        let _ = scenario;
204        (1280, 800)
205    }
206
207    /// Prepare what a capture of `fixture.scenario()` shows that its actions
208    /// cannot stage, before the view is built: typed stand-ins for live
209    /// sources, scratch files, content-store blobs, other people's versions
210    /// and collaborators. Runs only for a capture, whose views are built in
211    /// [`RunMode::Capture`](super::RunMode). See [`Fixture`](super::Fixture).
212    fn fixture(fixture: &mut super::Fixture<Self::Resource>) {
213        let _ = fixture;
214    }
215
216    /// The actions that stage the resource for a capture: `Some(name)` for
217    /// `--shots --scenario NAME`, `None` for a plain `--shots`. They apply
218    /// through the same door as every control; then the views stage their
219    /// view state ([`ViewCx::on_stage`], `ResourceSurface::stage`) and the
220    /// frame is captured. See [`stage`](super::stage).
221    fn scenario(name: Option<&str>) -> Vec<Invoke<Self::Resource>> {
222        let _ = name;
223        Vec::new()
224    }
225
226    /// What a person does in the scene that the scenario's actions and
227    /// staged view state cannot show: text typed into an editor, a drag
228    /// held over its drop target, a menu opened with a right click. Driven
229    /// on `input` through the window's own event path after the actions
230    /// and staging, so the frame shows the app's real answer to that input.
231    /// Must be deterministic, like the rest of the scenario.
232    ///
233    /// # Errors
234    /// Returns why the input could not be given (its target is missing).
235    fn scenario_input(
236        name: Option<&str>,
237        input: &mut ui_core::headless::FrameHarness,
238    ) -> Result<(), String> {
239        let _ = (name, input);
240        Ok(())
241    }
242
243    /// What a brand-new record starts with that depends on who creates it or
244    /// where: the creator as its owner, a first-run import of settings that
245    /// already exist on this device. Everything that depends on neither is
246    /// the resource's [`initialize`](Resource::initialize), which is the same
247    /// on every device so that two people creating the same record converge
248    /// on one.
249    ///
250    /// The actions apply once, right after the genesis, attributed to the
251    /// creator and recorded as content from outside: they sync like any
252    /// edit but are nobody's undo step, so a first Ctrl+Z does not take the
253    /// seed back. A collaborator who creates the same record concurrently
254    /// seeds it too, so a seed sets values rather than adding rows.
255    fn seed(cx: &SeedCx<'_>) -> Vec<Invoke<Self::Resource>> {
256        let _ = cx;
257        Vec::new()
258    }
259
260    /// The records the app keeps besides the one it shows: the person's own
261    /// records and the settings sections that are resource models. Each is
262    /// opened with the window and published beside the app's resource, so
263    /// the palette and agents change it whether or not a view has shown it,
264    /// and `tilo-app surface` commits its surface with the app's.
265    ///
266    /// ```ignore
267    /// fn records(records: &mut Records<'_>) {
268    ///     records.person::<WatchHistory>().settings::<Playback>();
269    /// }
270    /// ```
271    fn records(records: &mut super::Records<'_>) {
272        let _ = records;
273    }
274}
275
276/// The History scope of [`ResourceApp::FILE_HISTORY`]: the files of the
277/// mounted filesystem an app shows.
278#[derive(Clone, Copy)]
279pub struct FileHistory {
280    /// What the scope is called in History: "Selected file".
281    pub title: &'static str,
282    /// Draws one version of a file, from its bytes as the projection read
283    /// them.
284    pub preview: ui_core::history_mode::checked_files::FilePreviewFactory,
285}
286
287/// What [`ResourceApp::seed`] may read: who is creating the record, and the
288/// app's host — its settings, files and secrets on this device — when the
289/// record is created in a window. A participant without one (an agent, a
290/// room service: [`seed_record`](super::seed_record)) creates it with no
291/// host: what a seed imports from the device is the device's own to seed.
292pub struct SeedCx<'a> {
293    pub(crate) me: &'a str,
294    pub(crate) device: &'a str,
295    pub(crate) host: Option<&'a HostContext>,
296    pub(crate) room: Option<&'a str>,
297}
298
299impl SeedCx<'_> {
300    /// Who is creating the record: the id their views read as
301    /// [`RecordEnv::me`](super::RecordEnv::me) and the seed's actions read as their author.
302    pub fn me(&self) -> &str {
303        self.me
304    }
305
306    /// The device creating the record.
307    pub fn device(&self) -> &str {
308        self.device
309    }
310
311    /// The host-selected room of a new record, and its display name. An
312    /// ordinary restore has no creation scope. A windowless room participant
313    /// carries its explicit route without supplying device services or secrets.
314    pub fn creation_workspace(&self) -> Option<(&str, &str)> {
315        self.host
316            .and_then(HostContext::creation_workspace)
317            .map(|workspace| (workspace.id.as_str(), workspace.name.as_str()))
318            .or_else(|| self.room.map(|room| (room, room)))
319    }
320
321    /// The app's host on this device; `None` for a participant without a
322    /// window.
323    pub fn host(&self) -> Option<&HostContext> {
324        self.host
325    }
326}
327
328type Leaked = &'static (dyn std::any::Any + Send + Sync);
329
330pub(crate) fn leaked<T: Send + Sync + 'static>(
331    key: TypeId,
332    make: impl FnOnce() -> T,
333) -> &'static T {
334    static TABLE: OnceLock<Mutex<HashMap<TypeId, Leaked>>> = OnceLock::new();
335    let table = TABLE.get_or_init(Mutex::default);
336    let mut table = table
337        .lock()
338        .unwrap_or_else(std::sync::PoisonError::into_inner);
339    let entry = *table
340        .entry(key)
341        .or_insert_with(|| Box::leak(Box::new(make())) as Leaked);
342    drop(table);
343    // One value per (type, T) key is ever inserted, so the downcast holds.
344    entry
345        .downcast_ref::<T>()
346        .unwrap_or_else(|| unreachable_definition())
347}
348
349#[allow(
350    clippy::panic,
351    reason = "a keyed table entry of the wrong type is a framework bug"
352)]
353fn unreachable_definition() -> ! {
354    panic!("framework definition table holds a value of another type")
355}
356
357struct DefinitionKey<A>(std::marker::PhantomData<A>);
358
359/// The toolkit application definition of `A`, created once per process.
360pub fn definition<A: ResourceApp>() -> &'static ApplicationDefinition {
361    leaked(TypeId::of::<DefinitionKey<A>>(), || {
362        super::kinds::register::<A>();
363        ui_core::app_surface::register_kind(<A::Resource as Resource>::KIND, A::ID);
364        definition_of::<A>()
365    })
366}
367
368fn definition_of<A: ResourceApp>() -> ApplicationDefinition {
369    ApplicationDefinition {
370        app_id: A::ID,
371        title: A::TITLE,
372        icon: icon(hooks::<A>()),
373        record_kind: RecordKind::new(<A::Resource as Resource>::KIND),
374        required: vec![],
375        optional: vec![],
376        opens: A::OPENS,
377        room_record: A::ROOM_RECORD,
378        create: create::<A>,
379    }
380}
381
382fn icon(app: &AppHooks) -> IconDescriptor {
383    IconDescriptor::Named(
384        app.icon
385            .unwrap_or_else(|| app.id.rsplit('.').next().unwrap_or(app.id))
386            .to_owned(),
387    )
388}
389
390/// What the launch path, the window and History read of an app: its
391/// constants, and shims over the parts that need its types. One table per
392/// app; everything that reads it is compiled once for every app.
393pub(crate) struct AppHooks {
394    pub(crate) id: &'static str,
395    pub(crate) title: &'static str,
396    icon: Option<&'static str>,
397    /// [`Resource::KIND`] of the app's resource.
398    pub(crate) kind: &'static str,
399    /// [`Resource::LABEL`] of the app's resource.
400    label: &'static str,
401    behaviour: ComponentBehaviour,
402    document_history: bool,
403    file_history: Option<FileHistory>,
404    #[cfg(tilo_native_runner)]
405    private_windows: bool,
406    #[cfg(tilo_native_runner)]
407    options: &'static [&'static str],
408    #[cfg(tilo_native_runner)]
409    flags: &'static [&'static str],
410    catalog: fn() -> Option<&'static EmbeddedCatalog>,
411    definition: fn() -> &'static ApplicationDefinition,
412    launch_record: fn(&LaunchInput<'_>) -> Option<RecordId>,
413    local_operations: fn() -> Vec<tilo_actions::ActionSpec>,
414    declarations: fn() -> Rc<[tilo_resource::Declaration]>,
415    /// Initialize a record the launch created: the resource's own
416    /// initialization, the launch's genesis and the creator's seed.
417    new_record: NewRecord,
418    /// The capture's run, from the app's fixture.
419    prepare_capture: fn(&mut HostContext, Option<&str>) -> Result<Run, LaunchError>,
420    /// Read the record `context` opened, build the root over it and take
421    /// the launch's actions on it; or why the record does not read.
422    open: OpenRoot,
423    pub(crate) action_registry: fn() -> tilo_actions::ActionRegistry,
424    history: fn() -> &'static ui_core::history_mode::HistoryDefinition,
425    file_history_witness: fn() -> &'static ui_core::history_mode::HistoryDefinition,
426}
427
428/// [`AppHooks::new_record`]: the context, and what the launch named.
429type NewRecord = fn(
430    &mut HostContext,
431    &[String],
432    Option<&ui_core::application::OpenFile>,
433    &[ui_core::application::LaunchOption],
434) -> Result<(), LaunchError>;
435
436/// [`AppHooks::open`]: the context, who edits, whether it is a capture and
437/// whether the launch created the record.
438type OpenRoot = fn(
439    &mut HostContext,
440    Editing,
441    bool,
442    bool,
443) -> Result<Result<Box<dyn ui_core::widget::AppWidget>, String>, LaunchError>;
444
445struct Hooks<A>(std::marker::PhantomData<A>);
446
447impl<A: ResourceApp> Hooks<A> {
448    const HOOKS: AppHooks = AppHooks {
449        id: A::ID,
450        title: A::TITLE,
451        icon: A::ICON,
452        kind: <A::Resource as Resource>::KIND,
453        label: <A::Resource as Resource>::LABEL,
454        behaviour: A::BEHAVIOUR,
455        document_history: A::DOCUMENT_HISTORY,
456        file_history: A::FILE_HISTORY,
457        #[cfg(tilo_native_runner)]
458        private_windows: A::PRIVATE_WINDOWS,
459        #[cfg(tilo_native_runner)]
460        options: A::OPTIONS,
461        #[cfg(tilo_native_runner)]
462        flags: A::FLAGS,
463        catalog: A::catalog,
464        definition: definition::<A>,
465        launch_record: A::launch_record,
466        local_operations: A::local_operations,
467        declarations: declarations_of::<A::Resource>,
468        new_record: new_record::<A>,
469        prepare_capture: prepare_capture::<A>,
470        open: open_root::<A>,
471        action_registry: action_registry::<A>,
472        history: super::preview::history_definition::<A>,
473        file_history_witness: super::preview::file_history_witness::<A>,
474    };
475}
476
477/// `A`'s [`AppHooks`].
478pub(crate) fn hooks<A: ResourceApp>() -> &'static AppHooks {
479    &Hooks::<A>::HOOKS
480}
481
482fn declarations_of<R: Resource>() -> Rc<[tilo_resource::Declaration]> {
483    tilo_resource::vocabulary::<R>().declarations()
484}
485
486/// Who may edit the resource a root shows, and what it is built for.
487pub(crate) enum Editing {
488    /// The participant (`me`) and their permissions, followed while the app
489    /// runs, in a window someone uses or a capture.
490    Live {
491        permissions: LivePermissions,
492        me: String,
493        run: Run,
494    },
495    /// Never: a past version in History mode.
496    ReadOnly,
497}
498
499/// What a root is built with besides its resource type: who edits it and
500/// how, what it was built for, and where its settings are kept.
501struct RootShell {
502    /// The permission that makes the app's resource editable.
503    scope: PermissionScope,
504    permissions: Option<LivePermissions>,
505    read_only: bool,
506    me: Option<String>,
507    run: Run,
508    storage: Rc<super::settings::ViewStorage>,
509}
510
511impl RootShell {
512    fn new(
513        app: &AppHooks,
514        editing: Editing,
515        storage: AppStorage,
516        hosted: super::settings::Hosted,
517    ) -> Self {
518        let scope = PermissionScope::app(app.id, ActionKind::Undoable);
519        let (permissions, read_only, me, run) = match editing {
520            Editing::Live {
521                permissions,
522                me,
523                run,
524            } => {
525                let read_only = !permissions.check(&scope);
526                (Some(permissions), read_only, Some(me), run)
527            }
528            Editing::ReadOnly => (None, true, None, Run::past()),
529        };
530        let run = run.for_participant(me.as_deref());
531        let storage = super::settings::ViewStorage::new(storage, hosted.attach(app.id, app.title));
532        Self {
533            scope,
534            permissions,
535            read_only,
536            me,
537            run,
538            storage,
539        }
540    }
541
542    /// Whether effects and projection drains run: in a window someone uses
543    /// only. History shows a past version, and a capture reaches nothing
544    /// outside.
545    fn runs_effects(&self) -> bool {
546        self.permissions.is_some() && self.run.mode.is_live()
547    }
548}
549
550/// Build the framework root for a resource state: the live handle, the
551/// dispatcher, the published resource and the app's view.
552pub(crate) fn build_root<A: ResourceApp>(
553    reader: Reader<A::Resource>,
554    editing: Editing,
555    storage: AppStorage,
556    hosted: super::settings::Hosted,
557) -> ResourceRoot {
558    let app = hooks::<A>();
559    // History names this app's fields by their titles.
560    if let Some(shape) = <A::Resource as Resource>::shape() {
561        ui_core::history_source::sentence::learn_field_titles(&shape);
562    }
563    let shell = RootShell::new(app, editing, storage, hosted);
564    let runs_effects = shell.runs_effects();
565    let RootShell {
566        scope,
567        permissions,
568        read_only,
569        me,
570        run,
571        storage,
572    } = shell;
573    let label = A::label(reader.state());
574    let published = tilo_resource::publish::<A::Resource>(app.kind, &label, reader.state());
575    let live = Live::new(reader);
576    let alive = Rc::new(());
577    let dispatch = Dispatcher::new(app.id, app.kind, read_only, me.as_deref());
578    let dispatch = if permissions.is_none() {
579        dispatch.past()
580    } else {
581        dispatch
582    };
583    // A window someone uses opens the records the app keeps beside its own,
584    // so they are published before any view shows them.
585    if permissions.is_some() {
586        A::records(&mut super::Records::new(&storage));
587    }
588    let effects = runs_effects.then(|| Effects::live(dispatch.clone(), live.clone()));
589    let mut cx = ViewCx::root(live, &alive, dispatch, published, run, storage);
590    if let Some(effects) = effects {
591        cx.env.effects = effects;
592    }
593    let content = A::view(&mut cx);
594    let live = permissions.is_some();
595    let root = ResourceRoot::new(app.title, icon(app), label, cx, alive, content)
596        .following(permissions, scope)
597        .with_presentation((app.local_operations)());
598    if !app.document_history {
599        return root.without_history();
600    }
601    if live {
602        #[cfg(not(target_family = "wasm"))]
603        return root.drawing_past(super::preview::draw_past::<A>);
604    }
605    root
606}
607
608/// The decoders of every edit `A`'s document takes: its resource's actions
609/// and History's restores.
610pub(crate) fn action_registry<A: ResourceApp>() -> tilo_actions::ActionRegistry {
611    let mut registry =
612        tilo_actions::ActionRegistry::with(tilo_resource::group::register::<A::Resource>);
613    registry
614        .register::<super::history::ResourceHistoryEdit<A::Resource>>(super::history::EDIT_TYPE);
615    registry
616}
617
618/// The actions `invokes` build at `now_ms`, groups expanded; or the first
619/// refusal.
620fn built_actions<R: Resource>(
621    invokes: &[Invoke<R>],
622    now_ms: i64,
623) -> Result<Vec<R::Action>, String> {
624    let mut actions = Vec::new();
625    for invoke in invokes {
626        if let Some(refusal) = invoke.refusal() {
627            return Err(refusal.to_owned());
628        }
629        if invoke.is_group() {
630            actions
631                .extend(tilo_resource::group::build_group::<R>(&invoke.arguments, now_ms)?.actions);
632        } else {
633            actions.push(invoke.build(now_ms)?);
634        }
635    }
636    Ok(actions)
637}
638
639/// Apply a new record's [`ResourceApp::seed`] through the replayable
640/// executor: attributed to `author` as their edits are, and recorded as
641/// content from outside, which no undo takes back.
642pub(crate) fn apply_seed<A: ResourceApp>(
643    doc: &mut tilo_resource::Doc,
644    author: &str,
645    seed: &[Invoke<A::Resource>],
646) -> Result<(), String> {
647    let actions = built_actions(seed, tilo_resource::origin_now_ms())?;
648    let encoded = actions
649        .iter()
650        .map(|action| tilo_resource::encode::<A::Resource>(action).map_err(|e| e.to_string()))
651        .collect::<Result<Vec<_>, _>>()?;
652    let actions: Vec<&dyn tilo_actions::Action> = actions
653        .iter()
654        .map(|action| action as &dyn tilo_actions::Action)
655        .collect();
656    apply_seed_actions(hooks::<A>(), doc, author, &actions, encoded)
657}
658
659/// [`apply_seed`] once the seed is built and encoded.
660fn apply_seed_actions(
661    app: &AppHooks,
662    doc: &mut tilo_resource::Doc,
663    author: &str,
664    actions: &[&dyn tilo_actions::Action],
665    encoded: Vec<tilo_actions::EncodedAction>,
666) -> Result<(), String> {
667    let writer = ui_core::history_source::authors::device_actor(author, doc.get_actor());
668    for (action, encoded) in actions.iter().zip(encoded) {
669        let context = tilo_actions::ActionContext::for_resource(app.kind, app.kind, app.label)
670            .with_app(app.id, app.title)
671            .with_invoker(tilo_actions::ActionInvoker::actor(author))
672            .with_change(tilo_actions::ChangeKind::External);
673        tilo_actions::apply_local_with_context(doc, *action, encoded, Some(&writer), context)
674            .map_err(|error| format!("seeding the new record failed: {error}"))?;
675    }
676    Ok(())
677}
678
679/// Write a launch's [`ResourceApp::genesis`] into the record's genesis:
680/// built at time zero and applied as the genesis writes everything, so every
681/// device that creates the record writes the same change.
682fn write_genesis<R: Resource>(
683    doc: &mut tilo_resource::Doc,
684    genesis: &[Invoke<R>],
685) -> Result<(), String> {
686    for action in &built_actions(genesis, 0)? {
687        tilo_actions::Action::apply(action, doc).map_err(|error| error.to_string())?;
688    }
689    Ok(())
690}
691
692/// Initialize a record a launch of `A` created: the resource's own
693/// initialization and the launch's [`ResourceApp::genesis`] in the
694/// genesis, then the creator's [`ResourceApp::seed`].
695fn new_record<A: ResourceApp>(
696    context: &mut HostContext,
697    arguments: &[String],
698    file: Option<&ui_core::application::OpenFile>,
699    options: &[ui_core::application::LaunchOption],
700) -> Result<(), LaunchError> {
701    let genesis = A::genesis(
702        &LaunchInput::new(arguments, file)
703            .with_options(options)
704            .with_content(context.content()),
705    );
706    let author = context.author().0.clone();
707    let seed = A::seed(&SeedCx {
708        me: &author,
709        device: context.device_id(),
710        host: Some(context),
711        room: None,
712    });
713    initialize_record(
714        context,
715        &mut |doc| {
716            <A::Resource as Resource>::initialize(doc).map_err(|error| error.to_string())?;
717            write_genesis(doc, &genesis)
718        },
719        &mut |doc| apply_seed::<A>(doc, &author, &seed),
720    )
721}
722
723/// What initializing a new record writes into it.
724type RecordWrite<'a> = &'a mut dyn FnMut(&mut tilo_resource::Doc) -> Result<(), String>;
725
726/// Initialize the new record `context` opened: `genesis` into its genesis,
727/// then `seed`. Not generic over the app, so the record store's
728/// initialization is compiled once.
729fn initialize_record(
730    context: &mut HostContext,
731    genesis: RecordWrite<'_>,
732    seed: RecordWrite<'_>,
733) -> Result<(), LaunchError> {
734    context
735        .initialize_new_record_seeded(|doc| genesis(doc), |doc| seed(doc))
736        .map_err(LaunchError::Record)
737}
738
739/// Bring an older record up to the resource's storage version; what it
740/// answers when it cannot. Only an older document is written to: opening
741/// another view of a current record must not commit what one already open
742/// has pending.
743fn migrate_older(
744    context: &HostContext,
745    version: u32,
746    migrate: &mut dyn FnMut(&mut tilo_resource::Doc) -> Result<bool, String>,
747) -> Result<Result<(), String>, LaunchError> {
748    let stored = context
749        .read_record(tilo_resource::evolution::stored_version)
750        .map_err(LaunchError::Record)?;
751    Ok(if stored < version {
752        context.migrate_record(|doc| migrate(doc)).map(drop)
753    } else {
754        Ok(())
755    })
756}
757
758/// Read the record `context` opened as `A`'s resource, once an older
759/// document is brought up to the app's storage version, build its root and
760/// take the launch's actions ([`ResourceApp::launch`]) on it; `created`
761/// when the launch created the record. Or why it does not read, which the
762/// window shows instead of the view.
763///
764/// A capture launched on nothing is staged by its scenario alone: what a
765/// launch on nothing takes reads from this device (a library, the last
766/// folder), which a screenshot must not depend on. A capture launched on a
767/// file takes that file, as the command line does.
768fn open_root<A: ResourceApp>(
769    context: &mut HostContext,
770    editing: Editing,
771    capture: bool,
772    created: bool,
773) -> Result<Result<Box<dyn ui_core::widget::AppWidget>, String>, LaunchError> {
774    let migrated = migrate_older(context, <A::Resource as Resource>::VERSION, &mut |doc| {
775        tilo_resource::evolution::migrate::<A::Resource>(doc).map_err(|error| error.to_string())
776    })?;
777    let read = context
778        .read_record(|doc| {
779            Reader::<A::Resource>::read(doc)
780                .map_err(|error| tilo_resource::Unreadable::of::<A::Resource>(doc, &error).reason)
781        })
782        .map_err(LaunchError::Record)?;
783    let reader = match migrated.and(read) {
784        Ok(reader) => reader,
785        Err(reason) => return Ok(Err(reason)),
786    };
787    let root = build_root::<A>(reader, editing, context.storage(), hosted(context));
788    if let Some(typed) = root.typed::<A::Resource>() {
789        let record = typed.live.get();
790        let launch = root.launch().with_record(&*record);
791        if !capture || !launch.is_empty() {
792            for invoke in A::launch(&launch, created) {
793                typed.dispatch.submit(invoke);
794            }
795        }
796    }
797    Ok(Ok(root.into_widget()))
798}
799
800/// What a window's root keeps of the person and the device.
801fn hosted(context: &HostContext) -> super::settings::Hosted {
802    super::settings::Hosted {
803        // A private window keeps nothing of the person's: their own
804        // records are not even opened.
805        person: (!context.is_private())
806            .then(|| context.person_records().cloned())
807            .flatten(),
808        device: Some(context.device_id().to_owned()),
809    }
810}
811
812/// The window of a record that does not read as the app's resource.
813fn unopened(app: &AppHooks, context: HostContext, reason: String) -> AppComponent {
814    let unopened = super::root::Unopened::new(
815        app.title,
816        icon(app),
817        (app.kind, app.label),
818        &(app.declarations)(),
819        reason,
820    );
821    let mut component = AppComponent::new(context, unopened.boxed(), (app.action_registry)());
822    component.set_behaviour(app.behaviour);
823    component
824}
825
826#[allow(
827    clippy::needless_pass_by_value,
828    reason = "the toolkit's ComponentFactory signature passes the target owned"
829)]
830fn create<A: ResourceApp>(
831    context: HostContext,
832    target: LaunchTarget,
833    _restore: RestoreInput,
834) -> Result<AppComponent, LaunchError> {
835    create_component(hooks::<A>(), context, &target)
836}
837
838/// [`create`] of the app `app` describes.
839fn create_component(
840    app: &'static AppHooks,
841    mut context: HostContext,
842    target: &LaunchTarget,
843) -> Result<AppComponent, LaunchError> {
844    if let Some(catalog) = (app.catalog)() {
845        catalog.ensure();
846    }
847    // A record of its own, or the room's record an app keeps one of
848    // (`ResourceApp::ROOM_RECORD`), opened for the first time.
849    let created = matches!(target, LaunchTarget::New | LaunchTarget::PrimaryNew)
850        || context.creation_workspace().is_some();
851    record_content(&mut context);
852    let file = context.take_open_file();
853    let arguments = context.take_launch_arguments();
854    let options = context.take_launch_options();
855    if created {
856        (app.new_record)(&mut context, &arguments, file.as_ref(), &options)?;
857    }
858    let capture = context.capture().is_some();
859    let file_history = app.file_history.map(|_| FileHistorySource::default());
860    // A window someone uses can open another record in its place; a
861    // capture shows the one it staged.
862    let opener = (!capture).then(|| {
863        let reopener = context.reopener();
864        super::launch::OpenHere::new(move |arguments| open_in_place(app, &reopener, arguments))
865    });
866    let run = match context.capture().cloned() {
867        Some(capture) => (app.prepare_capture)(&mut context, capture.scenario())?,
868        None => Run::live(context.content().cloned()).in_private(context.is_private()),
869    };
870    if let Some(file) = &file {
871        file.report_opened(app.kind);
872    }
873    let run = run
874        .launched(Launched {
875            record: Some(context.record().id.clone()),
876            arguments,
877            options,
878            file,
879            handoff: context.take_handoff(),
880            lent: super::scenario::Lent::new(context.take_lent()),
881        })
882        .opening_with(opener)
883        .showing_file_history(
884            file_history
885                .clone()
886                .map(|source| (source, (app.file_history_witness)())),
887        );
888    let permissions = context.live_permissions();
889    let me = context.author().0.clone();
890    let editing = Editing::Live {
891        permissions,
892        me,
893        run,
894    };
895    let root = match (app.open)(&mut context, editing, capture, created)? {
896        Ok(root) => root,
897        Err(reason) => return Ok(unopened(app, context, reason)),
898    };
899    let mut component = AppComponent::new(context, root, (app.action_registry)());
900    component.set_behaviour(app.behaviour);
901    if app.document_history {
902        let history = (app.history)();
903        match file_history {
904            Some(files) => component.register_document_and_file_history(history, files),
905            None => component.register_document_history(history),
906        }
907        .map_err(|error| LaunchError::Operation(error.to_string()))?;
908    }
909    Ok(component)
910}
911
912/// Join the content store the host lent a window to its record's route:
913/// content the record references and this device lacks is asked of the
914/// route, and content the app stores reaches the record's other readers.
915/// A capture keeps its fixture's store as it is: it reaches nothing outside.
916fn record_content(context: &mut HostContext) {
917    if context.capture().is_some() {
918        return;
919    }
920    let Some(local) = context.content().cloned() else {
921        return;
922    };
923    let lane = ui_core::collaboration::record_content::ContentLane::of(
924        context.app_id(),
925        context.record().id.as_str(),
926    );
927    context
928        .provide_content(ui_core::collaboration::record_content::RecordContent::store(local, lane));
929}
930
931/// Prepare a capture's scene before its view is built: the app's fixture,
932/// then the versions other people wrote, merged as their changes arrive.
933fn prepare_capture<A: ResourceApp>(
934    context: &mut HostContext,
935    scenario: Option<&str>,
936) -> Result<Run, LaunchError> {
937    let mut fixture = Fixture::<A::Resource>::new(scenario, context.content().cloned());
938    A::fixture(&mut fixture);
939    if context.content().is_none()
940        && let Some(content) = fixture.lent_content()
941    {
942        context.provide_content(content.clone());
943    }
944    if fixture.has_history() {
945        merge_peer(context, &mut |peer| {
946            fixture.write_history(peer, &action_registry::<A>())
947        })?;
948    }
949    Ok(fixture.into_run())
950}
951
952/// Merge into the record `context` opened what `write` writes into a fork
953/// of it, as another participant's changes arrive.
954fn merge_peer(
955    context: &HostContext,
956    write: &mut dyn FnMut(&mut tilo_resource::Doc) -> Result<(), String>,
957) -> Result<(), LaunchError> {
958    let mut peer = context
959        .read_record(|doc| doc.clone().fork())
960        .map_err(LaunchError::Record)?;
961    write(&mut peer).map_err(LaunchError::Operation)?;
962    context
963        .merge_peer_document(peer.save())
964        .map_err(LaunchError::Operation)?;
965    Ok(())
966}
967
968#[cfg(not(target_arch = "wasm32"))]
969const CAPTURE_INSTANCE: u64 = 1;
970#[cfg(not(target_arch = "wasm32"))]
971pub(crate) const DATA_DIR_INSTANCE: u64 = 0;
972
973#[cfg(not(target_arch = "wasm32"))]
974/// The host of an explicit `--data-dir` profile: one personal workspace in
975/// that directory. Tools that seed or inspect such a profile open it the same
976/// way the app does.
977pub(crate) fn data_dir_host(
978    directory: &std::path::Path,
979    author: &UserId,
980    permissions: Rc<RefCell<PermissionContext>>,
981) -> Result<ApplicationHost, LaunchError> {
982    let root = std::path::absolute(directory).map_err(operation)?;
983    ApplicationHost::new(
984        root,
985        "local-device".into(),
986        "personal",
987        author.clone(),
988        permissions,
989        tilo_actions::ActionContext::default(),
990    )
991}
992
993#[cfg(not(target_arch = "wasm32"))]
994/// The permissions of `author` holding `role`, shared with the host so a
995/// later role change reaches the running app.
996pub(crate) fn role_permissions(author: &str, role: Role) -> Rc<RefCell<PermissionContext>> {
997    Rc::new(RefCell::new(PermissionContext::member(author, role)))
998}
999
1000#[cfg(not(target_arch = "wasm32"))]
1001fn operation(error: impl std::fmt::Display) -> LaunchError {
1002    LaunchError::Operation(error.to_string())
1003}
1004
1005/// Prepare `definition` on `host`: the record a launch named, else the one
1006/// the instance showed last, else the room's default.
1007#[cfg(tilo_native_runner)]
1008pub(crate) fn prepare_on(
1009    host: &mut ApplicationHost,
1010    definition: &ApplicationDefinition,
1011    instance: u64,
1012    named: Option<RecordId>,
1013    inputs: ComponentRunnerInputs,
1014) -> Result<PreparedComponentInputs, LaunchError> {
1015    let kind = definition.record_kind.clone();
1016    match named {
1017        Some(id) => {
1018            host.prepare_record(definition, instance, RecordCheckout::new(id, kind), inputs)
1019        }
1020        None => host.prepare_default(
1021            definition,
1022            instance,
1023            RecordCheckout::new(RecordId::room_default(), kind),
1024            None,
1025            inputs,
1026        ),
1027    }
1028}
1029
1030/// Prepare `A` for a launch: the record its arguments name, and the
1031/// arguments handed to the component for [`ResourceApp::launch`]. A first
1032/// argument naming a file of a type the app [opens](ResourceApp::OPENS) is
1033/// also handed over as that file, as a desktop handing it the file would.
1034#[cfg(tilo_native_runner)]
1035fn prepare_app<A: ResourceApp>(
1036    definition: &ApplicationDefinition,
1037    args: &LaunchArgs,
1038) -> Result<PreparedComponentInputs, LaunchError> {
1039    prepare_hooked(hooks::<A>(), definition, args)
1040}
1041
1042/// [`prepare_app`] of the app `app` describes.
1043#[cfg(tilo_native_runner)]
1044fn prepare_hooked(
1045    app: &AppHooks,
1046    definition: &ApplicationDefinition,
1047    args: &LaunchArgs,
1048) -> Result<PreparedComponentInputs, LaunchError> {
1049    let (record, arguments) = record_argument(args.positionals());
1050    let options = launch_options(app, args);
1051    let named = record
1052        .or_else(|| (app.launch_record)(&LaunchInput::new(arguments, None).with_options(&options)));
1053    let private = app.private_windows && args.has(PRIVATE_FLAG);
1054    let mut prepared = prepare_named(definition, args, named, private)?;
1055    hand_launch(definition, arguments, &mut prepared)?;
1056    prepared.context.provide_launch_options(options);
1057    // The session launches the target of a playback handoff with where it
1058    // stood in its environment; a capture continues nothing.
1059    if args.shots.is_none()
1060        && let Some(handoff) = process_handoff()
1061    {
1062        prepared.context.provide_handoff(handoff);
1063    }
1064    Ok(prepared)
1065}
1066
1067/// The playback handoff the session handed this process at its start.
1068#[cfg(tilo_native_runner)]
1069fn process_handoff() -> Option<ui_core::application::PlaybackHandoff> {
1070    let position_ms = std::env::var("TILO_MEDIA_HANDOFF_POSITION_MS")
1071        .ok()?
1072        .parse()
1073        .ok()?;
1074    Some(ui_core::application::PlaybackHandoff {
1075        position_ms,
1076        resume_playing: std::env::var("TILO_MEDIA_HANDOFF_RESUME_PLAYING")
1077            .is_ok_and(|value| value == "1"),
1078        title: std::env::var("TILO_MEDIA_HANDOFF_TITLE")
1079            .ok()
1080            .filter(|title| !title.trim().is_empty()),
1081    })
1082}
1083
1084/// The options of `A`'s own that `args` named, in declaration order.
1085#[cfg(tilo_native_runner)]
1086fn launch_options(app: &AppHooks, args: &LaunchArgs) -> Vec<ui_core::application::LaunchOption> {
1087    let options = app.options.iter().filter_map(|&name| {
1088        args.value(name)
1089            .map(|value| ui_core::application::LaunchOption {
1090                name: name.to_owned(),
1091                value: Some(value.to_owned()),
1092            })
1093    });
1094    let flags = app
1095        .flags
1096        .iter()
1097        .filter(|&&name| args.has(name))
1098        .map(|&name| ui_core::application::LaunchOption {
1099            name: name.to_owned(),
1100            value: None,
1101        });
1102    options.chain(flags).collect()
1103}
1104
1105/// Hand a prepared component what its launch named: the arguments, and the
1106/// file the first one names when the app opens its type and it is a path
1107/// rather than a URL.
1108///
1109/// # Errors
1110/// A file of a type the app opens that cannot be read.
1111#[cfg(tilo_native_runner)]
1112pub(crate) fn hand_launch(
1113    definition: &ApplicationDefinition,
1114    arguments: &[String],
1115    prepared: &mut PreparedComponentInputs,
1116) -> Result<(), LaunchError> {
1117    hand_launch_to(definition, arguments, &mut prepared.context)
1118}
1119
1120/// Hand `context` what a launch naming `arguments` names: the arguments,
1121/// and on a device with paths the file the first one names when the app
1122/// opens its type.
1123///
1124/// # Errors
1125/// A file of a type the app opens that cannot be read.
1126fn hand_launch_to(
1127    definition: &ApplicationDefinition,
1128    arguments: &[String],
1129    context: &mut HostContext,
1130) -> Result<(), LaunchError> {
1131    // A URL (`https://…`, `file://…`) is an argument for the app to resolve,
1132    // not a path on this device.
1133    #[cfg(tilo_native_runner)]
1134    if let Some(path) = arguments
1135        .first()
1136        .filter(|argument| !argument.contains("://"))
1137        .map(std::path::Path::new)
1138        && let Some(file) = launched_file(definition, path)?
1139    {
1140        context.provide_open_file(file);
1141    }
1142    #[cfg(not(tilo_native_runner))]
1143    let _ = definition;
1144    context.provide_launch_arguments(arguments.to_vec());
1145    Ok(())
1146}
1147
1148/// Open, in the window `reopener` belongs to, what a launch of `app` naming
1149/// `arguments` opens ([`ResourceApp::launch_record`], [`ResourceApp::launch`]):
1150/// the component built for that record takes the window, its own record and
1151/// history with it. The store's refusal of the record, an unreadable file
1152/// or the app's refusal to build leaves the window as it is, and is the
1153/// answer the returned outcome gives.
1154fn open_in_place(
1155    app: &'static AppHooks,
1156    reopener: &ui_core::application::Reopener,
1157    arguments: Vec<String>,
1158) -> ui_core::application::ReplacementOutcome {
1159    // Built when the host takes it: the record store is busy while the
1160    // asking view handles the event or operation that asked.
1161    reopener.replace_with(move |reopener| {
1162        let definition = (app.definition)();
1163        let record = (app.launch_record)(&LaunchInput::new(&arguments, None))
1164            .unwrap_or_else(RecordId::minted);
1165        let (mut context, target) = reopener.prepare(definition, record)?;
1166        hand_launch_to(definition, &arguments, &mut context)?;
1167        let component = definition.instantiate(context, target, RestoreInput::None)?;
1168        Ok(ui_core::application::ComponentReplacement::new(
1169            component, arguments,
1170        ))
1171    })
1172}
1173
1174/// The file at `path` to hand the component, when `definition` opens its
1175/// type; `None` for any other argument (a URL, a folder, another type).
1176///
1177/// # Errors
1178/// A file of a type the app opens that cannot be read.
1179#[cfg(tilo_native_runner)]
1180fn launched_file(
1181    definition: &ApplicationDefinition,
1182    path: &std::path::Path,
1183) -> Result<Option<ui_core::application::OpenFile>, LaunchError> {
1184    // A regular file whose name says nothing of its type is plain bytes.
1185    let Some(media_type) = ui_core::dnd::mime_for_path(path)
1186        .or_else(|| path.is_file().then_some("application/octet-stream"))
1187        .filter(|media_type| definition.opens_media_type(media_type))
1188    else {
1189        return Ok(None);
1190    };
1191    let name = path.file_name().map_or_else(
1192        || path.display().to_string(),
1193        |name| name.to_string_lossy().into_owned(),
1194    );
1195    let unreadable = |error: String| {
1196        LaunchError::Operation(format!("{} cannot be read: {error}", path.display()))
1197    };
1198    let bytes = tilo_files::file_at(path)
1199        .and_then(|(dir, file)| tilo_files::blocking::read(&dir, &file))
1200        .map_err(|error| unreadable(error.to_string()))?;
1201    let (file, _receipt) =
1202        ui_core::application::OpenFile::new(&name, media_type, bytes).map_err(unreadable)?;
1203    Ok(Some(file))
1204}
1205
1206#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
1207fn prepare_definition(
1208    definition: &ApplicationDefinition,
1209    args: &LaunchArgs,
1210) -> Result<PreparedComponentInputs, LaunchError> {
1211    let (record, _) = record_argument(args.positionals());
1212    prepare_named(definition, args, record, false)
1213}
1214
1215/// The record a launch names with a first argument
1216/// `tilo-record:<id>` ([`RecordId::uri`]), as the session launches an app to
1217/// run an action on one of its records, and the arguments after it.
1218#[cfg(tilo_native_runner)]
1219fn record_argument(arguments: &[String]) -> (Option<RecordId>, &[String]) {
1220    match arguments.split_first() {
1221        Some((first, rest)) => match RecordId::from_uri(first) {
1222            Some(record) => (Some(record), rest),
1223            None => (None, arguments),
1224        },
1225        None => (None, arguments),
1226    }
1227}
1228
1229#[cfg(all(test, tilo_native_runner))]
1230mod record_argument_tests {
1231    use super::*;
1232
1233    #[test]
1234    fn a_record_uri_names_the_record_and_is_not_handed_to_the_app() {
1235        let record = RecordId::minted();
1236        let arguments = vec![record.uri(), "--then".to_owned()];
1237        let (named, rest) = record_argument(&arguments);
1238        assert_eq!(named, Some(record));
1239        assert_eq!(rest, ["--then".to_owned()]);
1240
1241        let plain = vec!["notes.md".to_owned()];
1242        assert_eq!(record_argument(&plain), (None, plain.as_slice()));
1243        assert_eq!(record_argument(&[]), (None, [].as_slice()));
1244    }
1245}
1246
1247#[cfg(tilo_native_runner)]
1248fn prepare_named(
1249    definition: &ApplicationDefinition,
1250    args: &LaunchArgs,
1251    named: Option<RecordId>,
1252    private: bool,
1253) -> Result<PreparedComponentInputs, LaunchError> {
1254    let inputs = ComponentRunnerInputs::from_native_process();
1255    // Captures, audits, headless driving and private windows never touch the
1256    // user's profile: they run on a private one removed with the process,
1257    // unless a headless drive names its profile with `--data-dir`.
1258    let isolated = private
1259        || args.shots.is_some()
1260        || args.responsive_matrix.is_some()
1261        || args.native_audit
1262        || (args.headless.is_some() && args.value("--data-dir").is_none());
1263    if isolated {
1264        let profile = tempfile::Builder::new()
1265            .prefix("tilo-capture-")
1266            .tempdir()
1267            .map_err(operation)?;
1268        let root = profile.path().join("state");
1269        let profile_root = profile.path().to_path_buf();
1270        let content = profile_content(profile.path());
1271        let mut host = ApplicationHost::new_with_profile_lifetime(
1272            root,
1273            "capture-device".into(),
1274            "capture-workspace",
1275            UserId("capture-author".into()),
1276            role_permissions("capture-author", Role::Owner),
1277            tilo_actions::ActionContext::default(),
1278            Rc::new(profile),
1279        )?;
1280        let mut prepared = prepare_on(&mut host, definition, CAPTURE_INSTANCE, named, inputs)?;
1281        prepared.context.provide_content(content);
1282        // An audit and a headless drive certify and drive the app someone
1283        // uses; only a screenshot is built from fixtures.
1284        if args.shots.is_some() || args.responsive_matrix.is_some() {
1285            prepared
1286                .context
1287                .provide_capture(Capture::new(args.scenario.clone()));
1288        }
1289        if private {
1290            prepared.context.provide_private();
1291        }
1292        return Ok(super::testing::in_profile(
1293            prepared,
1294            &profile_root,
1295            definition.app_id,
1296        ));
1297    }
1298    if let Some(directory) = args.value("--data-dir") {
1299        let author = ui_core::collaboration::local_user_id();
1300        let mut host = data_dir_host(
1301            std::path::Path::new(directory),
1302            &author,
1303            role_permissions(&author.0, Role::Owner),
1304        )?;
1305        return prepare_on(&mut host, definition, DATA_DIR_INSTANCE, named, inputs).map(
1306            |mut prepared| {
1307                prepared
1308                    .context
1309                    .provide_content(super::testing::data_dir_content(directory.as_ref()));
1310                super::testing::in_profile(
1311                    prepared,
1312                    &super::testing::data_dir_root(directory.as_ref()),
1313                    definition.app_id,
1314                )
1315            },
1316        );
1317    }
1318    let mut native = NativeProcessHost::open(definition)?;
1319    let mut prepared = native.prepare_launch(definition, named, inputs)?;
1320    prepared
1321        .context
1322        .provide_content(tilo_content::ContentStore::for_app());
1323    Ok(prepared)
1324}
1325
1326/// The content store of a profile the launch keeps in `directory`: the
1327/// bytes its records name live with them, and go with them.
1328#[cfg(tilo_native_runner)]
1329pub(crate) fn profile_content(directory: &std::path::Path) -> tilo_content::ContentStore {
1330    tilo_content::ContentStore::in_dir(directory.join("content"))
1331}
1332
1333/// Take the launch's actions ([`ResourceApp::launch`]) as the live host's
1334/// first idle tick does, before anything else drives the harness.
1335#[cfg(tilo_native_runner)]
1336pub(crate) fn settle_launch(harness: &mut FrameHarness) {
1337    harness.wait_until(ui_core::time::Duration::ZERO, |_| true);
1338}
1339
1340#[cfg(not(target_arch = "wasm32"))]
1341fn capture<A: ResourceApp>(
1342    harness: &mut FrameHarness,
1343    args: &LaunchArgs,
1344) -> Result<(), LaunchError> {
1345    settle_launch(harness);
1346    super::stage::stage::<A>(harness, args.scenario.as_deref())
1347}
1348
1349#[cfg(not(target_arch = "wasm32"))]
1350fn shot_viewport<A: ResourceApp>(args: &LaunchArgs) -> (u32, u32) {
1351    A::shot_viewport(args.scenario.as_deref())
1352}
1353
1354#[cfg(tilo_native_runner)]
1355struct LiveOptionsKey<A>(std::marker::PhantomData<A>);
1356
1357/// The options a live launch of `A` takes: its profile, its commands and its
1358/// own options.
1359#[cfg(tilo_native_runner)]
1360fn live_options<A: ResourceApp>() -> &'static [&'static str] {
1361    leaked(TypeId::of::<LiveOptionsKey<A>>(), || {
1362        let mut options = vec!["--data-dir"];
1363        options.extend(A::commands().iter().map(|command| command.option));
1364        options.extend(A::OPTIONS);
1365        options
1366    })
1367    .as_slice()
1368}
1369
1370#[cfg(tilo_native_runner)]
1371struct LiveFlagsKey<A>(std::marker::PhantomData<A>);
1372
1373/// The switches a live launch of `A` takes: its own, and `--private` when it
1374/// opens private windows.
1375#[cfg(tilo_native_runner)]
1376fn live_flags<A: ResourceApp>() -> &'static [&'static str] {
1377    leaked(TypeId::of::<LiveFlagsKey<A>>(), || {
1378        let mut flags = A::FLAGS.to_vec();
1379        if A::PRIVATE_WINDOWS {
1380            flags.push(PRIVATE_FLAG);
1381        }
1382        flags
1383    })
1384    .as_slice()
1385}
1386
1387/// Run the command a launch of `A` names, if it names one.
1388#[cfg(tilo_native_runner)]
1389fn run_command<A: ResourceApp>(args: &LaunchArgs) -> Result<bool, LaunchError> {
1390    for command in A::commands() {
1391        if let Some(value) = args.value(command.option) {
1392            (command.run)(value)
1393                .map_err(|error| LaunchError::Operation(format!("{}: {error}", command.option)))?;
1394            return Ok(true);
1395        }
1396    }
1397    Ok(false)
1398}
1399
1400/// The flag that opens a private window of an app that offers them.
1401#[cfg(tilo_native_runner)]
1402const PRIVATE_FLAG: &str = "--private";
1403
1404#[cfg(tilo_native_runner)]
1405/// The launch specification of `A`, for apps that add options to it.
1406pub fn launch_spec<A: ResourceApp>() -> LaunchSpec {
1407    let spec = LaunchSpec::from_definition(definition::<A>())
1408        .component_host(prepare_app::<A>)
1409        .native_services()
1410        .capture_scenario(capture::<A>)
1411        .shot_viewport(shot_viewport::<A>)
1412        .scenarios(A::scenarios())
1413        .max_positionals(A::ARGUMENTS)
1414        .command_handler(run_command::<A>)
1415        .allowed_options(&["--data-dir"])
1416        .allowed_live_options(live_options::<A>())
1417        .allowed_live_flags(live_flags::<A>());
1418    let spec = if !A::ATTACHES_RUNTIME {
1419        spec.runtime_attachment(|_| false)
1420    } else if A::PRIVATE_WINDOWS {
1421        // A private window is nobody's runtime participant: it neither syncs
1422        // nor shares what it shows.
1423        spec.runtime_attachment(|args| !args.has(PRIVATE_FLAG))
1424    } else {
1425        spec
1426    };
1427    // An input surface types into other apps' fields: nothing drives it
1428    // without a person. It is a session service the compositor starts
1429    // without a room, so it has no workspace runtime to attach to.
1430    if A::BEHAVIOUR.window == ui_core::application::WindowRole::InputSurface {
1431        spec.disable_headless().runtime_attachment(|_| false)
1432    } else {
1433        spec
1434    }
1435}
1436
1437#[cfg(not(tilo_native_runner))]
1438/// Named for the same apps that build a native [`LaunchSpec`]; the browser
1439/// host mounts [`definition`] itself and never runs this path.
1440pub fn launch_spec<A: ResourceApp>() -> crate::launch::LaunchSpec {
1441    crate::launch::LaunchSpec::from_definition(definition::<A>())
1442}
1443
1444#[cfg(feature = "native")]
1445/// The launch specification of any application definition: the standard
1446/// component host, record and launch modes, without a resource type's
1447/// capture scenarios.
1448#[cfg(not(target_arch = "wasm32"))]
1449pub fn definition_launch_spec(definition: &'static ApplicationDefinition) -> LaunchSpec {
1450    LaunchSpec::from_definition(definition)
1451        .component_host(prepare_definition)
1452        .native_services()
1453        .allowed_options(&["--data-dir"])
1454        .allowed_live_options(&["--data-dir"])
1455}
1456
1457/// Run `A`: the live window, or `--shots`, `--headless` and the other
1458/// standard launch modes. Needs the native runner (the default `native`
1459/// feature); a host without it drives [`launch_spec`] itself.
1460#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
1461pub fn run<A: ResourceApp>() {
1462    let args: Vec<String> = std::env::args().collect();
1463    if args.iter().skip(1).any(|arg| arg == SURFACE_FLAG) {
1464        let code = match serde_json::to_string_pretty(&super::testing::surface_report::<A>()) {
1465            Ok(report) => {
1466                println!("{report}");
1467                0
1468            }
1469            Err(error) => {
1470                eprintln!("Error: {error}");
1471                1
1472            }
1473        };
1474        #[allow(
1475            clippy::exit,
1476            reason = "the surface report is the whole run of the binary"
1477        )]
1478        std::process::exit(code);
1479    }
1480    if let Some(code) = run_helper::<A>(&args) {
1481        #[allow(
1482            clippy::exit,
1483            reason = "a helper the binary is ends the process with its own exit code"
1484        )]
1485        std::process::exit(code);
1486    }
1487    crate::launch::run_or_exit(&launch_spec::<A>());
1488}
1489
1490/// The flag that makes an app's binary print its
1491/// [`surface_report`](super::testing::surface_report)
1492/// instead of running: what `tilo-app surface` and `tilo-app check` read.
1493pub const SURFACE_FLAG: &str = "--surface";
1494
1495/// Run the first of `A`'s [helpers](ResourceApp::helpers) that `args` is
1496/// for, answering the exit code the process ends with; `None` when the
1497/// command line is the app's own.
1498pub fn run_helper<A: ResourceApp>(args: &[String]) -> Option<i32> {
1499    for helper in A::helpers() {
1500        match (helper.run)(args) {
1501            Ok(None) => {}
1502            Ok(Some(code)) => return Some(code),
1503            Err(error) => {
1504                eprintln!("Error: {error}");
1505                return Some(1);
1506            }
1507        }
1508    }
1509    None
1510}