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(
1398                ui_core::window_door::requested_reveal().or_else(|| revealed(target)),
1399            ),
1400        })
1401        .opening_with(opener)
1402        .showing_file_history(
1403            files
1404                .clone()
1405                .map(|source| (source, (app.file_history_witness)())),
1406        );
1407    let editing = Editing::Live {
1408        permissions: context.live_permissions(),
1409        me: context.author().0.clone(),
1410        run,
1411    };
1412    Ok(Starting {
1413        replay,
1414        files,
1415        editing,
1416        created,
1417        capture,
1418    })
1419}
1420
1421/// Build the window over the record the launch started on: its root, the
1422/// document History its versions go to, and the step a replay opens at.
1423fn window(
1424    app: &'static AppHooks,
1425    mut context: HostContext,
1426    starting: Starting,
1427) -> Result<AppComponent, LaunchError> {
1428    let Starting {
1429        replay,
1430        files,
1431        editing,
1432        created,
1433        capture,
1434    } = starting;
1435    let root = match session_reading(app, &mut context, &editing)? {
1436        Some(hosted) => hosted,
1437        None => match (app.open)(&mut context, editing, capture, created)? {
1438            Ok(root) => root,
1439            Err(reason) => return Ok(unopened(app, context, &reason)),
1440        },
1441    };
1442    let hosted = context.hosted();
1443    let mut component = AppComponent::new(context, root, (app.action_registry)());
1444    component.set_behaviour(app.behaviour);
1445    // A window over the session's reading keeps no document here: its
1446    // versions are the session's record's, shown by the session, so the
1447    // component registers no document History (`one-app-model.md` §4).
1448    if app.document_history && !hosted {
1449        let history = (app.history)();
1450        match files {
1451            Some(files) => component.register_document_and_file_history(history, files),
1452            None => component.register_document_history(history),
1453        }
1454        .map_err(|error| LaunchError::Operation(error.to_string()))?;
1455    }
1456    // A replay opens at the recording's first step: the state the window
1457    // showed when the recording starts.
1458    if let Some(start) = replay.as_ref().and_then(|recording| recording.heads_at(0)) {
1459        for hash in start {
1460            component
1461                .open_history_at(ui_core::history_source::entry_id_from_hash(
1462                    &hash.to_string(),
1463                ))
1464                .map_err(|error| LaunchError::Operation(error.to_string()))?;
1465        }
1466    }
1467    Ok(component)
1468}
1469
1470/// The recording `file` holds, opened in a window of its own: a private
1471/// window on a record the launch created, which reads and is never written,
1472/// so the recording reaches nothing outside it
1473/// (`docs/architecture/session-replay.md`).
1474///
1475/// # Errors
1476/// An app that keeps no History, a window that is not private or not on a
1477/// new record, or bytes that are no recording.
1478fn replay_of(
1479    app: &AppHooks,
1480    context: &mut HostContext,
1481    file: &ui_core::application::OpenFile,
1482    created: bool,
1483) -> Result<tilo_resource::recording::Recording, LaunchError> {
1484    if !app.document_history {
1485        return Err(LaunchError::Operation(format!(
1486            "{} keeps no History to replay a recording in",
1487            app.title
1488        )));
1489    }
1490    if !context.is_private() || !created {
1491        return Err(LaunchError::Operation(
1492            "a recording opens only in a private window of its own".into(),
1493        ));
1494    }
1495    context.restrict_to_reading();
1496    tilo_resource::recording::Recording::load(
1497        file.bytes(),
1498        &tilo_resource::recording::Limits::default(),
1499    )
1500    .map_err(|error| LaunchError::Operation(error.to_string()))
1501}
1502
1503/// The item a launch on a record asked to show: the resource its `open`
1504/// names.
1505fn revealed(target: &LaunchTarget) -> Option<String> {
1506    match target {
1507        LaunchTarget::Record {
1508            reveal: Some(address),
1509            ..
1510        } if address.action == tilo_actions::remote::APPLICATION_OPEN_ACTION => {
1511            Some(address.resource.clone())
1512        }
1513        _ => None,
1514    }
1515}
1516
1517/// Join the content store the host lent a window to its record's route:
1518/// content the record references and this device lacks is asked of the
1519/// route, and content the app stores reaches the record's other readers.
1520/// A capture keeps its fixture's store as it is: it reaches nothing outside.
1521fn record_content(context: &mut HostContext) {
1522    if context.capture().is_some() {
1523        return;
1524    }
1525    let Some(local) = context.content().cloned() else {
1526        return;
1527    };
1528    let lane = ui_core::collaboration::record_content::ContentLane::of_window();
1529    // The window keeps its lane: another window of the app on this thread
1530    // reads its own, never this one's queue of wants.
1531    ui_core::collaboration::record_content::keep_lane(&context.wake_handle(), Arc::clone(&lane));
1532    // Whatever carries the record's content for the process (a browser
1533    // page's replication) asks for and hands over this window's too.
1534    let record = &context.record().id;
1535    let wire = if record.is_package_primary() {
1536        ""
1537    } else {
1538        record.as_str()
1539    };
1540    ui_core::collaboration::record_content::ContentLane::show_on_route(
1541        context.app_id(),
1542        wire,
1543        &lane,
1544    );
1545    context
1546        .provide_content(ui_core::collaboration::record_content::RecordContent::store(local, lane));
1547}
1548
1549/// Prepare a capture's scene before its view is built: the app's fixture,
1550/// then the versions other people wrote, merged as their changes arrive.
1551fn prepare_capture<A: ResourceApp>(
1552    context: &mut HostContext,
1553    scenario: Option<&str>,
1554) -> Result<Run, LaunchError> {
1555    let mut fixture =
1556        Fixture::<A::Resource>::new(scenario, &context.author().0, context.content().cloned());
1557    A::fixture(&mut fixture);
1558    if context.content().is_none()
1559        && let Some(content) = fixture.lent_content()
1560    {
1561        context.provide_content(content.clone());
1562    }
1563    if fixture.has_history() {
1564        merge_peer(context, &mut |peer| {
1565            fixture.write_history(peer, &action_registry::<A>())
1566        })?;
1567    }
1568    Ok(fixture.into_run())
1569}
1570
1571/// Merge into the record `context` opened what `write` writes into a fork
1572/// of it, as another participant's changes arrive.
1573fn merge_peer(
1574    context: &HostContext,
1575    write: &mut dyn FnMut(&mut tilo_resource::Doc) -> Result<(), String>,
1576) -> Result<(), LaunchError> {
1577    let mut peer = context
1578        .read_record(|doc| doc.clone().fork())
1579        .map_err(LaunchError::Record)?;
1580    write(&mut peer).map_err(LaunchError::Operation)?;
1581    context
1582        .merge_peer_document(peer.save())
1583        .map_err(LaunchError::Operation)?;
1584    Ok(())
1585}
1586
1587#[cfg(not(target_arch = "wasm32"))]
1588const CAPTURE_INSTANCE: u64 = 1;
1589#[cfg(not(target_arch = "wasm32"))]
1590pub(crate) const DATA_DIR_INSTANCE: u64 = 0;
1591
1592#[cfg(not(target_arch = "wasm32"))]
1593/// The host of an explicit `--data-dir` profile: one personal workspace in
1594/// that directory. Tools that seed or inspect such a profile open it the same
1595/// way the app does.
1596pub(crate) fn data_dir_host(
1597    directory: &std::path::Path,
1598    author: &UserId,
1599    permissions: ui_core::permissions::SharedPermissions,
1600) -> Result<ApplicationHost, LaunchError> {
1601    let root = std::path::absolute(directory).map_err(super::headless::operation)?;
1602    ApplicationHost::new(
1603        root,
1604        "local-device".into(),
1605        "personal",
1606        author.clone(),
1607        permissions,
1608        tilo_actions::ActionContext::default(),
1609    )
1610}
1611
1612#[cfg(not(target_arch = "wasm32"))]
1613/// The permissions of `author` holding `role`, shared with the host so a
1614/// later role change reaches the running app.
1615pub(crate) fn role_permissions(
1616    author: &str,
1617    role: Role,
1618) -> ui_core::permissions::SharedPermissions {
1619    ui_core::permissions::SharedPermissions::new(PermissionContext::member(author, role))
1620}
1621
1622/// Prepare `definition` on `host`: the record a launch named, else the one
1623/// the instance showed last, else the workspace's default.
1624#[cfg(tilo_native_runner)]
1625pub(crate) fn prepare_on(
1626    host: &mut ApplicationHost,
1627    definition: &ApplicationDefinition,
1628    instance: u64,
1629    named: Option<RecordId>,
1630    inputs: ComponentRunnerInputs,
1631) -> Result<PreparedComponentInputs, LaunchError> {
1632    let kind = definition.record_kind.clone();
1633    match named {
1634        // A launch naming the workspace's own record (a notification about
1635        // something in it) opens it as a launch naming nothing does.
1636        Some(id) if !(id.is_package_primary() && definition.keeps_workspace_record()) => {
1637            host.prepare_record(definition, instance, RecordCheckout::new(id, kind), inputs)
1638        }
1639        _ => host.prepare_default(
1640            definition,
1641            instance,
1642            RecordCheckout::new(RecordId::workspace_default(), kind),
1643            None,
1644            inputs,
1645        ),
1646    }
1647}
1648
1649/// Prepare `A` for a launch: the record its arguments name, and the
1650/// arguments handed to the component for [`ResourceApp::launch`]. A first
1651/// argument naming a file of a type the app [opens](ResourceApp::OPENS) is
1652/// also handed over as that file, as a desktop handing it the file would.
1653#[cfg(tilo_native_runner)]
1654fn prepare_app<A: ResourceApp>(
1655    definition: &ApplicationDefinition,
1656    args: &LaunchArgs,
1657) -> Result<PreparedComponentInputs, LaunchError> {
1658    prepare_hooked(hooks::<A>(), definition, args)
1659}
1660
1661#[cfg(tilo_native_runner)]
1662fn prepare_hooked(
1663    app: &AppHooks,
1664    definition: &ApplicationDefinition,
1665    args: &LaunchArgs,
1666) -> Result<PreparedComponentInputs, LaunchError> {
1667    let (record, arguments) = record_argument(args.positionals());
1668    let options = launch_options(app, args);
1669    let named = record
1670        .or_else(|| (app.launch_record)(&LaunchInput::new(arguments, None).with_options(&options)));
1671    // A recording replays in a private window: nothing it shows reaches
1672    // the person's records, devices or collaborators.
1673    let private = (app.private_windows && args.has(PRIVATE_FLAG)) || names_recording(arguments);
1674    let mut prepared = prepare_named(definition, args, named, private)?;
1675    // A window someone looks at shows before its record. What drives or photographs one acts
1676    // on the record from its first step: it waits for it.
1677    let drives = args.headless.is_some()
1678        || args.shots.is_some()
1679        || args.responsive_matrix.is_some()
1680        || args.native_audit;
1681    if drives && let Some(record) = prepared.context.record_opening() {
1682        record.take_in(true).map_err(LaunchError::Record)?;
1683    }
1684    hand_launch(definition, arguments, &mut prepared)?;
1685    prepared.context.provide_launch_options(options);
1686    // The session launches the target of a playback handoff with where it
1687    // stood in its environment; a capture continues nothing, and neither does
1688    // a later window the shell opens in the process.
1689    if args.shots.is_none()
1690        && ui_core::window_door::requested_instance().is_none()
1691        && let Some(handoff) = process_handoff()
1692    {
1693        prepared.context.provide_handoff(handoff);
1694    }
1695    Ok(prepared)
1696}
1697
1698/// Whether the launch names a recording to replay: a first argument that
1699/// is a path to one.
1700#[cfg(tilo_native_runner)]
1701fn names_recording(arguments: &[String]) -> bool {
1702    arguments
1703        .first()
1704        .filter(|argument| !argument.contains("://"))
1705        .and_then(|path| ui_core::dnd::mime_for_path(std::path::Path::new(path)))
1706        == Some(tilo_content::RECORDING.mime)
1707}
1708
1709/// The playback handoff the session handed this process at its start.
1710#[cfg(tilo_native_runner)]
1711fn process_handoff() -> Option<ui_core::application::PlaybackHandoff> {
1712    let position_ms = std::env::var("TILO_MEDIA_HANDOFF_POSITION_MS")
1713        .ok()?
1714        .parse()
1715        .ok()?;
1716    Some(ui_core::application::PlaybackHandoff {
1717        position_ms,
1718        resume_playing: std::env::var("TILO_MEDIA_HANDOFF_RESUME_PLAYING")
1719            .is_ok_and(|value| value == "1"),
1720        title: std::env::var("TILO_MEDIA_HANDOFF_TITLE")
1721            .ok()
1722            .filter(|title| !title.trim().is_empty()),
1723    })
1724}
1725
1726/// The options of `A`'s own that `args` named, in declaration order.
1727#[cfg(tilo_native_runner)]
1728fn launch_options(app: &AppHooks, args: &LaunchArgs) -> Vec<ui_core::application::LaunchOption> {
1729    let options = app.options.iter().filter_map(|&name| {
1730        args.value(name)
1731            .map(|value| ui_core::application::LaunchOption {
1732                name: name.to_owned(),
1733                value: Some(value.to_owned()),
1734            })
1735    });
1736    let flags = app
1737        .flags
1738        .iter()
1739        .filter(|&&name| args.has(name))
1740        .map(|&name| ui_core::application::LaunchOption {
1741            name: name.to_owned(),
1742            value: None,
1743        });
1744    options.chain(flags).collect()
1745}
1746
1747/// Hand a prepared component what its launch named: the arguments, and the
1748/// file the first one names when the app opens its type and it is a path
1749/// rather than a URL.
1750#[cfg(tilo_native_runner)]
1751pub(crate) fn hand_launch(
1752    definition: &ApplicationDefinition,
1753    arguments: &[String],
1754    prepared: &mut PreparedComponentInputs,
1755) -> Result<(), LaunchError> {
1756    hand_launch_to(definition, arguments, &mut prepared.context)
1757}
1758
1759/// Hand `context` what a launch naming `arguments` names: the arguments,
1760/// and on a device with paths the file the first one names when the app
1761/// opens its type.
1762#[cfg_attr(
1763    not(tilo_native_runner),
1764    expect(
1765        clippy::unnecessary_wraps,
1766        reason = "only a device with paths reads a file"
1767    )
1768)]
1769fn hand_launch_to(
1770    definition: &ApplicationDefinition,
1771    arguments: &[String],
1772    context: &mut HostContext,
1773) -> Result<(), LaunchError> {
1774    // A URL (`https://…`, `file://…`) is an argument for the app to resolve,
1775    // not a path on this device.
1776    #[cfg(tilo_native_runner)]
1777    if let Some(path) = arguments
1778        .first()
1779        .filter(|argument| !argument.contains("://"))
1780        .map(std::path::Path::new)
1781        && let Some(file) = launched_file(definition, path)?
1782    {
1783        context.provide_open_file(file);
1784    }
1785    #[cfg(not(tilo_native_runner))]
1786    let _ = definition;
1787    context.provide_launch_arguments(arguments.to_vec());
1788    Ok(())
1789}
1790
1791/// Open, in the window `reopener` belongs to, what a launch of `app` naming
1792/// `arguments` opens ([`ResourceApp::launch_record`], [`ResourceApp::launch`]):
1793/// the component built for that record takes the window, its own record and
1794/// history with it. The store's refusal of the record, an unreadable file
1795/// or the app's refusal to build leaves the window as it is, and is the
1796/// answer the returned outcome gives.
1797fn open_in_place(
1798    app: &'static AppHooks,
1799    reopener: &ui_core::application::Reopener,
1800    arguments: Vec<String>,
1801) -> ui_core::application::ReplacementOutcome {
1802    // Built when the host takes it: the record store is busy while the
1803    // asking view handles the event or operation that asked.
1804    reopener.replace_with(move |reopener| {
1805        let definition = (app.definition)();
1806        // A record named by its uri (one of the app's documents a list
1807        // offers) opens as a launch naming it does.
1808        let (named, rest) = record_argument(&arguments);
1809        let record = named
1810            .or_else(|| (app.launch_record)(&LaunchInput::new(rest, None)))
1811            .unwrap_or_else(RecordId::minted);
1812        let (mut context, target) = reopener.prepare(definition, record)?;
1813        hand_launch_to(definition, rest, &mut context)?;
1814        let component = definition.instantiate(context, target, RestoreInput::None)?;
1815        Ok(ui_core::application::ComponentReplacement::new(
1816            component, arguments,
1817        ))
1818    })
1819}
1820
1821/// The file at `path` to hand the component, when `definition` opens its
1822/// type; `None` for any other argument (a URL, a folder, another type).
1823#[cfg(tilo_native_runner)]
1824fn launched_file(
1825    definition: &ApplicationDefinition,
1826    path: &std::path::Path,
1827) -> Result<Option<ui_core::application::OpenFile>, LaunchError> {
1828    // A regular file whose name says nothing of its type is plain bytes.
1829    let Some(media_type) = ui_core::dnd::mime_for_path(path)
1830        .or_else(|| path.is_file().then_some("application/octet-stream"))
1831        .filter(|media_type| definition.opens_media_type(media_type))
1832    else {
1833        return Ok(None);
1834    };
1835    let name = path.file_name().map_or_else(
1836        || path.display().to_string(),
1837        |name| name.to_string_lossy().into_owned(),
1838    );
1839    let unreadable = |error: String| {
1840        LaunchError::Operation(format!("{} cannot be read: {error}", path.display()))
1841    };
1842    let bytes = tilo_files::file_at(path)
1843        .and_then(|(dir, file)| tilo_files::blocking::read(&dir, &file))
1844        .map_err(|error| unreadable(error.to_string()))?;
1845    let (file, _receipt) =
1846        ui_core::application::OpenFile::new(&name, media_type, bytes).map_err(unreadable)?;
1847    Ok(Some(file))
1848}
1849
1850#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
1851fn prepare_definition(
1852    definition: &ApplicationDefinition,
1853    args: &LaunchArgs,
1854) -> Result<PreparedComponentInputs, LaunchError> {
1855    let (record, _) = record_argument(args.positionals());
1856    prepare_named(definition, args, record, false)
1857}
1858
1859/// The record a launch names with a first argument
1860/// `tilo-record:<id>` ([`RecordId::uri`]), as the session launches an app to
1861/// run an action on one of its records, and the arguments after it.
1862fn record_argument(arguments: &[String]) -> (Option<RecordId>, &[String]) {
1863    match arguments.split_first() {
1864        Some((first, rest)) => match RecordId::from_uri(first) {
1865            Some(record) => (Some(record), rest),
1866            None => (None, arguments),
1867        },
1868        None => (None, arguments),
1869    }
1870}
1871
1872#[cfg(all(test, tilo_native_runner))]
1873mod record_argument_tests {
1874    use super::*;
1875
1876    #[test]
1877    fn a_record_uri_names_the_record_and_is_not_handed_to_the_app() {
1878        let record = RecordId::minted();
1879        let arguments = vec![record.uri(), "--then".to_owned()];
1880        let (named, rest) = record_argument(&arguments);
1881        assert_eq!(named, Some(record));
1882        assert_eq!(rest, ["--then".to_owned()]);
1883
1884        let plain = vec!["notes.md".to_owned()];
1885        assert_eq!(record_argument(&plain), (None, plain.as_slice()));
1886        assert_eq!(record_argument(&[]), (None, [].as_slice()));
1887    }
1888}
1889
1890#[cfg(tilo_native_runner)]
1891fn prepare_named(
1892    definition: &ApplicationDefinition,
1893    args: &LaunchArgs,
1894    named: Option<RecordId>,
1895    private: bool,
1896) -> Result<PreparedComponentInputs, LaunchError> {
1897    let inputs = ComponentRunnerInputs::from_native_process();
1898    // Captures, audits, headless driving, first-frame measurements and
1899    // private windows never touch the user's profile: they run on a private
1900    // one removed with the process, unless a headless drive or a
1901    // measurement names its profile with `--data-dir`.
1902    // A headless drive that names a scenario is a capture's window: built
1903    // from that scenario's fixtures, on a profile of its own.
1904    let captured = args.shots.is_some()
1905        || args.responsive_matrix.is_some()
1906        || (args.headless.is_some() && args.scenario.is_some());
1907    let isolated = private
1908        || captured
1909        || args.native_audit
1910        || ((args.headless.is_some() || args.first_frame) && args.value("--data-dir").is_none());
1911    if isolated {
1912        let profile = tempfile::Builder::new()
1913            .prefix("tilo-capture-")
1914            .tempdir()
1915            .map_err(super::headless::operation)?;
1916        let root = profile.path().join("state");
1917        let profile_root = profile.path().to_path_buf();
1918        let content = profile_content(profile.path());
1919        let mut host = ApplicationHost::new_with_profile_lifetime(
1920            root,
1921            "capture-device".into(),
1922            "capture-workspace",
1923            UserId("capture-author".into()),
1924            role_permissions("capture-author", Role::Owner),
1925            tilo_actions::ActionContext::default(),
1926            Rc::new(profile),
1927        )?;
1928        let mut prepared = prepare_on(&mut host, definition, CAPTURE_INSTANCE, named, inputs)?;
1929        prepared.context.provide_content(content);
1930        // An audit and a headless drive certify and drive the app someone
1931        // uses; only a screenshot, or a drive of a named scenario, is built
1932        // from fixtures.
1933        if captured {
1934            // A drive invokes what the scene publishes; a screenshot only
1935            // photographs it.
1936            let scenario = args.scenario.clone();
1937            let capture = if args.headless.is_some() {
1938                Capture::driven(scenario)
1939            } else {
1940                Capture::new(scenario)
1941            };
1942            prepared.context.provide_capture(capture);
1943            // The theme the capture draws in is the stored choice its
1944            // controls show.
1945            ui_core::storage::SettingsDocument::Profile(profile_root.clone())
1946                .update_section(
1947                    ui_core::theme::APPEARANCE_SECTION,
1948                    ui_core::launch::capture_appearance(args),
1949                )
1950                .map_err(super::headless::operation)?;
1951        }
1952        if private {
1953            prepared.context.provide_private();
1954        }
1955        return Ok(super::headless::in_profile(
1956            prepared,
1957            &profile_root,
1958            definition.app_id(),
1959        ));
1960    }
1961    if let Some(directory) = args.value("--data-dir") {
1962        let author = ui_core::collaboration::local_user_id();
1963        let mut host = data_dir_host(
1964            std::path::Path::new(directory),
1965            &author,
1966            role_permissions(&author.0, Role::Owner),
1967        )?;
1968        return prepare_on(&mut host, definition, DATA_DIR_INSTANCE, named, inputs).map(
1969            |mut prepared| {
1970                prepared
1971                    .context
1972                    .provide_content(super::headless::data_dir_content(directory.as_ref()));
1973                super::headless::in_profile(
1974                    prepared,
1975                    &super::headless::data_dir_root(directory.as_ref()),
1976                    definition.app_id(),
1977                )
1978            },
1979        );
1980    }
1981    let mut prepared = NativeProcessHost::prepare_window(definition, named, inputs)?;
1982    prepared
1983        .context
1984        .provide_content(tilo_content_device::for_app());
1985    Ok(prepared)
1986}
1987
1988/// The content store of a profile the launch keeps in `directory`: the
1989/// bytes its records name live with them, and go with them.
1990#[cfg(tilo_native_runner)]
1991pub(crate) fn profile_content(directory: &std::path::Path) -> tilo_content::ContentStore {
1992    tilo_content::ContentStore::in_dir(directory.join("content"))
1993}
1994
1995/// Take the launch's actions ([`ResourceApp::launch`]) as the live host's
1996/// first idle tick does, before anything else drives the harness.
1997#[cfg(tilo_native_runner)]
1998pub(crate) fn settle_launch(harness: &mut FrameHarness) {
1999    harness.wait_until(ui_core::time::Duration::ZERO, |_| true);
2000}
2001
2002#[cfg(not(target_arch = "wasm32"))]
2003fn capture<A: ResourceApp>(
2004    harness: &mut FrameHarness,
2005    args: &LaunchArgs,
2006) -> Result<(), LaunchError> {
2007    settle_launch(harness);
2008    super::stage::stage::<A>(harness, args.scenario.as_deref())
2009}
2010
2011#[cfg(not(target_arch = "wasm32"))]
2012fn shot_viewport<A: ResourceApp>(args: &LaunchArgs) -> (u32, u32) {
2013    A::shot_viewport(args.scenario.as_deref())
2014}
2015
2016#[cfg(tilo_native_runner)]
2017struct LiveOptionsKey<A>(std::marker::PhantomData<A>);
2018
2019/// The options a live launch of `A` takes: its profile, its commands and its
2020/// own options.
2021#[cfg(tilo_native_runner)]
2022fn live_options<A: ResourceApp>() -> &'static [&'static str] {
2023    leaked(TypeId::of::<LiveOptionsKey<A>>(), || {
2024        let mut options = vec!["--data-dir"];
2025        options.extend(A::commands().iter().map(|command| command.option));
2026        options.extend(A::OPTIONS);
2027        options
2028    })
2029    .as_slice()
2030}
2031
2032#[cfg(tilo_native_runner)]
2033struct LiveFlagsKey<A>(std::marker::PhantomData<A>);
2034
2035/// The switches a live launch of `A` takes: its own, and `--private` when it
2036/// opens private windows.
2037#[cfg(tilo_native_runner)]
2038fn live_flags<A: ResourceApp>() -> &'static [&'static str] {
2039    leaked(TypeId::of::<LiveFlagsKey<A>>(), || {
2040        let mut flags = A::FLAGS.to_vec();
2041        if A::PRIVATE_WINDOWS {
2042            flags.push(PRIVATE_FLAG);
2043        }
2044        flags
2045    })
2046    .as_slice()
2047}
2048
2049/// Run the command a launch of `A` names, if it names one.
2050#[cfg(tilo_native_runner)]
2051fn run_command<A: ResourceApp>(args: &LaunchArgs) -> Result<bool, LaunchError> {
2052    for command in A::commands() {
2053        if let Some(value) = args.value(command.option) {
2054            (command.run)(value)
2055                .map_err(|error| LaunchError::Operation(format!("{}: {error}", command.option)))?;
2056            return Ok(true);
2057        }
2058    }
2059    Ok(false)
2060}
2061
2062/// The flag that opens a private window of an app that offers them.
2063#[cfg(tilo_native_runner)]
2064const PRIVATE_FLAG: &str = "--private";
2065
2066#[cfg(tilo_native_runner)]
2067/// The launch specification of `A`, for apps that add options to it.
2068pub fn launch_spec<A: ResourceApp>() -> LaunchSpec {
2069    let spec = LaunchSpec::from_definition(definition::<A>())
2070        .component_host(prepare_app::<A>)
2071        .native_services()
2072        .capture_scenario(capture::<A>)
2073        .shot_viewport(shot_viewport::<A>)
2074        .scenarios(A::scenarios())
2075        .reads_host(A::READS_HOST)
2076        .max_positionals(A::ARGUMENTS)
2077        .command_handler(run_command::<A>)
2078        .allowed_options(&["--data-dir"])
2079        .allowed_live_options(live_options::<A>())
2080        .allowed_live_flags(live_flags::<A>());
2081    let spec = if !A::ATTACHES_RUNTIME {
2082        spec.runtime_attachment(|_| false)
2083    } else if A::PRIVATE_WINDOWS {
2084        // A private window is nobody's runtime participant: it neither syncs
2085        // nor shares what it shows.
2086        spec.runtime_attachment(|args| !args.has(PRIVATE_FLAG))
2087    } else {
2088        spec
2089    };
2090    // An input surface types into other apps' fields: nothing drives it
2091    // without a person. It is a session service the compositor starts
2092    // without a workspace, so it has no workspace runtime to attach to.
2093    if A::BEHAVIOUR.window == ui_core::application::WindowRole::InputSurface {
2094        spec.disable_headless().runtime_attachment(|_| false)
2095    } else {
2096        spec
2097    }
2098}
2099
2100#[cfg(not(tilo_native_runner))]
2101/// Named for the same apps that build a native [`LaunchSpec`]; the browser
2102/// host mounts [`definition`] itself and never runs this path.
2103pub fn launch_spec<A: ResourceApp>() -> crate::launch::LaunchSpec {
2104    crate::launch::LaunchSpec::from_definition(definition::<A>())
2105}
2106
2107#[cfg(feature = "native")]
2108/// The launch specification of any application definition: the standard
2109/// component host, record and launch modes, without a resource type's
2110/// capture scenarios.
2111#[cfg(not(target_arch = "wasm32"))]
2112pub fn definition_launch_spec(definition: &'static ApplicationDefinition) -> LaunchSpec {
2113    LaunchSpec::from_definition(definition)
2114        .component_host(prepare_definition)
2115        .native_services()
2116        .allowed_options(&["--data-dir"])
2117        .allowed_live_options(&["--data-dir"])
2118}
2119
2120/// Run `A`: the live window, or `--shots`, `--headless` and the other
2121/// standard launch modes. Needs the native runner (the default `native`
2122/// feature); a host without it drives [`launch_spec`] itself.
2123#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
2124pub fn run<A: ResourceApp>() {
2125    /// Print `report` and end the process: what `--surface` does. An item
2126    /// of its own, not generic over the app, so the JSON printer is
2127    /// compiled once rather than in every app's `run`.
2128    #[inline(never)]
2129    fn print_surface_report(report: &serde_json::Value) -> ! {
2130        let code = match serde_json::to_string_pretty(report) {
2131            Ok(report) => {
2132                println!("{report}");
2133                0
2134            }
2135            Err(error) => {
2136                eprintln!("Error: {error}");
2137                1
2138            }
2139        };
2140        #[allow(
2141            clippy::exit,
2142            reason = "the surface report is the whole run of the binary"
2143        )]
2144        std::process::exit(code);
2145    }
2146
2147    let args: Vec<String> = std::env::args().collect();
2148    if args.iter().skip(1).any(|arg| arg == SURFACE_FLAG) {
2149        print_surface_report(&super::headless::surface_report::<A>());
2150    }
2151    // A record search reads, with no window of this app: its document in,
2152    // what search finds in it out.
2153    if let Some(code) = super::record_index::serve::<A>() {
2154        #[allow(
2155            clippy::exit,
2156            reason = "the record reader is the whole run of the binary"
2157        )]
2158        std::process::exit(code);
2159    }
2160    if let Some(code) = run_helper::<A>(&args) {
2161        #[allow(
2162            clippy::exit,
2163            reason = "a helper the binary is ends the process with its own exit code"
2164        )]
2165        std::process::exit(code);
2166    }
2167    crate::launch::run_or_exit(&launch_spec::<A>());
2168}
2169
2170/// The flag that makes an app's binary print its
2171/// [`surface_report`](super::headless::surface_report)
2172/// instead of running: what `tilo-app surface` and `tilo-app check` read.
2173pub const SURFACE_FLAG: &str = "--surface";
2174
2175/// Run the first of `A`'s [helpers](ResourceApp::helpers) that `args` is
2176/// for, answering the exit code the process ends with; `None` when the
2177/// command line is the app's own.
2178pub fn run_helper<A: ResourceApp>(args: &[String]) -> Option<i32> {
2179    for helper in A::helpers() {
2180        match (helper.run)(args) {
2181            Ok(None) => {}
2182            Ok(Some(code)) => return Some(code),
2183            Err(error) => {
2184                eprintln!("Error: {error}");
2185                return Some(1);
2186            }
2187        }
2188    }
2189    None
2190}