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::{Arc, Mutex, OnceLock};
7
8use tilo_resource::{Invoke, Reader, Resource};
9use ui_core::application::{
10    AppComponent, ApplicationDefinition, ComponentBehaviour, ComponentFactory, HostContext,
11    LaunchTarget, 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    ui_core::application::{
26        ApplicationHost, Capture, ComponentRunnerInputs, NativeProcessHost, PreparedComponentInputs,
27    },
28    ui_core::headless::FrameHarness,
29    ui_core::launch::{LaunchArgs, LaunchSpec},
30    ui_core::permissions::{PermissionContext, Role, UserId},
31    ui_core::record::RecordCheckout,
32};
33
34use super::dispatch::Dispatcher;
35use super::effect::Effects;
36use super::launch::{Command, Helper, LaunchInput};
37use super::live::Live;
38use super::root::ResourceRoot;
39use super::scenario::{Fixture, Launched, Run};
40use super::view::ViewCx;
41use tilo_lock::MutexRecover as _;
42
43/// The id, title and icon of an app named at launch ([`ResourceApp::named`]).
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct AppName {
46    pub id: &'static str,
47    pub title: &'static str,
48    pub icon: Option<&'static str>,
49}
50
51/// A framework app: one resource type and a view of it.
52///
53/// Everything else — storage, sync, collaboration, history, undo,
54/// permissions, the action palette, the agent and script door, search — is
55/// provided. See the [module docs](super).
56pub trait ResourceApp: 'static {
57    /// The resource this app edits.
58    type Resource: Resource;
59    /// The application id, e.g. `"so.tilo.scratchpad"`.
60    const ID: &'static str;
61    /// The window and launcher title.
62    const TITLE: &'static str;
63    /// A named icon; defaults to the app id's last segment.
64    const ICON: Option<&'static str> = None;
65    /// How every window hosting the app treats it: focus after a rebuild,
66    /// live appearance, and whether captures withhold it. An app that shows
67    /// secrets declares `capture_protected` here, so the live launch, the
68    /// shell and every other host of its component protect it alike; an
69    /// on-screen keyboard declares its window an
70    /// [input surface](ui_core::application::WindowRole::InputSurface).
71    const BEHAVIOUR: ComponentBehaviour = ComponentBehaviour::DEFAULT;
72    /// Media types (`type/subtype`, lowercase) of the files the app opens
73    /// when a host hands it one (a file association, a file dropped on its
74    /// icon, a path of that type on its command line); [`Self::launch`]
75    /// receives the file. `"*/*"`
76    /// ([`ANY_MEDIA_TYPE`](ui_core::application::ANY_MEDIA_TYPE)) opens every
77    /// file, a file whose name says nothing of its type as
78    /// `application/octet-stream`.
79    const OPENS: &'static [&'static str] = &[];
80    /// How many arguments (paths, URLs) a launch may name;
81    /// [`Self::launch_record`] and [`Self::launch`] receive them.
82    const ARGUMENTS: usize = 0;
83    /// Whether the resource keeps document state whose versions History
84    /// shows. An app whose state lives outside its document (a measurement,
85    /// a device) sets `false`: it publishes no History actions and its
86    /// window offers no History.
87    const DOCUMENT_HISTORY: bool = true;
88    /// The app brings what its windows kept before it kept one record per
89    /// workspace into that record in its own seed
90    /// ([`SeedCx::window_records`]), as Notes copies its pages by id. Every
91    /// app keeps one record per workspace, which every window of it there
92    /// opens and the first one creates; without this the framework copies
93    /// the earlier windows' records into it value by value
94    /// (`adopt_windows`).
95    const WORKSPACE_RECORD: bool = false;
96    /// Each window shows a resource of its own: a document an editor opened
97    /// (Word Processor, Code Editor, Sheets), a terminal session. A window
98    /// that names none starts a new one, and two windows show two of them
99    /// (or one document together, when both open it); no window mirrors
100    /// another. The documents earlier windows started stay in the workspace:
101    /// the app lists them with [`RecordEnv::window_documents`](super::RecordEnv::window_documents)
102    /// and opens one in a window with [`RecordEnv::open_here`](super::RecordEnv::open_here).
103    /// Without it the app's record is its library and settings,
104    /// one per workspace, which every window of the app there shows.
105    const WINDOW_RESOURCE: bool = false;
106    /// The app's own command-line options that take a value
107    /// (`--purpose number` or `--purpose=number`): how this window runs
108    /// rather than what it opens. [`Self::launch`] reads them from its
109    /// [`LaunchInput::option`], views and surfaces from
110    /// [`RecordEnv::launch_option`](super::RecordEnv::launch_option).
111    const OPTIONS: &'static [&'static str] = &[];
112    /// The app's own switches, which take no value (`--debug-hit-areas`):
113    /// [`LaunchInput::flag`], [`RecordEnv::launch_flag`](super::RecordEnv::launch_flag).
114    const FLAGS: &'static [&'static str] = &[];
115    /// Whether the app opens private windows (`--private`): a window on a
116    /// profile of its own that is thrown away with the process — its record,
117    /// settings and files never reach the person's — and that attaches to
118    /// no runtime, so nothing it shows syncs or is shared. It holds none of
119    /// the person's own records ([`RecordEnv::person`](super::RecordEnv::person) answers `None`), and its
120    /// view tells with [`RecordEnv::is_private`](super::RecordEnv::is_private).
121    const PRIVATE_WINDOWS: bool = false;
122    /// The history of a file of the mounted filesystem the app shows (the
123    /// file selected in a file manager), a History scope beside the
124    /// resource's own: its versions are read, previewed and restored through
125    /// the filesystem's projection, not the app's document. The app's
126    /// view names the file with [`RecordEnv::file_history`](super::RecordEnv::file_history).
127    const FILE_HISTORY: Option<FileHistory> = None;
128    /// What of the machine it runs on the app shows (its journal, its
129    /// processes, its drives, the person's real files). A headless drive
130    /// that captures it must then name one of its [scenarios](Self::scenarios)
131    /// and is built from that scenario's fixtures, so no picture of the app
132    /// shows the machine of whoever took it.
133    const READS_HOST: &'static [ui_core::launch::HostSource] = &[];
134
135    /// The app this process is, when only the launch knows it: a runtime that runs a package
136    /// (a tilolang app) is the package's app, with its id, title and icon, rather than
137    /// [`Self::ID`], [`Self::TITLE`] and [`Self::ICON`]. Asked once per process, before the
138    /// definition is first made.
139    fn named() -> Option<AppName> {
140        None
141    }
142
143    /// Whether each member of a workspace keeps the app's record as their own: a window that
144    /// names no record opens the person's own record of the workspace, which only their devices
145    /// read, instead of the workspace's record of the app
146    /// ([`ApplicationDefinition::member_record`]). Asked once per process, before the definition
147    /// is first made.
148    fn member_record() -> bool {
149        false
150    }
151
152    /// Import a former document format using host-local custody, before typed
153    /// projection or resource publication. Unlike schema evolution this may
154    /// depend on device services. A pending or failed import must leave the
155    /// document unchanged; returning an error withholds the resource. Reopening
156    /// retries the import, so retain acknowledged writes across pending retries.
157    fn import_record(context: &HostContext) -> Result<(), String> {
158        let _ = context;
159        Ok(())
160    }
161
162    /// Build the view. Called once for the live app, and again for every
163    /// historical version History mode shows (with a read-only context), so
164    /// it must build widgets and nothing else.
165    fn view(cx: &mut ViewCx<Self::Resource>) -> Element;
166
167    /// The window shows before the workspace's record is read: an app that keeps
168    /// what its first frame shows beside the record (a tilolang app's
169    /// index) opens a record this device holds off the frame, shows
170    /// [`Self::opening`] meanwhile, and builds [`Self::view`] once the record
171    /// is had. Only for an app that keeps one record per workspace.
172    const OPENS_BEFORE_RECORD: bool = false;
173
174    /// What the window shows while the workspace's record is read off the frame
175    /// ([`Self::OPENS_BEFORE_RECORD`]); `None` waits for the record. What it
176    /// returns is handed to [`Self::view`] once the record is had, as a
177    /// service lent to the window ([`RecordEnv::take_lent`](super::RecordEnv::take_lent)):
178    /// the view keeps showing it rather than building it again. A write it
179    /// makes before then waits for the record ([`OpeningCx::record`]).
180    fn opening(cx: &OpeningCx<'_>) -> Option<Box<dyn OpeningView>> {
181        let _ = cx;
182        None
183    }
184
185    /// The label of the open resource in the palette and in history.
186    fn label(state: &<Self::Resource as Resource>::State) -> String {
187        let _ = state;
188        <Self::Resource as Resource>::LABEL.to_owned()
189    }
190
191    /// View and effect operations of this app's presentation, published on
192    /// the same resource as [`Resource::local_operations`]. A shared resource
193    /// type (rich text) does not know about one app's chrome. Document
194    /// mutations do not belong here. History does not either: it is the
195    /// resource's own history, not a toggle beside it.
196    fn local_operations() -> Vec<tilo_actions::ActionSpec> {
197        Vec::new()
198    }
199
200    /// The record a launch naming `input` opens: the file's own record
201    /// ([`RecordId::identify_file`]), a record derived from it
202    /// ([`RecordId::derived`]), a minted one. `None`, the default, opens the
203    /// record the app would open anyway: the one the window showed last, else
204    /// the workspace's default. Asked by a launcher that picks the record (the
205    /// command line, a test host), before the record is opened, so it must
206    /// not depend on anything but `input`.
207    fn launch_record(input: &LaunchInput<'_>) -> Option<RecordId> {
208        let _ = input;
209        None
210    }
211
212    /// The actions a launch naming `input` takes once its record is open;
213    /// `created` when this launch created the record, so actions that fill
214    /// a new record (the source of a new project) are taken only then. They
215    /// go through the same door as every control: checked against the
216    /// participant's permissions, attributed, undoable and in history. A
217    /// launch that shows something rather than changing the resource (a
218    /// link to one item, a file to look at before acting on it) invokes one
219    /// of the app's effects ([`Invoke::effect`]) through the same door. A
220    /// capture launched on nothing takes none: its [scenario](Self::scenario)
221    /// stages the record.
222    ///
223    /// When the record was already there, [`LaunchInput::record`] reads it
224    /// as it is: a launch on a file can tell that the file changed since
225    /// the record last read or wrote it.
226    fn launch(input: &LaunchInput<'_>, created: bool) -> Vec<Invoke<Self::Resource>> {
227        let _ = (input, created);
228        Vec::new()
229    }
230
231    /// What a record a launch naming `input` creates starts with besides
232    /// the resource's [`initialize`](Resource::initialize): the content of
233    /// the file it was launched on. The actions are written into the
234    /// record's genesis, so they must depend on nothing but `input` — two
235    /// devices that each create the record of one file converge on one
236    /// document only if both write the same genesis. They are nobody's
237    /// edit: no history entry, no undo.
238    fn genesis(input: &LaunchInput<'_>) -> Vec<Invoke<Self::Resource>> {
239        let _ = input;
240        Vec::new()
241    }
242
243    /// The commands the app's binary runs instead of opening a window.
244    fn commands() -> &'static [Command] {
245        &[]
246    }
247
248    /// The helpers the app's binary also is ([`Helper`]), asked in order
249    /// before the app starts.
250    fn helpers() -> &'static [Helper] {
251        &[]
252    }
253
254    /// Whether the app takes part in the workspace runtime of the workspace it is
255    /// launched in. An app that only runs on this device (an imager on a
256    /// platform without the runtime) says no.
257    const ATTACHES_RUNTIME: bool = true;
258
259    /// Whether the session's document host keeps this app's record, so the
260    /// app's process opens none: its views read the readings the session
261    /// publishes and submit actions over its one connection
262    /// (`docs/architecture/apps.md` §8 step 2, `one-app-model.md` §4). An
263    /// app the host serves says yes and also
264    /// [attaches](Self::ATTACHES_RUNTIME) nothing: it joins no workspace of
265    /// its own and holds no key or transport.
266    ///
267    /// A launch the session did not make (a device with the runtime compiled
268    /// in, a capture, a test) keeps the record here, as before.
269    const HOSTED_BY_SESSION: bool = false;
270
271    /// The app's message catalog, loaded before its view is built in every
272    /// host that mounts the app, so the view's lookups find its messages.
273    fn catalog() -> Option<&'static EmbeddedCatalog> {
274        None
275    }
276
277    /// Named screenshot scenarios for `--shots --scenario NAME`; `--shots-dir`
278    /// captures every one.
279    fn scenarios() -> &'static [&'static str] {
280        &[]
281    }
282
283    /// The viewport a capture of `scenario` (`None` for a plain `--shots`)
284    /// is drawn at, unless `--viewport` names one: a narrow scenario's
285    /// phone-sized window.
286    fn shot_viewport(scenario: Option<&str>) -> (u32, u32) {
287        let _ = scenario;
288        (1280, 800)
289    }
290
291    /// Prepare what a capture of `fixture.scenario()` shows that its actions
292    /// cannot stage, before the view is built: typed stand-ins for live
293    /// sources, scratch files, content-store blobs, other people's versions
294    /// and collaborators. Runs only for a capture, whose views are built in
295    /// [`RunMode::Capture`](super::RunMode). See [`Fixture`](super::Fixture).
296    fn fixture(fixture: &mut super::Fixture<Self::Resource>) {
297        let _ = fixture;
298    }
299
300    /// The actions that stage the resource for a capture: `Some(name)` for
301    /// `--shots --scenario NAME`, `None` for a plain `--shots`. They apply
302    /// through the same door as every control; then the views stage their
303    /// view state ([`ViewCx::on_stage`], `ResourceSurface::stage`) and the
304    /// frame is captured. See [`stage`](super::stage).
305    fn scenario(name: Option<&str>) -> Vec<Invoke<Self::Resource>> {
306        let _ = name;
307        Vec::new()
308    }
309
310    /// What a person does in the scene that the scenario's actions and
311    /// staged view state cannot show: text typed into an editor, a drag
312    /// held over its drop target, a menu opened with a right click. Driven
313    /// on `input` through the window's own event path after the actions
314    /// and staging, so the frame shows the app's real answer to that input.
315    /// Must be deterministic, like the rest of the scenario.
316    fn scenario_input(
317        name: Option<&str>,
318        input: &mut ui_core::headless::FrameHarness,
319    ) -> Result<(), String> {
320        let _ = (name, input);
321        Ok(())
322    }
323
324    /// What a brand-new record starts with that depends on who creates it or
325    /// where: the creator as its owner, a first-run import of settings that
326    /// already exist on this device. Everything that depends on neither is
327    /// the resource's [`initialize`](Resource::initialize), which is the same
328    /// on every device so that two people creating the same record converge
329    /// on one.
330    ///
331    /// The actions apply once, right after the genesis, attributed to the
332    /// creator and recorded as content from outside: they sync like any
333    /// edit but are nobody's undo step, so a first Ctrl+Z does not take the
334    /// seed back. A collaborator who creates the same record concurrently
335    /// seeds it too, so a seed sets values rather than adding rows.
336    fn seed(cx: &SeedCx<'_>) -> Vec<Invoke<Self::Resource>> {
337        let _ = cx;
338        Vec::new()
339    }
340
341    /// The records the app keeps besides the one it shows: the person's own
342    /// records and the settings sections that are resource models. Each is
343    /// opened with the window and published beside the app's resource, so
344    /// the palette and agents change it whether or not a view has shown it,
345    /// and `tilo-app surface` commits its surface with the app's.
346    ///
347    /// ```ignore
348    /// fn records(records: &mut Records<'_>) {
349    ///     records.person::<WatchHistory>().settings::<Playback>();
350    /// }
351    /// ```
352    fn records(records: &mut super::Records<'_>) {
353        let _ = records;
354    }
355
356    /// The person's own records the app keeps
357    /// ([`PersonKind`](super::PersonKind)), declared with the app rather than
358    /// with the window showing it: each is opened with the window, kept in
359    /// the person's own scope instead of a workspace's, and part of the
360    /// app's committed surface whether or not a view has shown it.
361    ///
362    /// ```ignore
363    /// const PERSON_RECORDS: &'static [PersonKind] =
364    ///     &[PersonKind::shared::<accounts::person::PersonAccounts>()];
365    /// ```
366    const PERSON_RECORDS: &'static [super::PersonKind] = &[];
367}
368
369/// What [`ResourceApp::opening`] builds with: who the window is for, the workspace, and the record
370/// on its way.
371pub struct OpeningCx<'a> {
372    pub(crate) me: &'a str,
373    pub(crate) device: &'a str,
374    pub(crate) workspace: String,
375    pub(crate) files: ui_core::storage::AppFiles,
376    pub(crate) record: ui_core::record::RecordOpening,
377}
378
379impl OpeningCx<'_> {
380    /// Who the window is for.
381    #[must_use]
382    pub fn me(&self) -> &str {
383        self.me
384    }
385
386    /// This device's id.
387    #[must_use]
388    pub fn device(&self) -> &str {
389        self.device
390    }
391
392    /// The workspace the window shows, as [`RecordEnv::workspace`](super::RecordEnv::workspace) names it.
393    #[must_use]
394    pub fn workspace(&self) -> &str {
395        &self.workspace
396    }
397
398    /// The app's private file areas, as [`RecordEnv::storage`](super::RecordEnv::storage) gives
399    /// them to the view.
400    #[must_use]
401    pub fn files(&self) -> ui_core::storage::AppFiles {
402        self.files.clone()
403    }
404
405    /// The workspace's record, being read off the frame: what a write before it is had waits for
406    /// ([`RecordOpening::document`](ui_core::record::RecordOpening::document)).
407    #[must_use]
408    pub fn record(&self) -> &ui_core::record::RecordOpening {
409        &self.record
410    }
411}
412
413/// What a window shows before its record ([`ResourceApp::opening`]): a widget that the view
414/// takes over as the service it was lent.
415pub trait OpeningView: ui_core::widget::Widget {
416    /// This, as the service lent to the view.
417    fn into_lent(self: Box<Self>) -> Box<dyn std::any::Any>;
418}
419
420impl<W: ui_core::widget::Widget + 'static> OpeningView for W {
421    fn into_lent(self: Box<Self>) -> Box<dyn std::any::Any> {
422        self
423    }
424}
425
426/// The History scope of [`ResourceApp::FILE_HISTORY`]: the files of the
427/// mounted filesystem an app shows.
428#[derive(Clone, Copy)]
429pub struct FileHistory {
430    /// What the scope is called in History: "Selected file".
431    pub title: &'static str,
432    /// Draws one version of a file, from its bytes as the projection read
433    /// them.
434    pub preview: ui_core::history_mode::checked_files::FilePreviewFactory,
435}
436
437/// What [`ResourceApp::seed`] may read: who is creating the record, and the
438/// app's host — its settings, files and secrets on this device — when the
439/// record is created in a window. A participant without one (an agent, a
440/// workspace service: [`seed_record`](super::seed_record)) creates it with no
441/// host: what a seed imports from the device is the device's own to seed.
442pub struct SeedCx<'a> {
443    pub(crate) me: &'a str,
444    pub(crate) device: &'a str,
445    pub(crate) host: Option<&'a HostContext>,
446    pub(crate) workspace: Option<&'a str>,
447}
448
449impl SeedCx<'_> {
450    /// Who is creating the record: the id their views read as
451    /// [`RecordEnv::me`](super::RecordEnv::me) and the seed's actions read as their author.
452    pub fn me(&self) -> &str {
453        self.me
454    }
455
456    /// The device creating the record.
457    pub fn device(&self) -> &str {
458        self.device
459    }
460
461    /// The host-selected workspace of a new record, and its display name. An
462    /// ordinary restore has no creation scope. A windowless workspace participant
463    /// carries its explicit route without supplying device services or secrets.
464    pub fn creation_workspace(&self) -> Option<(&str, &str)> {
465        self.host
466            .and_then(HostContext::creation_workspace)
467            .map(|workspace| (workspace.id.as_str(), workspace.name.as_str()))
468            .or_else(|| self.workspace.map(|workspace| (workspace, workspace)))
469    }
470
471    /// The app's host on this device; `None` for a participant without a
472    /// window.
473    pub fn host(&self) -> Option<&HostContext> {
474        self.host
475    }
476
477    /// What the app's windows in this workspace kept, one record each, from
478    /// before it [kept one record per workspace](ResourceApp::WORKSPACE_RECORD): every
479    /// window default record of its kind on this device, read-only and in
480    /// id order. The workspace's record is created once, so a seed that copies
481    /// these into it migrates them exactly once; the records themselves are
482    /// left as they were. Empty for any other record, and without a host.
483    pub fn window_records(&self) -> Vec<(RecordId, tilo_resource::Doc)> {
484        self.host
485            .map(HostContext::window_records)
486            .unwrap_or_default()
487    }
488}
489
490type Leaked = &'static (dyn std::any::Any + Send + Sync);
491
492pub(crate) fn leaked<T: Send + Sync + 'static>(
493    key: TypeId,
494    make: impl FnOnce() -> T,
495) -> &'static T {
496    let mut make = Some(make);
497    let entry = leaked_entry(key, &mut || match make.take() {
498        Some(make) => Box::leak(Box::new(make())) as Leaked,
499        None => unreachable_definition(),
500    });
501    // One value per (type, T) key is ever inserted, so the downcast holds.
502    entry
503        .downcast_ref::<T>()
504        .unwrap_or_else(|| unreachable_definition())
505}
506
507/// The value kept under `key`, made by `make` the first time. Not generic,
508/// so the table is compiled once rather than for every app and key.
509#[inline(never)]
510fn leaked_entry(key: TypeId, make: &mut dyn FnMut() -> Leaked) -> Leaked {
511    static TABLE: OnceLock<Mutex<HashMap<TypeId, Leaked>>> = OnceLock::new();
512    let table = TABLE.get_or_init(Mutex::default);
513    let mut table = table.lock_recover();
514    *table.entry(key).or_insert_with(make)
515}
516
517#[allow(
518    clippy::panic,
519    reason = "a keyed table entry of the wrong type is a framework bug"
520)]
521fn unreachable_definition() -> ! {
522    panic!("framework definition table holds a value of another type")
523}
524
525struct DefinitionKey<A>(std::marker::PhantomData<A>);
526
527/// The toolkit application definition of `A`, created once per process.
528pub fn definition<A: ResourceApp>() -> &'static ApplicationDefinition {
529    leaked(TypeId::of::<DefinitionKey<A>>(), || {
530        super::kinds::register::<A>();
531        ui_core::app_surface::register_kind(<A::Resource as Resource>::KIND, hooks::<A>().id());
532        definition_of::<A>()
533    })
534}
535
536fn definition_of<A: ResourceApp>() -> ApplicationDefinition {
537    // An app's library and settings are one record per workspace: every
538    // window of it there opens the same data, across closing, reopening and
539    // restarts, and keeps only its view of it (scroll, selection). An app
540    // whose windows each show a resource of their own (a document, a
541    // session) keeps that per window.
542    application_definition(hooks::<A>(), A::OPENS, !A::WINDOW_RESOURCE, create::<A>)
543}
544
545/// The toolkit definition of the app `app` describes. Not generic, so it is
546/// compiled once rather than for every app.
547#[inline(never)]
548fn application_definition(
549    app: &AppHooks,
550    opens: &'static [&'static str],
551    workspace_record: bool,
552    create: ComponentFactory,
553) -> ApplicationDefinition {
554    ApplicationDefinition {
555        app_id: app.id().into(),
556        title: app.title().into(),
557        icon: icon(app),
558        record_kind: RecordKind::new(app.kind),
559        required: vec![],
560        optional: vec![],
561        opens,
562        workspace_record,
563        member_record: (app.member_record)(),
564        opens_before_record: workspace_record && app.opens_before_record,
565        create,
566    }
567}
568
569fn icon(app: &AppHooks) -> IconDescriptor {
570    IconDescriptor::Named(
571        app.icon_name()
572            .unwrap_or_else(|| app.id().rsplit('.').next().unwrap_or_else(|| app.id()))
573            .to_owned(),
574    )
575}
576
577/// Where the app's record lives: its window's own, or the session's document
578/// host's ([`ResourceApp::HOSTED_BY_SESSION`]).
579#[derive(Clone, Copy, PartialEq, Eq)]
580enum Hosting {
581    /// The window opens its record and edits it here.
582    Own,
583    /// The session's document host keeps it: the view reads the session's
584    /// reading and submits its actions over the session's connection.
585    Session,
586}
587
588impl Hosting {
589    const fn of(hosted_by_session: bool) -> Self {
590        if hosted_by_session {
591            Self::Session
592        } else {
593            Self::Own
594        }
595    }
596
597    const fn in_session(self) -> bool {
598        matches!(self, Self::Session)
599    }
600}
601
602/// What the launch path, the window and History read of an app: its
603/// constants, and shims over the parts that need its types. One table per
604/// app; everything that reads it is compiled once for every app.
605pub(crate) struct AppHooks {
606    id: &'static str,
607    title: &'static str,
608    icon: Option<&'static str>,
609    /// [`ResourceApp::named`] — the app's name when the launch gives it.
610    named: fn() -> Option<AppName>,
611    /// [`ResourceApp::member_record`].
612    member_record: fn() -> bool,
613    /// [`Resource::KIND`] of the app's resource.
614    pub(crate) kind: &'static str,
615    /// [`Resource::LABEL`] of the app's resource.
616    label: &'static str,
617    behaviour: ComponentBehaviour,
618    document_history: bool,
619    file_history: Option<FileHistory>,
620    #[cfg(tilo_native_runner)]
621    private_windows: bool,
622    #[cfg(tilo_native_runner)]
623    options: &'static [&'static str],
624    #[cfg(tilo_native_runner)]
625    flags: &'static [&'static str],
626    catalog: fn() -> Option<&'static EmbeddedCatalog>,
627    definition: fn() -> &'static ApplicationDefinition,
628    launch_record: fn(&LaunchInput<'_>) -> Option<RecordId>,
629    local_operations: fn() -> Vec<tilo_actions::ActionSpec>,
630    declarations: fn() -> Rc<[tilo_resource::Declaration]>,
631    /// Initialize a record the launch created: the resource's own
632    /// initialization, the launch's genesis and the creator's seed.
633    new_record: NewRecord,
634    prepare_capture: fn(&mut HostContext, Option<&str>) -> Result<Run, LaunchError>,
635    /// Read the record `context` opened, build the root over it and take
636    /// the launch's actions on it; or why the record does not read.
637    open: OpenRoot,
638    /// Build the root over a reading the session published, for the app
639    /// whose record the session's document host keeps
640    /// ([`ResourceApp::HOSTED_BY_SESSION`]): the view syncs from the
641    /// reading and submits over the connection.
642    open_hosted: OpenHosted,
643    /// [`ResourceApp::HOSTED_BY_SESSION`].
644    hosting: Hosting,
645    /// [`ResourceApp::OPENS_BEFORE_RECORD`].
646    opens_before_record: bool,
647    /// [`ResourceApp::opening`].
648    pub(crate) opening: fn(&OpeningCx<'_>) -> Option<Box<dyn OpeningView>>,
649    pub(crate) action_registry: fn() -> tilo_actions::ActionRegistry,
650    history: fn() -> &'static ui_core::history_mode::HistoryDefinition,
651    file_history_witness: fn() -> &'static ui_core::history_mode::HistoryDefinition,
652}
653
654/// [`AppHooks::new_record`] — the context, and what the launch named.
655type NewRecord = fn(
656    &mut HostContext,
657    &[String],
658    Option<&ui_core::application::OpenFile>,
659    &[ui_core::application::LaunchOption],
660) -> Result<(), LaunchError>;
661
662/// [`AppHooks::open_hosted`] — the context, who edits, the connection to the
663/// session and the resource address it publishes at.
664type OpenHosted = fn(
665    &mut HostContext,
666    Editing,
667    super::Connection,
668    &str,
669) -> Result<Box<dyn ui_core::widget::AppWidget>, LaunchError>;
670
671/// [`AppHooks::open`] — the context, who edits, whether it is a capture and
672/// whether the launch created the record.
673type OpenRoot = fn(
674    &mut HostContext,
675    Editing,
676    bool,
677    bool,
678) -> Result<Result<Box<dyn ui_core::widget::AppWidget>, String>, LaunchError>;
679
680struct Hooks<A>(std::marker::PhantomData<A>);
681
682impl<A: ResourceApp> Hooks<A> {
683    const HOOKS: AppHooks = AppHooks {
684        id: A::ID,
685        title: A::TITLE,
686        icon: A::ICON,
687        named: A::named,
688        member_record: A::member_record,
689        kind: <A::Resource as Resource>::KIND,
690        label: <A::Resource as Resource>::LABEL,
691        behaviour: A::BEHAVIOUR,
692        document_history: A::DOCUMENT_HISTORY,
693        file_history: A::FILE_HISTORY,
694        #[cfg(tilo_native_runner)]
695        private_windows: A::PRIVATE_WINDOWS,
696        #[cfg(tilo_native_runner)]
697        options: A::OPTIONS,
698        #[cfg(tilo_native_runner)]
699        flags: A::FLAGS,
700        catalog: A::catalog,
701        definition: definition::<A>,
702        launch_record: A::launch_record,
703        local_operations: A::local_operations,
704        declarations: declarations_of::<A::Resource>,
705        new_record: new_record::<A>,
706        prepare_capture: prepare_capture::<A>,
707        open: open_root::<A>,
708        open_hosted: open_hosted_root::<A>,
709        hosting: Hosting::of(A::HOSTED_BY_SESSION),
710        opens_before_record: A::OPENS_BEFORE_RECORD && !A::WINDOW_RESOURCE,
711        opening: A::opening,
712        action_registry: action_registry::<A>,
713        history: super::preview::history_definition::<A>,
714        file_history_witness: super::preview::file_history_witness::<A>,
715    };
716}
717
718impl AppHooks {
719    /// The app's id: as the launch names it, else [`ResourceApp::ID`].
720    pub(crate) fn id(&self) -> &'static str {
721        (self.named)().map_or(self.id, |named| named.id)
722    }
723
724    /// The window and launcher title: as the launch names it, else [`ResourceApp::TITLE`].
725    pub(crate) fn title(&self) -> &'static str {
726        (self.named)().map_or(self.title, |named| named.title)
727    }
728
729    fn icon_name(&self) -> Option<&'static str> {
730        (self.named)().map_or(self.icon, |named| named.icon)
731    }
732}
733
734/// `A`'s [`AppHooks`].
735pub(crate) fn hooks<A: ResourceApp>() -> &'static AppHooks {
736    &Hooks::<A>::HOOKS
737}
738
739fn declarations_of<R: Resource>() -> Rc<[tilo_resource::Declaration]> {
740    tilo_resource::vocabulary::<R>().declarations()
741}
742
743/// Who may edit the resource a root shows, and what it is built for.
744pub(crate) enum Editing {
745    /// The participant (`me`) and their permissions, followed while the app
746    /// runs, in a window someone uses or a capture.
747    Live {
748        permissions: LivePermissions,
749        me: String,
750        run: Run,
751    },
752    /// Never: a past version in History mode.
753    ReadOnly,
754}
755
756/// What a root is built with besides its resource type: who edits it and
757/// how, what it was built for, and where its settings are kept.
758struct RootShell {
759    /// The permission that makes the app's resource editable.
760    scope: PermissionScope,
761    permissions: Option<LivePermissions>,
762    read_only: bool,
763    me: Option<String>,
764    run: Run,
765    storage: Rc<super::settings::ViewStorage>,
766}
767
768impl RootShell {
769    fn new(
770        app: &AppHooks,
771        editing: Editing,
772        storage: AppStorage,
773        hosted: super::settings::Hosted,
774    ) -> Self {
775        let scope = PermissionScope::app(app.id(), ActionKind::Undoable);
776        let (permissions, read_only, me, run) = match editing {
777            Editing::Live {
778                permissions,
779                me,
780                run,
781            } => {
782                let read_only = !permissions.check(&scope);
783                (Some(permissions), read_only, Some(me), run)
784            }
785            Editing::ReadOnly => (None, true, None, Run::past()),
786        };
787        let run = run.for_participant(me.as_deref());
788        let storage =
789            super::settings::ViewStorage::new(storage, hosted.attach(app.id(), app.title()));
790        Self {
791            scope,
792            permissions,
793            read_only,
794            me,
795            run,
796            storage,
797        }
798    }
799
800    /// Whether effects and projection drains run: in a window someone uses
801    /// only. History shows a past version, and a capture reaches nothing
802    /// outside.
803    fn runs_effects(&self) -> bool {
804        self.permissions.is_some() && self.run.mode.is_live()
805    }
806
807    /// Whether the effects someone invokes run, though the window starts
808    /// no work on its own: a capture a headless drive invokes actions on.
809    fn runs_invoked_effects(&self) -> bool {
810        self.permissions.is_some() && self.run.driven
811    }
812}
813
814/// Build the framework root for a resource state: the live handle, the
815/// dispatcher, the published resource and the app's view.
816pub(crate) fn build_root<A: ResourceApp>(
817    reader: Reader<A::Resource>,
818    editing: Editing,
819    storage: AppStorage,
820    hosted: super::settings::Hosted,
821) -> ResourceRoot {
822    build_root_from::<A>(reader, editing, storage, hosted, super::Source::Document)
823}
824
825/// [`build_root`] over `source`: the app's own document, or the session's
826/// document host over the app's one connection (`one-app-model.md` §4).
827pub(crate) fn build_root_from<A: ResourceApp>(
828    reader: Reader<A::Resource>,
829    editing: Editing,
830    storage: AppStorage,
831    hosted: super::settings::Hosted,
832    source: super::Source,
833) -> ResourceRoot {
834    let app = hooks::<A>();
835    // History names this app's fields by their titles.
836    if let Some(shape) = <A::Resource as Resource>::shape() {
837        ui_core::history_source::sentence::learn_field_titles(&shape);
838    }
839    let shell = RootShell::new(app, editing, storage, hosted);
840    let runs_effects = shell.runs_effects();
841    let runs_invoked_effects = shell.runs_invoked_effects();
842    let RootShell {
843        scope,
844        permissions,
845        read_only,
846        me,
847        run,
848        storage,
849    } = shell;
850    let label = A::label(reader.state());
851    let published = tilo_resource::publish::<A::Resource>(app.kind, &label, reader.state());
852    let live = Live::new(reader);
853    let alive = Rc::new(());
854    let dispatch = Dispatcher::new(app.id(), app.kind, read_only, me.as_deref())
855        .on_record(
856            run.launched
857                .record
858                .as_ref()
859                .map_or("", |record| record.route()),
860        )
861        .over_source(source);
862    let dispatch = if permissions.is_none() {
863        dispatch.past()
864    } else {
865        dispatch
866    };
867    // A window someone uses opens the records the app keeps beside its own,
868    // so they are published before any view shows them.
869    if permissions.is_some() {
870        A::records(&mut super::Records::new(&storage).with_base(live.clone(), dispatch.clone()));
871        for record in A::PERSON_RECORDS {
872            record.open(&storage);
873        }
874    }
875    let effects = if runs_effects {
876        Some(Effects::live(dispatch.clone(), live.clone()))
877    } else {
878        runs_invoked_effects.then(|| Effects::invoked(dispatch.clone(), live.clone()))
879    };
880    let awaiting = run.workspace.clone();
881    let mut cx = ViewCx::root(live, &alive, dispatch, published, run, storage);
882    if let Some(effects) = effects {
883        cx.env.effects = effects;
884    }
885    // A record the workspace may hold shows once the workspace's holdings arrived;
886    // the app's view says nothing about it. Holdings already here (no
887    // shell hosts the window, or the workspace synced) need no stand-in.
888    let content: Element = match awaiting {
889        Some(holdings) if workspace_pending(&holdings) => cx.arriving_workspace(&holdings, A::view),
890        _ => A::view(&mut cx),
891    };
892    let live = permissions.is_some();
893    let mut root = ResourceRoot::new(app.title(), icon(app), label, cx, alive, content)
894        .following(permissions, scope)
895        .with_presentation((app.local_operations)());
896    // A window shown over other apps' content paints no ground of its own:
897    // what it does not paint shows what is under it, and a keyboard that
898    // hides its keys takes the whole of its window with them.
899    if matches!(
900        A::BEHAVIOUR.window,
901        ui_core::application::WindowRole::Sheet | ui_core::application::WindowRole::InputSurface
902    ) {
903        root = root.without_ground();
904    }
905    if !app.document_history {
906        return root.without_history();
907    }
908    if live {
909        #[cfg(not(target_family = "wasm"))]
910        return root.drawing_past(super::preview::draw_past::<A>);
911    }
912    root
913}
914
915/// Whether the workspace's holdings are still on their way.
916fn workspace_pending(
917    holdings: &ui_core::arriving::Arriving<(), ui_core::workspace_sync::WorkspaceUnreachable>,
918) -> bool {
919    holdings.with(|workspace| workspace.ready().is_none())
920}
921
922/// The decoders of every edit `A`'s document takes: its resource's actions
923/// and History's restores.
924pub(crate) fn action_registry<A: ResourceApp>() -> tilo_actions::ActionRegistry {
925    let mut registry =
926        tilo_actions::ActionRegistry::with(tilo_resource::group::register::<A::Resource>);
927    registry
928        .register::<super::history::ResourceHistoryEdit<A::Resource>>(super::history::EDIT_TYPE);
929    registry
930}
931
932/// The actions `invokes` build at `now_ms`, groups expanded; or the first
933/// refusal.
934fn built_actions<R: Resource>(
935    invokes: &[Invoke<R>],
936    now_ms: i64,
937) -> Result<Vec<R::Action>, String> {
938    let mut actions = Vec::new();
939    for invoke in invokes {
940        if let Some(refusal) = invoke.refusal() {
941            return Err(refusal.to_owned());
942        }
943        if invoke.is_group() {
944            actions
945                .extend(tilo_resource::group::build_group::<R>(&invoke.arguments, now_ms)?.actions);
946        } else {
947            actions.push(invoke.build(now_ms)?);
948        }
949    }
950    Ok(actions)
951}
952
953/// Apply a new record's [`ResourceApp::seed`] through the replayable
954/// executor: attributed to `author` as their edits are, and recorded as
955/// content from outside, which no undo takes back.
956pub(crate) fn apply_seed<A: ResourceApp>(
957    doc: &mut tilo_resource::Doc,
958    author: &str,
959    seed: &[Invoke<A::Resource>],
960) -> Result<(), String> {
961    let actions = built_actions(seed, tilo_resource::origin_now_ms())?;
962    let encoded = actions
963        .iter()
964        .map(|action| tilo_resource::encode::<A::Resource>(action).map_err(|e| e.to_string()))
965        .collect::<Result<Vec<_>, _>>()?;
966    let actions: Vec<&dyn tilo_actions::Action> = actions
967        .iter()
968        .map(|action| action as &dyn tilo_actions::Action)
969        .collect();
970    apply_seed_actions(hooks::<A>(), doc, author, &actions, encoded)
971}
972
973/// [`apply_seed`] once the seed is built and encoded.
974fn apply_seed_actions(
975    app: &AppHooks,
976    doc: &mut tilo_resource::Doc,
977    author: &str,
978    actions: &[&dyn tilo_actions::Action],
979    encoded: Vec<tilo_actions::EncodedAction>,
980) -> Result<(), String> {
981    let writer = ui_core::history_source::authors::device_actor(author, doc.get_actor());
982    for (action, encoded) in actions.iter().zip(encoded) {
983        let context = tilo_actions::ActionContext::for_resource(app.kind, app.kind, app.label)
984            .with_app(app.id(), app.title())
985            .with_invoker(tilo_actions::ActionInvoker::actor(author))
986            .with_change(tilo_actions::ChangeKind::External);
987        tilo_actions::apply_local_with_context(doc, *action, encoded, Some(&writer), context)
988            .map_err(|error| format!("seeding the new record failed: {error}"))?;
989    }
990    Ok(())
991}
992
993/// Write a launch's [`ResourceApp::genesis`] into the record's genesis:
994/// built at time zero and applied as the genesis writes everything, so every
995/// device that creates the record writes the same change.
996fn write_genesis<R: Resource>(
997    doc: &mut tilo_resource::Doc,
998    genesis: &[Invoke<R>],
999) -> Result<(), String> {
1000    for action in &built_actions(genesis, 0)? {
1001        tilo_actions::Action::apply(action, doc).map_err(|error| error.to_string())?;
1002    }
1003    Ok(())
1004}
1005
1006/// Initialize a record a launch of `A` created: the resource's own
1007/// initialization and the launch's [`ResourceApp::genesis`] in the
1008/// genesis, then the creator's [`ResourceApp::seed`].
1009fn new_record<A: ResourceApp>(
1010    context: &mut HostContext,
1011    arguments: &[String],
1012    file: Option<&ui_core::application::OpenFile>,
1013    options: &[ui_core::application::LaunchOption],
1014) -> Result<(), LaunchError> {
1015    let genesis = A::genesis(
1016        &LaunchInput::new(arguments, file)
1017            .with_options(options)
1018            .with_content(context.content()),
1019    );
1020    let author = context.author().0.clone();
1021    let seed = A::seed(&SeedCx {
1022        me: &author,
1023        device: context.device_id(),
1024        host: Some(context),
1025        workspace: None,
1026    });
1027    // What the app's windows kept, one record each, before it kept one per
1028    // workspace; an app that adopts them in its own seed
1029    // (`ResourceApp::WORKSPACE_RECORD`) has done so.
1030    let windows = if A::WORKSPACE_RECORD {
1031        Vec::new()
1032    } else {
1033        context.window_records()
1034    };
1035    initialize_record(
1036        context,
1037        &mut |doc| {
1038            <A::Resource as Resource>::initialize(doc).map_err(|error| error.to_string())?;
1039            write_genesis(doc, &genesis)
1040        },
1041        &mut |doc| {
1042            apply_seed::<A>(doc, &author, &seed)?;
1043            super::adopt_windows::adopt(doc, &windows, &|window| {
1044                tilo_resource::evolution::migrate::<A::Resource>(window)
1045                    .map(drop)
1046                    .map_err(|error| error.to_string())
1047            })
1048        },
1049    )
1050}
1051
1052/// What initializing a new record writes into it.
1053type RecordWrite<'a> = &'a mut dyn FnMut(&mut tilo_resource::Doc) -> Result<(), String>;
1054
1055/// Initialize the new record `context` opened: `genesis` into its genesis,
1056/// then `seed`. Not generic over the app, so the record store's
1057/// initialization is compiled once.
1058fn initialize_record(
1059    context: &mut HostContext,
1060    genesis: RecordWrite<'_>,
1061    seed: RecordWrite<'_>,
1062) -> Result<(), LaunchError> {
1063    context
1064        .initialize_new_record_seeded(|doc| genesis(doc), |doc| seed(doc))
1065        .map_err(LaunchError::Record)
1066}
1067
1068/// Bring an older record up to the resource's storage version and this
1069/// build's version key; what it answers when it cannot. Only a document that
1070/// needs it is written to: opening another view of a current record must not
1071/// commit what one already open has pending.
1072fn migrate_older(
1073    context: &HostContext,
1074    needs: fn(&tilo_resource::Doc) -> bool,
1075    migrate: &mut dyn FnMut(&mut tilo_resource::Doc) -> Result<bool, String>,
1076) -> Result<Result<(), String>, LaunchError> {
1077    let older = context.read_record(needs).map_err(LaunchError::Record)?;
1078    Ok(if older {
1079        context.migrate_record(|doc| migrate(doc)).map(drop)
1080    } else {
1081        Ok(())
1082    })
1083}
1084
1085/// Read the record `context` opened as `A`'s resource, once an older
1086/// document is brought up to the app's storage version, build its root and
1087/// take the launch's actions ([`ResourceApp::launch`]) on it; `created`
1088/// when the launch created the record. Or why it does not read, which the
1089/// window shows instead of the view.
1090///
1091/// A capture launched on nothing is staged by its scenario alone: what a
1092/// launch on nothing takes reads from this device (a library, the last
1093/// folder), which a screenshot must not depend on. A capture launched on a
1094/// file takes that file, as the command line does.
1095fn open_root<A: ResourceApp>(
1096    context: &mut HostContext,
1097    editing: Editing,
1098    capture: bool,
1099    created: bool,
1100) -> Result<Result<Box<dyn ui_core::widget::AppWidget>, String>, LaunchError> {
1101    if let Err(reason) = A::import_record(context) {
1102        return Ok(Err(reason));
1103    }
1104    let migrated = migrate_older(
1105        context,
1106        tilo_resource::evolution::needs_migrate::<A::Resource>,
1107        &mut |doc| {
1108            tilo_resource::evolution::migrate::<A::Resource>(doc).map_err(|error| error.to_string())
1109        },
1110    )?;
1111    let read = context
1112        .read_record(|doc| {
1113            Reader::<A::Resource>::read(doc)
1114                .map_err(|error| tilo_resource::Unreadable::of::<A::Resource>(doc, &error).reason)
1115        })
1116        .map_err(LaunchError::Record)?;
1117    let reader = match migrated.and(read) {
1118        Ok(reader) => reader,
1119        Err(reason) => return Ok(Err(reason)),
1120    };
1121    let root = build_root::<A>(reader, editing, context.storage(), hosted(context));
1122    if let Some(typed) = root.typed::<A::Resource>() {
1123        let record = typed.live.get();
1124        let launch = root.launch().with_record(&*record);
1125        if !capture || !launch.is_empty() {
1126            for invoke in A::launch(&launch, created) {
1127                typed.dispatch.submit(invoke);
1128            }
1129        }
1130    }
1131    Ok(Ok(root.into_widget()))
1132}
1133
1134/// The root over the session's reading, when this launch is the session's
1135/// and the document host keeps the record: `None` when the app keeps its own
1136/// record here, as on a device with the runtime compiled in, in a capture
1137/// and in a test.
1138fn session_reading(
1139    app: &AppHooks,
1140    context: &mut HostContext,
1141    editing: &Editing,
1142) -> Result<Option<Box<dyn ui_core::widget::AppWidget>>, LaunchError> {
1143    if !app.hosting.in_session() || context.capture().is_some() {
1144        return Ok(None);
1145    }
1146    let Some(resource) = context.hosted_resource().map(str::to_owned) else {
1147        return Ok(None);
1148    };
1149    // The launcher made this launch its connection; a host that runs the app
1150    // inside itself supplies its own (`HostContext::provide_connection`).
1151    let connection = context
1152        .supplied_connection::<super::Connection>()
1153        .cloned()
1154        .unwrap_or_else(super::Connection::launcher);
1155    let editing = match editing {
1156        Editing::Live {
1157            permissions,
1158            me,
1159            run,
1160        } => Editing::Live {
1161            permissions: permissions.clone(),
1162            me: me.clone(),
1163            run: run.clone(),
1164        },
1165        Editing::ReadOnly => Editing::ReadOnly,
1166    };
1167    let root = (app.open_hosted)(context, editing, connection, &resource)?;
1168    Ok(Some(root))
1169}
1170
1171/// Build `A`'s root over the reading the session publishes at `resource`,
1172/// for the app whose record the session's document host keeps: the view
1173/// syncs from that reading and submits over `connection`
1174/// (`docs/designs/app-language/one-app-model.md` §4). No record is opened
1175/// here, and the launch's own actions are the app's to take on the record the
1176/// session holds, so none is taken.
1177///
1178/// # Errors
1179/// The session publishes no reading of the resource the caller may read.
1180fn open_hosted_root<A: ResourceApp>(
1181    context: &mut HostContext,
1182    editing: Editing,
1183    connection: super::Connection,
1184    resource: &str,
1185) -> Result<Box<dyn ui_core::widget::AppWidget>, LaunchError> {
1186    let reading = connection.reading(resource).ok_or_else(|| {
1187        LaunchError::Operation(format!(
1188            "the session publishes no reading of {resource} that this app may read"
1189        ))
1190    })?;
1191    let state = tilo_resource::state_from_reading::<A::Resource>(&reading)
1192        .map_err(|error| LaunchError::Operation(error.to_string()))?;
1193    let reader = Reader::<A::Resource>::with_state(state, None);
1194    let source = super::Source::over(connection, resource);
1195    let root = build_root_from::<A>(reader, editing, context.storage(), hosted(context), source)
1196        .reading_from_source();
1197    Ok(root.into_widget())
1198}
1199
1200/// The component of a window shown before its record ([`ResourceApp::opening`]): it shows
1201/// `view` until the record is had, then the window shows the component built as any launch
1202/// builds it, which takes `view` over ([`super::Opened`]).
1203fn opening_component(
1204    app: &'static AppHooks,
1205    mut context: HostContext,
1206    view: Box<dyn OpeningView>,
1207    record: ui_core::record::RecordOpening,
1208    restore: &RestoreInput,
1209) -> AppComponent {
1210    let arguments = context.take_launch_arguments();
1211    let options = context.take_launch_options();
1212    let handoff = context.take_handoff();
1213    let lent = context.take_lent();
1214    let restore = restore.clone();
1215    let reopener = context.reopener();
1216    let checkout = record.checkout().clone();
1217    let build: super::opening::Build = Box::new(move |shown| {
1218        let definition = (app.definition)();
1219        let (mut context, target) = reopener.prepare(definition, checkout.id)?;
1220        context.provide_launch_arguments(arguments.clone());
1221        context.provide_launch_options(options);
1222        if let Some(handoff) = handoff {
1223            context.provide_handoff(handoff);
1224        }
1225        for service in lent {
1226            context.lend_boxed(service);
1227        }
1228        context.lend(super::Opened::new(shown));
1229        let component = definition.instantiate(context, target, restore)?;
1230        Ok(ui_core::application::ComponentReplacement::new(
1231            component, arguments,
1232        ))
1233    });
1234    let root = super::opening::OpeningRoot::new(view, record, build, (app.title(), icon(app)));
1235    let mut component = AppComponent::new(context, Box::new(root), (app.action_registry)());
1236    component.set_behaviour(app.behaviour);
1237    component
1238}
1239
1240/// What a window's root keeps of the person and the device.
1241pub(crate) fn hosted(context: &HostContext) -> super::settings::Hosted {
1242    super::settings::Hosted {
1243        // A private window keeps nothing of the person's: their own
1244        // records are not even opened.
1245        person: (!context.is_private())
1246            .then(|| context.person_records().cloned())
1247            .flatten(),
1248        device: Some(context.device_id().to_owned()),
1249        records: Some((
1250            context.records().clone(),
1251            context.author().0.clone(),
1252            context.live_permissions(),
1253        )),
1254        workspace: context
1255            .workspace_records()
1256            .cloned()
1257            .map(|records| (records, context.wake_handle())),
1258    }
1259}
1260
1261/// The window of a record that does not read as the app's resource.
1262fn unopened(app: &AppHooks, context: HostContext, reason: &str) -> AppComponent {
1263    let unopened = super::root::Unopened::new(
1264        app.title(),
1265        icon(app),
1266        (app.kind, app.label),
1267        &(app.declarations)(),
1268        reason,
1269    );
1270    let mut component = AppComponent::new(context, unopened.boxed(), (app.action_registry)());
1271    component.set_behaviour(app.behaviour);
1272    // An earlier version of the record may still read as the resource:
1273    // History shows its versions and restores one, as in the app's own window.
1274    if app.document_history
1275        && let Err(error) = component.register_document_history((app.history)())
1276    {
1277        tracing::warn!(%error, "the unopened record shows without its History");
1278    }
1279    component
1280}
1281
1282#[allow(
1283    clippy::needless_pass_by_value,
1284    reason = "the toolkit's ComponentFactory signature passes the target owned"
1285)]
1286fn create<A: ResourceApp>(
1287    context: HostContext,
1288    target: LaunchTarget,
1289    restore: RestoreInput,
1290) -> Result<AppComponent, LaunchError> {
1291    create_component(hooks::<A>(), context, &target, &restore)
1292}
1293
1294fn create_component(
1295    app: &'static AppHooks,
1296    mut context: HostContext,
1297    target: &LaunchTarget,
1298    restore: &RestoreInput,
1299) -> Result<AppComponent, LaunchError> {
1300    if let Some(catalog) = (app.catalog)() {
1301        catalog.ensure();
1302    }
1303    // The workspace's record is being read off the frame: the window shows what the app shows
1304    // before it, and opens as below once the record is had.
1305    if let Some(record) = context.record_opening() {
1306        let opening = OpeningCx {
1307            me: &context.author().0,
1308            device: context.device_id(),
1309            workspace: context.records().home(),
1310            files: context.files(),
1311            record: record.clone(),
1312        };
1313        if let Some(view) = (app.opening)(&opening) {
1314            return Ok(opening_component(app, context, view, record, restore));
1315        }
1316        record.take_in(true).map_err(LaunchError::Record)?;
1317    }
1318    let starting = start_record(app, &mut context, target)?;
1319    window(app, context, starting)
1320}
1321
1322/// What the launch read of the record the window starts on, before the root
1323/// is built over it.
1324struct Starting {
1325    /// The recording the launch opened, when it replays one.
1326    replay: Option<tilo_resource::recording::Recording>,
1327    /// The record's file-time History, when the app keeps one.
1328    files: Option<FileHistorySource>,
1329    /// Who edits, over the run the launch started.
1330    editing: Editing,
1331    /// Whether the launch created the record.
1332    created: bool,
1333    /// Whether the launch stages a capture.
1334    capture: bool,
1335}
1336
1337/// Open the record the launch starts on: create or replay it, then read its
1338/// file, its arguments and options, and start the run they go with.
1339fn start_record(
1340    app: &'static AppHooks,
1341    context: &mut HostContext,
1342    target: &LaunchTarget,
1343) -> Result<Starting, LaunchError> {
1344    // A record of its own, or the workspace's record an app keeps one of
1345    // (`ResourceApp::WORKSPACE_RECORD`), opened for the first time.
1346    let created = matches!(target, LaunchTarget::New | LaunchTarget::PrimaryNew)
1347        || context.creation_workspace().is_some();
1348    record_content(context);
1349    let file = context.take_open_file();
1350    let arguments = context.take_launch_arguments();
1351    let options = context.take_launch_options();
1352    let replay = file
1353        .as_ref()
1354        .filter(|file| file.media_type() == tilo_content::RECORDING.mime)
1355        .map(|file| replay_of(app, context, file, created))
1356        .transpose()?;
1357    match &replay {
1358        Some(recording) => initialize_record(context, &mut |_| Ok(()), &mut |doc| {
1359            tilo_automerge::ApplyIncoming::merge_incoming(doc, &mut recording.doc().clone())
1360                .map(drop)
1361                .map_err(|error| error.to_string())
1362        })?,
1363        None if created => (app.new_record)(context, &arguments, file.as_ref(), &options)?,
1364        None => {}
1365    }
1366    let capture = context.capture().is_some();
1367    let files = app.file_history.map(|_| FileHistorySource::default());
1368    // A window someone uses can open another record in its place; a
1369    // capture shows the one it staged.
1370    let opener = (!capture).then(|| {
1371        let reopener = context.reopener();
1372        super::launch::OpenHere::new(move |arguments| open_in_place(app, &reopener, arguments))
1373    });
1374    let run = match context.capture().cloned() {
1375        Some(capture) => {
1376            (app.prepare_capture)(context, capture.scenario())?.driven_by_drive(capture.is_driven())
1377        }
1378        // The workspace's record, not on this device when the window opened:
1379        // the workspace may hold it, so the view waits for what the workspace holds.
1380        None => Run::live(context.content().cloned())
1381            .in_private(context.is_private())
1382            .awaiting_workspace(
1383                matches!(target, LaunchTarget::PrimaryNew).then(ui_core::workspace_sync::holdings),
1384            ),
1385    };
1386    if let Some(file) = &file {
1387        file.report_opened(app.kind);
1388    }
1389    let run = run
1390        .launched(Launched {
1391            record: Some(context.record().id.clone()),
1392            arguments,
1393            options,
1394            file,
1395            handoff: context.take_handoff(),
1396            lent: super::scenario::Lent::new(context.take_lent()),
1397            reveal: std::cell::RefCell::new(revealed(target)),
1398        })
1399        .opening_with(opener)
1400        .showing_file_history(
1401            files
1402                .clone()
1403                .map(|source| (source, (app.file_history_witness)())),
1404        );
1405    let editing = Editing::Live {
1406        permissions: context.live_permissions(),
1407        me: context.author().0.clone(),
1408        run,
1409    };
1410    Ok(Starting {
1411        replay,
1412        files,
1413        editing,
1414        created,
1415        capture,
1416    })
1417}
1418
1419/// Build the window over the record the launch started on: its root, the
1420/// document History its versions go to, and the step a replay opens at.
1421fn window(
1422    app: &'static AppHooks,
1423    mut context: HostContext,
1424    starting: Starting,
1425) -> Result<AppComponent, LaunchError> {
1426    let Starting {
1427        replay,
1428        files,
1429        editing,
1430        created,
1431        capture,
1432    } = starting;
1433    let root = match session_reading(app, &mut context, &editing)? {
1434        Some(hosted) => hosted,
1435        None => match (app.open)(&mut context, editing, capture, created)? {
1436            Ok(root) => root,
1437            Err(reason) => return Ok(unopened(app, context, &reason)),
1438        },
1439    };
1440    let hosted = context.hosted();
1441    let mut component = AppComponent::new(context, root, (app.action_registry)());
1442    component.set_behaviour(app.behaviour);
1443    // A window over the session's reading keeps no document here: its
1444    // versions are the session's record's, shown by the session, so the
1445    // component registers no document History (`one-app-model.md` §4).
1446    if app.document_history && !hosted {
1447        let history = (app.history)();
1448        match files {
1449            Some(files) => component.register_document_and_file_history(history, files),
1450            None => component.register_document_history(history),
1451        }
1452        .map_err(|error| LaunchError::Operation(error.to_string()))?;
1453    }
1454    // A replay opens at the recording's first step: the state the window
1455    // showed when the recording starts.
1456    if let Some(start) = replay.as_ref().and_then(|recording| recording.heads_at(0)) {
1457        for hash in start {
1458            component
1459                .open_history_at(ui_core::history_source::entry_id_from_hash(
1460                    &hash.to_string(),
1461                ))
1462                .map_err(|error| LaunchError::Operation(error.to_string()))?;
1463        }
1464    }
1465    Ok(component)
1466}
1467
1468/// The recording `file` holds, opened in a window of its own: a private
1469/// window on a record the launch created, which reads and is never written,
1470/// so the recording reaches nothing outside it
1471/// (`docs/architecture/session-replay.md`).
1472///
1473/// # Errors
1474/// An app that keeps no History, a window that is not private or not on a
1475/// new record, or bytes that are no recording.
1476fn replay_of(
1477    app: &AppHooks,
1478    context: &mut HostContext,
1479    file: &ui_core::application::OpenFile,
1480    created: bool,
1481) -> Result<tilo_resource::recording::Recording, LaunchError> {
1482    if !app.document_history {
1483        return Err(LaunchError::Operation(format!(
1484            "{} keeps no History to replay a recording in",
1485            app.title
1486        )));
1487    }
1488    if !context.is_private() || !created {
1489        return Err(LaunchError::Operation(
1490            "a recording opens only in a private window of its own".into(),
1491        ));
1492    }
1493    context.restrict_to_reading();
1494    tilo_resource::recording::Recording::load(
1495        file.bytes(),
1496        &tilo_resource::recording::Limits::default(),
1497    )
1498    .map_err(|error| LaunchError::Operation(error.to_string()))
1499}
1500
1501/// The item a launch on a record asked to show: the resource its `open`
1502/// names.
1503fn revealed(target: &LaunchTarget) -> Option<String> {
1504    match target {
1505        LaunchTarget::Record {
1506            reveal: Some(address),
1507            ..
1508        } if address.action == tilo_actions::remote::APPLICATION_OPEN_ACTION => {
1509            Some(address.resource.clone())
1510        }
1511        _ => None,
1512    }
1513}
1514
1515/// Join the content store the host lent a window to its record's route:
1516/// content the record references and this device lacks is asked of the
1517/// route, and content the app stores reaches the record's other readers.
1518/// A capture keeps its fixture's store as it is: it reaches nothing outside.
1519fn record_content(context: &mut HostContext) {
1520    if context.capture().is_some() {
1521        return;
1522    }
1523    let Some(local) = context.content().cloned() else {
1524        return;
1525    };
1526    let lane = ui_core::collaboration::record_content::ContentLane::of_window();
1527    // The window keeps its lane: another window of the app on this thread
1528    // reads its own, never this one's queue of wants.
1529    ui_core::collaboration::record_content::keep_lane(&context.wake_handle(), Arc::clone(&lane));
1530    // Whatever carries the record's content for the process (a browser
1531    // page's replication) asks for and hands over this window's too.
1532    let record = &context.record().id;
1533    let wire = if record.is_package_primary() {
1534        ""
1535    } else {
1536        record.as_str()
1537    };
1538    ui_core::collaboration::record_content::ContentLane::show_on_route(
1539        context.app_id(),
1540        wire,
1541        &lane,
1542    );
1543    context
1544        .provide_content(ui_core::collaboration::record_content::RecordContent::store(local, lane));
1545}
1546
1547/// Prepare a capture's scene before its view is built: the app's fixture,
1548/// then the versions other people wrote, merged as their changes arrive.
1549fn prepare_capture<A: ResourceApp>(
1550    context: &mut HostContext,
1551    scenario: Option<&str>,
1552) -> Result<Run, LaunchError> {
1553    let mut fixture =
1554        Fixture::<A::Resource>::new(scenario, &context.author().0, context.content().cloned());
1555    A::fixture(&mut fixture);
1556    if context.content().is_none()
1557        && let Some(content) = fixture.lent_content()
1558    {
1559        context.provide_content(content.clone());
1560    }
1561    if fixture.has_history() {
1562        merge_peer(context, &mut |peer| {
1563            fixture.write_history(peer, &action_registry::<A>())
1564        })?;
1565    }
1566    Ok(fixture.into_run())
1567}
1568
1569/// Merge into the record `context` opened what `write` writes into a fork
1570/// of it, as another participant's changes arrive.
1571fn merge_peer(
1572    context: &HostContext,
1573    write: &mut dyn FnMut(&mut tilo_resource::Doc) -> Result<(), String>,
1574) -> Result<(), LaunchError> {
1575    let mut peer = context
1576        .read_record(|doc| doc.clone().fork())
1577        .map_err(LaunchError::Record)?;
1578    write(&mut peer).map_err(LaunchError::Operation)?;
1579    context
1580        .merge_peer_document(peer.save())
1581        .map_err(LaunchError::Operation)?;
1582    Ok(())
1583}
1584
1585#[cfg(not(target_arch = "wasm32"))]
1586const CAPTURE_INSTANCE: u64 = 1;
1587#[cfg(not(target_arch = "wasm32"))]
1588pub(crate) const DATA_DIR_INSTANCE: u64 = 0;
1589
1590#[cfg(not(target_arch = "wasm32"))]
1591/// The host of an explicit `--data-dir` profile: one personal workspace in
1592/// that directory. Tools that seed or inspect such a profile open it the same
1593/// way the app does.
1594pub(crate) fn data_dir_host(
1595    directory: &std::path::Path,
1596    author: &UserId,
1597    permissions: ui_core::permissions::SharedPermissions,
1598) -> Result<ApplicationHost, LaunchError> {
1599    let root = std::path::absolute(directory).map_err(super::headless::operation)?;
1600    ApplicationHost::new(
1601        root,
1602        "local-device".into(),
1603        "personal",
1604        author.clone(),
1605        permissions,
1606        tilo_actions::ActionContext::default(),
1607    )
1608}
1609
1610#[cfg(not(target_arch = "wasm32"))]
1611/// The permissions of `author` holding `role`, shared with the host so a
1612/// later role change reaches the running app.
1613pub(crate) fn role_permissions(
1614    author: &str,
1615    role: Role,
1616) -> ui_core::permissions::SharedPermissions {
1617    ui_core::permissions::SharedPermissions::new(PermissionContext::member(author, role))
1618}
1619
1620/// Prepare `definition` on `host`: the record a launch named, else the one
1621/// the instance showed last, else the workspace's default.
1622#[cfg(tilo_native_runner)]
1623pub(crate) fn prepare_on(
1624    host: &mut ApplicationHost,
1625    definition: &ApplicationDefinition,
1626    instance: u64,
1627    named: Option<RecordId>,
1628    inputs: ComponentRunnerInputs,
1629) -> Result<PreparedComponentInputs, LaunchError> {
1630    let kind = definition.record_kind.clone();
1631    match named {
1632        // A launch naming the workspace's own record (a notification about
1633        // something in it) opens it as a launch naming nothing does.
1634        Some(id) if !(id.is_package_primary() && definition.keeps_workspace_record()) => {
1635            host.prepare_record(definition, instance, RecordCheckout::new(id, kind), inputs)
1636        }
1637        _ => host.prepare_default(
1638            definition,
1639            instance,
1640            RecordCheckout::new(RecordId::workspace_default(), kind),
1641            None,
1642            inputs,
1643        ),
1644    }
1645}
1646
1647/// Prepare `A` for a launch: the record its arguments name, and the
1648/// arguments handed to the component for [`ResourceApp::launch`]. A first
1649/// argument naming a file of a type the app [opens](ResourceApp::OPENS) is
1650/// also handed over as that file, as a desktop handing it the file would.
1651#[cfg(tilo_native_runner)]
1652fn prepare_app<A: ResourceApp>(
1653    definition: &ApplicationDefinition,
1654    args: &LaunchArgs,
1655) -> Result<PreparedComponentInputs, LaunchError> {
1656    prepare_hooked(hooks::<A>(), definition, args)
1657}
1658
1659#[cfg(tilo_native_runner)]
1660fn prepare_hooked(
1661    app: &AppHooks,
1662    definition: &ApplicationDefinition,
1663    args: &LaunchArgs,
1664) -> Result<PreparedComponentInputs, LaunchError> {
1665    let (record, arguments) = record_argument(args.positionals());
1666    let options = launch_options(app, args);
1667    let named = record
1668        .or_else(|| (app.launch_record)(&LaunchInput::new(arguments, None).with_options(&options)));
1669    // A recording replays in a private window: nothing it shows reaches
1670    // the person's records, devices or collaborators.
1671    let private = (app.private_windows && args.has(PRIVATE_FLAG)) || names_recording(arguments);
1672    let mut prepared = prepare_named(definition, args, named, private)?;
1673    // A window someone looks at shows before its record. What drives or photographs one acts
1674    // on the record from its first step: it waits for it.
1675    let drives = args.headless.is_some()
1676        || args.shots.is_some()
1677        || args.responsive_matrix.is_some()
1678        || args.native_audit;
1679    if drives && let Some(record) = prepared.context.record_opening() {
1680        record.take_in(true).map_err(LaunchError::Record)?;
1681    }
1682    hand_launch(definition, arguments, &mut prepared)?;
1683    prepared.context.provide_launch_options(options);
1684    // The session launches the target of a playback handoff with where it
1685    // stood in its environment; a capture continues nothing, and neither does
1686    // a later window the shell opens in the process.
1687    if args.shots.is_none()
1688        && ui_core::window_door::requested_instance().is_none()
1689        && let Some(handoff) = process_handoff()
1690    {
1691        prepared.context.provide_handoff(handoff);
1692    }
1693    Ok(prepared)
1694}
1695
1696/// Whether the launch names a recording to replay: a first argument that
1697/// is a path to one.
1698#[cfg(tilo_native_runner)]
1699fn names_recording(arguments: &[String]) -> bool {
1700    arguments
1701        .first()
1702        .filter(|argument| !argument.contains("://"))
1703        .and_then(|path| ui_core::dnd::mime_for_path(std::path::Path::new(path)))
1704        == Some(tilo_content::RECORDING.mime)
1705}
1706
1707/// The playback handoff the session handed this process at its start.
1708#[cfg(tilo_native_runner)]
1709fn process_handoff() -> Option<ui_core::application::PlaybackHandoff> {
1710    let position_ms = std::env::var("TILO_MEDIA_HANDOFF_POSITION_MS")
1711        .ok()?
1712        .parse()
1713        .ok()?;
1714    Some(ui_core::application::PlaybackHandoff {
1715        position_ms,
1716        resume_playing: std::env::var("TILO_MEDIA_HANDOFF_RESUME_PLAYING")
1717            .is_ok_and(|value| value == "1"),
1718        title: std::env::var("TILO_MEDIA_HANDOFF_TITLE")
1719            .ok()
1720            .filter(|title| !title.trim().is_empty()),
1721    })
1722}
1723
1724/// The options of `A`'s own that `args` named, in declaration order.
1725#[cfg(tilo_native_runner)]
1726fn launch_options(app: &AppHooks, args: &LaunchArgs) -> Vec<ui_core::application::LaunchOption> {
1727    let options = app.options.iter().filter_map(|&name| {
1728        args.value(name)
1729            .map(|value| ui_core::application::LaunchOption {
1730                name: name.to_owned(),
1731                value: Some(value.to_owned()),
1732            })
1733    });
1734    let flags = app
1735        .flags
1736        .iter()
1737        .filter(|&&name| args.has(name))
1738        .map(|&name| ui_core::application::LaunchOption {
1739            name: name.to_owned(),
1740            value: None,
1741        });
1742    options.chain(flags).collect()
1743}
1744
1745/// Hand a prepared component what its launch named: the arguments, and the
1746/// file the first one names when the app opens its type and it is a path
1747/// rather than a URL.
1748#[cfg(tilo_native_runner)]
1749pub(crate) fn hand_launch(
1750    definition: &ApplicationDefinition,
1751    arguments: &[String],
1752    prepared: &mut PreparedComponentInputs,
1753) -> Result<(), LaunchError> {
1754    hand_launch_to(definition, arguments, &mut prepared.context)
1755}
1756
1757/// Hand `context` what a launch naming `arguments` names: the arguments,
1758/// and on a device with paths the file the first one names when the app
1759/// opens its type.
1760#[cfg_attr(
1761    not(tilo_native_runner),
1762    expect(
1763        clippy::unnecessary_wraps,
1764        reason = "only a device with paths reads a file"
1765    )
1766)]
1767fn hand_launch_to(
1768    definition: &ApplicationDefinition,
1769    arguments: &[String],
1770    context: &mut HostContext,
1771) -> Result<(), LaunchError> {
1772    // A URL (`https://…`, `file://…`) is an argument for the app to resolve,
1773    // not a path on this device.
1774    #[cfg(tilo_native_runner)]
1775    if let Some(path) = arguments
1776        .first()
1777        .filter(|argument| !argument.contains("://"))
1778        .map(std::path::Path::new)
1779        && let Some(file) = launched_file(definition, path)?
1780    {
1781        context.provide_open_file(file);
1782    }
1783    #[cfg(not(tilo_native_runner))]
1784    let _ = definition;
1785    context.provide_launch_arguments(arguments.to_vec());
1786    Ok(())
1787}
1788
1789/// Open, in the window `reopener` belongs to, what a launch of `app` naming
1790/// `arguments` opens ([`ResourceApp::launch_record`], [`ResourceApp::launch`]):
1791/// the component built for that record takes the window, its own record and
1792/// history with it. The store's refusal of the record, an unreadable file
1793/// or the app's refusal to build leaves the window as it is, and is the
1794/// answer the returned outcome gives.
1795fn open_in_place(
1796    app: &'static AppHooks,
1797    reopener: &ui_core::application::Reopener,
1798    arguments: Vec<String>,
1799) -> ui_core::application::ReplacementOutcome {
1800    // Built when the host takes it: the record store is busy while the
1801    // asking view handles the event or operation that asked.
1802    reopener.replace_with(move |reopener| {
1803        let definition = (app.definition)();
1804        // A record named by its uri (one of the app's documents a list
1805        // offers) opens as a launch naming it does.
1806        let (named, rest) = record_argument(&arguments);
1807        let record = named
1808            .or_else(|| (app.launch_record)(&LaunchInput::new(rest, None)))
1809            .unwrap_or_else(RecordId::minted);
1810        let (mut context, target) = reopener.prepare(definition, record)?;
1811        hand_launch_to(definition, rest, &mut context)?;
1812        let component = definition.instantiate(context, target, RestoreInput::None)?;
1813        Ok(ui_core::application::ComponentReplacement::new(
1814            component, arguments,
1815        ))
1816    })
1817}
1818
1819/// The file at `path` to hand the component, when `definition` opens its
1820/// type; `None` for any other argument (a URL, a folder, another type).
1821#[cfg(tilo_native_runner)]
1822fn launched_file(
1823    definition: &ApplicationDefinition,
1824    path: &std::path::Path,
1825) -> Result<Option<ui_core::application::OpenFile>, LaunchError> {
1826    // A regular file whose name says nothing of its type is plain bytes.
1827    let Some(media_type) = ui_core::dnd::mime_for_path(path)
1828        .or_else(|| path.is_file().then_some("application/octet-stream"))
1829        .filter(|media_type| definition.opens_media_type(media_type))
1830    else {
1831        return Ok(None);
1832    };
1833    let name = path.file_name().map_or_else(
1834        || path.display().to_string(),
1835        |name| name.to_string_lossy().into_owned(),
1836    );
1837    let unreadable = |error: String| {
1838        LaunchError::Operation(format!("{} cannot be read: {error}", path.display()))
1839    };
1840    let bytes = tilo_files::file_at(path)
1841        .and_then(|(dir, file)| tilo_files::blocking::read(&dir, &file))
1842        .map_err(|error| unreadable(error.to_string()))?;
1843    let (file, _receipt) =
1844        ui_core::application::OpenFile::new(&name, media_type, bytes).map_err(unreadable)?;
1845    Ok(Some(file))
1846}
1847
1848#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
1849fn prepare_definition(
1850    definition: &ApplicationDefinition,
1851    args: &LaunchArgs,
1852) -> Result<PreparedComponentInputs, LaunchError> {
1853    let (record, _) = record_argument(args.positionals());
1854    prepare_named(definition, args, record, false)
1855}
1856
1857/// The record a launch names with a first argument
1858/// `tilo-record:<id>` ([`RecordId::uri`]), as the session launches an app to
1859/// run an action on one of its records, and the arguments after it.
1860fn record_argument(arguments: &[String]) -> (Option<RecordId>, &[String]) {
1861    match arguments.split_first() {
1862        Some((first, rest)) => match RecordId::from_uri(first) {
1863            Some(record) => (Some(record), rest),
1864            None => (None, arguments),
1865        },
1866        None => (None, arguments),
1867    }
1868}
1869
1870#[cfg(all(test, tilo_native_runner))]
1871mod record_argument_tests {
1872    use super::*;
1873
1874    #[test]
1875    fn a_record_uri_names_the_record_and_is_not_handed_to_the_app() {
1876        let record = RecordId::minted();
1877        let arguments = vec![record.uri(), "--then".to_owned()];
1878        let (named, rest) = record_argument(&arguments);
1879        assert_eq!(named, Some(record));
1880        assert_eq!(rest, ["--then".to_owned()]);
1881
1882        let plain = vec!["notes.md".to_owned()];
1883        assert_eq!(record_argument(&plain), (None, plain.as_slice()));
1884        assert_eq!(record_argument(&[]), (None, [].as_slice()));
1885    }
1886}
1887
1888#[cfg(tilo_native_runner)]
1889fn prepare_named(
1890    definition: &ApplicationDefinition,
1891    args: &LaunchArgs,
1892    named: Option<RecordId>,
1893    private: bool,
1894) -> Result<PreparedComponentInputs, LaunchError> {
1895    let inputs = ComponentRunnerInputs::from_native_process();
1896    // Captures, audits, headless driving, first-frame measurements and
1897    // private windows never touch the user's profile: they run on a private
1898    // one removed with the process, unless a headless drive or a
1899    // measurement names its profile with `--data-dir`.
1900    // A headless drive that names a scenario is a capture's window: built
1901    // from that scenario's fixtures, on a profile of its own.
1902    let captured = args.shots.is_some()
1903        || args.responsive_matrix.is_some()
1904        || (args.headless.is_some() && args.scenario.is_some());
1905    let isolated = private
1906        || captured
1907        || args.native_audit
1908        || ((args.headless.is_some() || args.first_frame) && args.value("--data-dir").is_none());
1909    if isolated {
1910        let profile = tempfile::Builder::new()
1911            .prefix("tilo-capture-")
1912            .tempdir()
1913            .map_err(super::headless::operation)?;
1914        let root = profile.path().join("state");
1915        let profile_root = profile.path().to_path_buf();
1916        let content = profile_content(profile.path());
1917        let mut host = ApplicationHost::new_with_profile_lifetime(
1918            root,
1919            "capture-device".into(),
1920            "capture-workspace",
1921            UserId("capture-author".into()),
1922            role_permissions("capture-author", Role::Owner),
1923            tilo_actions::ActionContext::default(),
1924            Rc::new(profile),
1925        )?;
1926        let mut prepared = prepare_on(&mut host, definition, CAPTURE_INSTANCE, named, inputs)?;
1927        prepared.context.provide_content(content);
1928        // An audit and a headless drive certify and drive the app someone
1929        // uses; only a screenshot, or a drive of a named scenario, is built
1930        // from fixtures.
1931        if captured {
1932            // A drive invokes what the scene publishes; a screenshot only
1933            // photographs it.
1934            let scenario = args.scenario.clone();
1935            let capture = if args.headless.is_some() {
1936                Capture::driven(scenario)
1937            } else {
1938                Capture::new(scenario)
1939            };
1940            prepared.context.provide_capture(capture);
1941            // The theme the capture draws in is the stored choice its
1942            // controls show.
1943            ui_core::storage::SettingsDocument::Profile(profile_root.clone())
1944                .update_section(
1945                    ui_core::theme::APPEARANCE_SECTION,
1946                    ui_core::launch::capture_appearance(args),
1947                )
1948                .map_err(super::headless::operation)?;
1949        }
1950        if private {
1951            prepared.context.provide_private();
1952        }
1953        return Ok(super::headless::in_profile(
1954            prepared,
1955            &profile_root,
1956            definition.app_id(),
1957        ));
1958    }
1959    if let Some(directory) = args.value("--data-dir") {
1960        let author = ui_core::collaboration::local_user_id();
1961        let mut host = data_dir_host(
1962            std::path::Path::new(directory),
1963            &author,
1964            role_permissions(&author.0, Role::Owner),
1965        )?;
1966        return prepare_on(&mut host, definition, DATA_DIR_INSTANCE, named, inputs).map(
1967            |mut prepared| {
1968                prepared
1969                    .context
1970                    .provide_content(super::headless::data_dir_content(directory.as_ref()));
1971                super::headless::in_profile(
1972                    prepared,
1973                    &super::headless::data_dir_root(directory.as_ref()),
1974                    definition.app_id(),
1975                )
1976            },
1977        );
1978    }
1979    let mut prepared = NativeProcessHost::prepare_window(definition, named, inputs)?;
1980    prepared
1981        .context
1982        .provide_content(tilo_content_device::for_app());
1983    Ok(prepared)
1984}
1985
1986/// The content store of a profile the launch keeps in `directory`: the
1987/// bytes its records name live with them, and go with them.
1988#[cfg(tilo_native_runner)]
1989pub(crate) fn profile_content(directory: &std::path::Path) -> tilo_content::ContentStore {
1990    tilo_content::ContentStore::in_dir(directory.join("content"))
1991}
1992
1993/// Take the launch's actions ([`ResourceApp::launch`]) as the live host's
1994/// first idle tick does, before anything else drives the harness.
1995#[cfg(tilo_native_runner)]
1996pub(crate) fn settle_launch(harness: &mut FrameHarness) {
1997    harness.wait_until(ui_core::time::Duration::ZERO, |_| true);
1998}
1999
2000#[cfg(not(target_arch = "wasm32"))]
2001fn capture<A: ResourceApp>(
2002    harness: &mut FrameHarness,
2003    args: &LaunchArgs,
2004) -> Result<(), LaunchError> {
2005    settle_launch(harness);
2006    super::stage::stage::<A>(harness, args.scenario.as_deref())
2007}
2008
2009#[cfg(not(target_arch = "wasm32"))]
2010fn shot_viewport<A: ResourceApp>(args: &LaunchArgs) -> (u32, u32) {
2011    A::shot_viewport(args.scenario.as_deref())
2012}
2013
2014#[cfg(tilo_native_runner)]
2015struct LiveOptionsKey<A>(std::marker::PhantomData<A>);
2016
2017/// The options a live launch of `A` takes: its profile, its commands and its
2018/// own options.
2019#[cfg(tilo_native_runner)]
2020fn live_options<A: ResourceApp>() -> &'static [&'static str] {
2021    leaked(TypeId::of::<LiveOptionsKey<A>>(), || {
2022        let mut options = vec!["--data-dir"];
2023        options.extend(A::commands().iter().map(|command| command.option));
2024        options.extend(A::OPTIONS);
2025        options
2026    })
2027    .as_slice()
2028}
2029
2030#[cfg(tilo_native_runner)]
2031struct LiveFlagsKey<A>(std::marker::PhantomData<A>);
2032
2033/// The switches a live launch of `A` takes: its own, and `--private` when it
2034/// opens private windows.
2035#[cfg(tilo_native_runner)]
2036fn live_flags<A: ResourceApp>() -> &'static [&'static str] {
2037    leaked(TypeId::of::<LiveFlagsKey<A>>(), || {
2038        let mut flags = A::FLAGS.to_vec();
2039        if A::PRIVATE_WINDOWS {
2040            flags.push(PRIVATE_FLAG);
2041        }
2042        flags
2043    })
2044    .as_slice()
2045}
2046
2047/// Run the command a launch of `A` names, if it names one.
2048#[cfg(tilo_native_runner)]
2049fn run_command<A: ResourceApp>(args: &LaunchArgs) -> Result<bool, LaunchError> {
2050    for command in A::commands() {
2051        if let Some(value) = args.value(command.option) {
2052            (command.run)(value)
2053                .map_err(|error| LaunchError::Operation(format!("{}: {error}", command.option)))?;
2054            return Ok(true);
2055        }
2056    }
2057    Ok(false)
2058}
2059
2060/// The flag that opens a private window of an app that offers them.
2061#[cfg(tilo_native_runner)]
2062const PRIVATE_FLAG: &str = "--private";
2063
2064#[cfg(tilo_native_runner)]
2065/// The launch specification of `A`, for apps that add options to it.
2066pub fn launch_spec<A: ResourceApp>() -> LaunchSpec {
2067    let spec = LaunchSpec::from_definition(definition::<A>())
2068        .component_host(prepare_app::<A>)
2069        .native_services()
2070        .capture_scenario(capture::<A>)
2071        .shot_viewport(shot_viewport::<A>)
2072        .scenarios(A::scenarios())
2073        .reads_host(A::READS_HOST)
2074        .max_positionals(A::ARGUMENTS)
2075        .command_handler(run_command::<A>)
2076        .allowed_options(&["--data-dir"])
2077        .allowed_live_options(live_options::<A>())
2078        .allowed_live_flags(live_flags::<A>());
2079    let spec = if !A::ATTACHES_RUNTIME {
2080        spec.runtime_attachment(|_| false)
2081    } else if A::PRIVATE_WINDOWS {
2082        // A private window is nobody's runtime participant: it neither syncs
2083        // nor shares what it shows.
2084        spec.runtime_attachment(|args| !args.has(PRIVATE_FLAG))
2085    } else {
2086        spec
2087    };
2088    // An input surface types into other apps' fields: nothing drives it
2089    // without a person. It is a session service the compositor starts
2090    // without a workspace, so it has no workspace runtime to attach to.
2091    if A::BEHAVIOUR.window == ui_core::application::WindowRole::InputSurface {
2092        spec.disable_headless().runtime_attachment(|_| false)
2093    } else {
2094        spec
2095    }
2096}
2097
2098#[cfg(not(tilo_native_runner))]
2099/// Named for the same apps that build a native [`LaunchSpec`]; the browser
2100/// host mounts [`definition`] itself and never runs this path.
2101pub fn launch_spec<A: ResourceApp>() -> crate::launch::LaunchSpec {
2102    crate::launch::LaunchSpec::from_definition(definition::<A>())
2103}
2104
2105#[cfg(feature = "native")]
2106/// The launch specification of any application definition: the standard
2107/// component host, record and launch modes, without a resource type's
2108/// capture scenarios.
2109#[cfg(not(target_arch = "wasm32"))]
2110pub fn definition_launch_spec(definition: &'static ApplicationDefinition) -> LaunchSpec {
2111    LaunchSpec::from_definition(definition)
2112        .component_host(prepare_definition)
2113        .native_services()
2114        .allowed_options(&["--data-dir"])
2115        .allowed_live_options(&["--data-dir"])
2116}
2117
2118/// Run `A`: the live window, or `--shots`, `--headless` and the other
2119/// standard launch modes. Needs the native runner (the default `native`
2120/// feature); a host without it drives [`launch_spec`] itself.
2121#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
2122pub fn run<A: ResourceApp>() {
2123    /// Print `report` and end the process: what `--surface` does. An item
2124    /// of its own, not generic over the app, so the JSON printer is
2125    /// compiled once rather than in every app's `run`.
2126    #[inline(never)]
2127    fn print_surface_report(report: &serde_json::Value) -> ! {
2128        let code = match serde_json::to_string_pretty(report) {
2129            Ok(report) => {
2130                println!("{report}");
2131                0
2132            }
2133            Err(error) => {
2134                eprintln!("Error: {error}");
2135                1
2136            }
2137        };
2138        #[allow(
2139            clippy::exit,
2140            reason = "the surface report is the whole run of the binary"
2141        )]
2142        std::process::exit(code);
2143    }
2144
2145    let args: Vec<String> = std::env::args().collect();
2146    if args.iter().skip(1).any(|arg| arg == SURFACE_FLAG) {
2147        print_surface_report(&super::headless::surface_report::<A>());
2148    }
2149    // A record search reads, with no window of this app: its document in,
2150    // what search finds in it out.
2151    if let Some(code) = super::record_index::serve::<A>() {
2152        #[allow(
2153            clippy::exit,
2154            reason = "the record reader is the whole run of the binary"
2155        )]
2156        std::process::exit(code);
2157    }
2158    if let Some(code) = run_helper::<A>(&args) {
2159        #[allow(
2160            clippy::exit,
2161            reason = "a helper the binary is ends the process with its own exit code"
2162        )]
2163        std::process::exit(code);
2164    }
2165    crate::launch::run_or_exit(&launch_spec::<A>());
2166}
2167
2168/// The flag that makes an app's binary print its
2169/// [`surface_report`](super::headless::surface_report)
2170/// instead of running: what `tilo-app surface` and `tilo-app check` read.
2171pub const SURFACE_FLAG: &str = "--surface";
2172
2173/// Run the first of `A`'s [helpers](ResourceApp::helpers) that `args` is
2174/// for, answering the exit code the process ends with; `None` when the
2175/// command line is the app's own.
2176pub fn run_helper<A: ResourceApp>(args: &[String]) -> Option<i32> {
2177    for helper in A::helpers() {
2178        match (helper.run)(args) {
2179            Ok(None) => {}
2180            Ok(Some(code)) => return Some(code),
2181            Err(error) => {
2182                eprintln!("Error: {error}");
2183                return Some(1);
2184            }
2185        }
2186    }
2187    None
2188}