1use 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#[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#[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
51pub trait ResourceApp: 'static {
57 type Resource: Resource;
59 const ID: &'static str;
61 const TITLE: &'static str;
63 const ICON: Option<&'static str> = None;
65 const BEHAVIOUR: ComponentBehaviour = ComponentBehaviour::DEFAULT;
72 const OPENS: &'static [&'static str] = &[];
80 const ARGUMENTS: usize = 0;
83 const DOCUMENT_HISTORY: bool = true;
88 const WORKSPACE_RECORD: bool = false;
96 const WINDOW_RESOURCE: bool = false;
106 const OPTIONS: &'static [&'static str] = &[];
112 const FLAGS: &'static [&'static str] = &[];
115 const PRIVATE_WINDOWS: bool = false;
122 const FILE_HISTORY: Option<FileHistory> = None;
128 const READS_HOST: &'static [ui_core::launch::HostSource] = &[];
134
135 fn named() -> Option<AppName> {
140 None
141 }
142
143 fn member_record() -> bool {
149 false
150 }
151
152 fn import_record(context: &HostContext) -> Result<(), String> {
158 let _ = context;
159 Ok(())
160 }
161
162 fn view(cx: &mut ViewCx<Self::Resource>) -> Element;
166
167 const OPENS_BEFORE_RECORD: bool = false;
173
174 fn opening(cx: &OpeningCx<'_>) -> Option<Box<dyn OpeningView>> {
181 let _ = cx;
182 None
183 }
184
185 fn label(state: &<Self::Resource as Resource>::State) -> String {
187 let _ = state;
188 <Self::Resource as Resource>::LABEL.to_owned()
189 }
190
191 fn local_operations() -> Vec<tilo_actions::ActionSpec> {
197 Vec::new()
198 }
199
200 fn launch_record(input: &LaunchInput<'_>) -> Option<RecordId> {
208 let _ = input;
209 None
210 }
211
212 fn launch(input: &LaunchInput<'_>, created: bool) -> Vec<Invoke<Self::Resource>> {
227 let _ = (input, created);
228 Vec::new()
229 }
230
231 fn genesis(input: &LaunchInput<'_>) -> Vec<Invoke<Self::Resource>> {
239 let _ = input;
240 Vec::new()
241 }
242
243 fn commands() -> &'static [Command] {
245 &[]
246 }
247
248 fn helpers() -> &'static [Helper] {
251 &[]
252 }
253
254 const ATTACHES_RUNTIME: bool = true;
258
259 const HOSTED_BY_SESSION: bool = false;
270
271 fn catalog() -> Option<&'static EmbeddedCatalog> {
274 None
275 }
276
277 fn scenarios() -> &'static [&'static str] {
280 &[]
281 }
282
283 fn shot_viewport(scenario: Option<&str>) -> (u32, u32) {
287 let _ = scenario;
288 (1280, 800)
289 }
290
291 fn fixture(fixture: &mut super::Fixture<Self::Resource>) {
297 let _ = fixture;
298 }
299
300 fn scenario(name: Option<&str>) -> Vec<Invoke<Self::Resource>> {
306 let _ = name;
307 Vec::new()
308 }
309
310 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 fn seed(cx: &SeedCx<'_>) -> Vec<Invoke<Self::Resource>> {
337 let _ = cx;
338 Vec::new()
339 }
340
341 fn records(records: &mut super::Records<'_>) {
353 let _ = records;
354 }
355
356 const PERSON_RECORDS: &'static [super::PersonKind] = &[];
367}
368
369pub 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 #[must_use]
382 pub fn me(&self) -> &str {
383 self.me
384 }
385
386 #[must_use]
388 pub fn device(&self) -> &str {
389 self.device
390 }
391
392 #[must_use]
394 pub fn workspace(&self) -> &str {
395 &self.workspace
396 }
397
398 #[must_use]
401 pub fn files(&self) -> ui_core::storage::AppFiles {
402 self.files.clone()
403 }
404
405 #[must_use]
408 pub fn record(&self) -> &ui_core::record::RecordOpening {
409 &self.record
410 }
411}
412
413pub trait OpeningView: ui_core::widget::Widget {
416 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#[derive(Clone, Copy)]
429pub struct FileHistory {
430 pub title: &'static str,
432 pub preview: ui_core::history_mode::checked_files::FilePreviewFactory,
435}
436
437pub 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 pub fn me(&self) -> &str {
453 self.me
454 }
455
456 pub fn device(&self) -> &str {
458 self.device
459 }
460
461 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 pub fn host(&self) -> Option<&HostContext> {
474 self.host
475 }
476
477 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 entry
503 .downcast_ref::<T>()
504 .unwrap_or_else(|| unreachable_definition())
505}
506
507#[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
527pub 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 application_definition(hooks::<A>(), A::OPENS, !A::WINDOW_RESOURCE, create::<A>)
543}
544
545#[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#[derive(Clone, Copy, PartialEq, Eq)]
580enum Hosting {
581 Own,
583 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
602pub(crate) struct AppHooks {
606 id: &'static str,
607 title: &'static str,
608 icon: Option<&'static str>,
609 named: fn() -> Option<AppName>,
611 member_record: fn() -> bool,
613 pub(crate) kind: &'static str,
615 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 new_record: NewRecord,
634 prepare_capture: fn(&mut HostContext, Option<&str>) -> Result<Run, LaunchError>,
635 open: OpenRoot,
638 open_hosted: OpenHosted,
643 hosting: Hosting,
645 opens_before_record: bool,
647 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
654type NewRecord = fn(
656 &mut HostContext,
657 &[String],
658 Option<&ui_core::application::OpenFile>,
659 &[ui_core::application::LaunchOption],
660) -> Result<(), LaunchError>;
661
662type OpenHosted = fn(
665 &mut HostContext,
666 Editing,
667 super::Connection,
668 &str,
669) -> Result<Box<dyn ui_core::widget::AppWidget>, LaunchError>;
670
671type 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 pub(crate) fn id(&self) -> &'static str {
721 (self.named)().map_or(self.id, |named| named.id)
722 }
723
724 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
734pub(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
743pub(crate) enum Editing {
745 Live {
748 permissions: LivePermissions,
749 me: String,
750 run: Run,
751 },
752 ReadOnly,
754}
755
756struct RootShell {
759 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 fn runs_effects(&self) -> bool {
804 self.permissions.is_some() && self.run.mode.is_live()
805 }
806
807 fn runs_invoked_effects(&self) -> bool {
810 self.permissions.is_some() && self.run.driven
811 }
812}
813
814pub(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
825pub(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 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 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 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 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
915fn 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
922pub(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
932fn 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
953pub(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
973fn 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
993fn 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
1006fn 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 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
1052type RecordWrite<'a> = &'a mut dyn FnMut(&mut tilo_resource::Doc) -> Result<(), String>;
1054
1055fn 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
1068fn 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
1085fn 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
1134fn 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 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
1171fn 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
1200fn 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
1240pub(crate) fn hosted(context: &HostContext) -> super::settings::Hosted {
1242 super::settings::Hosted {
1243 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
1261fn 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 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 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
1322struct Starting {
1325 replay: Option<tilo_resource::recording::Recording>,
1327 files: Option<FileHistorySource>,
1329 editing: Editing,
1331 created: bool,
1333 capture: bool,
1335}
1336
1337fn start_record(
1340 app: &'static AppHooks,
1341 context: &mut HostContext,
1342 target: &LaunchTarget,
1343) -> Result<Starting, LaunchError> {
1344 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 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 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
1421fn 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 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 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
1470fn 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
1503fn 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
1517fn 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 ui_core::collaboration::record_content::keep_lane(&context.wake_handle(), Arc::clone(&lane));
1532 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
1549fn 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
1571fn 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"))]
1593pub(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"))]
1613pub(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#[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 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#[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 let private = (app.private_windows && args.has(PRIVATE_FLAG)) || names_recording(arguments);
1674 let mut prepared = prepare_named(definition, args, named, private)?;
1675 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 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#[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#[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#[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#[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#[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 #[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
1791fn open_in_place(
1798 app: &'static AppHooks,
1799 reopener: &ui_core::application::Reopener,
1800 arguments: Vec<String>,
1801) -> ui_core::application::ReplacementOutcome {
1802 reopener.replace_with(move |reopener| {
1805 let definition = (app.definition)();
1806 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#[cfg(tilo_native_runner)]
1824fn launched_file(
1825 definition: &ApplicationDefinition,
1826 path: &std::path::Path,
1827) -> Result<Option<ui_core::application::OpenFile>, LaunchError> {
1828 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
1859fn 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 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 if captured {
1934 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 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#[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#[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#[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#[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#[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#[cfg(tilo_native_runner)]
2064const PRIVATE_FLAG: &str = "--private";
2065
2066#[cfg(tilo_native_runner)]
2067pub 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 spec.runtime_attachment(|args| !args.has(PRIVATE_FLAG))
2087 } else {
2088 spec
2089 };
2090 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))]
2101pub fn launch_spec<A: ResourceApp>() -> crate::launch::LaunchSpec {
2104 crate::launch::LaunchSpec::from_definition(definition::<A>())
2105}
2106
2107#[cfg(feature = "native")]
2108#[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#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
2124pub fn run<A: ResourceApp>() {
2125 #[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 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
2170pub const SURFACE_FLAG: &str = "--surface";
2174
2175pub 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}