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(revealed(target)),
1398 })
1399 .opening_with(opener)
1400 .showing_file_history(
1401 files
1402 .clone()
1403 .map(|source| (source, (app.file_history_witness)())),
1404 );
1405 let editing = Editing::Live {
1406 permissions: context.live_permissions(),
1407 me: context.author().0.clone(),
1408 run,
1409 };
1410 Ok(Starting {
1411 replay,
1412 files,
1413 editing,
1414 created,
1415 capture,
1416 })
1417}
1418
1419fn window(
1422 app: &'static AppHooks,
1423 mut context: HostContext,
1424 starting: Starting,
1425) -> Result<AppComponent, LaunchError> {
1426 let Starting {
1427 replay,
1428 files,
1429 editing,
1430 created,
1431 capture,
1432 } = starting;
1433 let root = match session_reading(app, &mut context, &editing)? {
1434 Some(hosted) => hosted,
1435 None => match (app.open)(&mut context, editing, capture, created)? {
1436 Ok(root) => root,
1437 Err(reason) => return Ok(unopened(app, context, &reason)),
1438 },
1439 };
1440 let hosted = context.hosted();
1441 let mut component = AppComponent::new(context, root, (app.action_registry)());
1442 component.set_behaviour(app.behaviour);
1443 if app.document_history && !hosted {
1447 let history = (app.history)();
1448 match files {
1449 Some(files) => component.register_document_and_file_history(history, files),
1450 None => component.register_document_history(history),
1451 }
1452 .map_err(|error| LaunchError::Operation(error.to_string()))?;
1453 }
1454 if let Some(start) = replay.as_ref().and_then(|recording| recording.heads_at(0)) {
1457 for hash in start {
1458 component
1459 .open_history_at(ui_core::history_source::entry_id_from_hash(
1460 &hash.to_string(),
1461 ))
1462 .map_err(|error| LaunchError::Operation(error.to_string()))?;
1463 }
1464 }
1465 Ok(component)
1466}
1467
1468fn replay_of(
1477 app: &AppHooks,
1478 context: &mut HostContext,
1479 file: &ui_core::application::OpenFile,
1480 created: bool,
1481) -> Result<tilo_resource::recording::Recording, LaunchError> {
1482 if !app.document_history {
1483 return Err(LaunchError::Operation(format!(
1484 "{} keeps no History to replay a recording in",
1485 app.title
1486 )));
1487 }
1488 if !context.is_private() || !created {
1489 return Err(LaunchError::Operation(
1490 "a recording opens only in a private window of its own".into(),
1491 ));
1492 }
1493 context.restrict_to_reading();
1494 tilo_resource::recording::Recording::load(
1495 file.bytes(),
1496 &tilo_resource::recording::Limits::default(),
1497 )
1498 .map_err(|error| LaunchError::Operation(error.to_string()))
1499}
1500
1501fn revealed(target: &LaunchTarget) -> Option<String> {
1504 match target {
1505 LaunchTarget::Record {
1506 reveal: Some(address),
1507 ..
1508 } if address.action == tilo_actions::remote::APPLICATION_OPEN_ACTION => {
1509 Some(address.resource.clone())
1510 }
1511 _ => None,
1512 }
1513}
1514
1515fn record_content(context: &mut HostContext) {
1520 if context.capture().is_some() {
1521 return;
1522 }
1523 let Some(local) = context.content().cloned() else {
1524 return;
1525 };
1526 let lane = ui_core::collaboration::record_content::ContentLane::of_window();
1527 ui_core::collaboration::record_content::keep_lane(&context.wake_handle(), Arc::clone(&lane));
1530 let record = &context.record().id;
1533 let wire = if record.is_package_primary() {
1534 ""
1535 } else {
1536 record.as_str()
1537 };
1538 ui_core::collaboration::record_content::ContentLane::show_on_route(
1539 context.app_id(),
1540 wire,
1541 &lane,
1542 );
1543 context
1544 .provide_content(ui_core::collaboration::record_content::RecordContent::store(local, lane));
1545}
1546
1547fn prepare_capture<A: ResourceApp>(
1550 context: &mut HostContext,
1551 scenario: Option<&str>,
1552) -> Result<Run, LaunchError> {
1553 let mut fixture =
1554 Fixture::<A::Resource>::new(scenario, &context.author().0, context.content().cloned());
1555 A::fixture(&mut fixture);
1556 if context.content().is_none()
1557 && let Some(content) = fixture.lent_content()
1558 {
1559 context.provide_content(content.clone());
1560 }
1561 if fixture.has_history() {
1562 merge_peer(context, &mut |peer| {
1563 fixture.write_history(peer, &action_registry::<A>())
1564 })?;
1565 }
1566 Ok(fixture.into_run())
1567}
1568
1569fn merge_peer(
1572 context: &HostContext,
1573 write: &mut dyn FnMut(&mut tilo_resource::Doc) -> Result<(), String>,
1574) -> Result<(), LaunchError> {
1575 let mut peer = context
1576 .read_record(|doc| doc.clone().fork())
1577 .map_err(LaunchError::Record)?;
1578 write(&mut peer).map_err(LaunchError::Operation)?;
1579 context
1580 .merge_peer_document(peer.save())
1581 .map_err(LaunchError::Operation)?;
1582 Ok(())
1583}
1584
1585#[cfg(not(target_arch = "wasm32"))]
1586const CAPTURE_INSTANCE: u64 = 1;
1587#[cfg(not(target_arch = "wasm32"))]
1588pub(crate) const DATA_DIR_INSTANCE: u64 = 0;
1589
1590#[cfg(not(target_arch = "wasm32"))]
1591pub(crate) fn data_dir_host(
1595 directory: &std::path::Path,
1596 author: &UserId,
1597 permissions: ui_core::permissions::SharedPermissions,
1598) -> Result<ApplicationHost, LaunchError> {
1599 let root = std::path::absolute(directory).map_err(super::headless::operation)?;
1600 ApplicationHost::new(
1601 root,
1602 "local-device".into(),
1603 "personal",
1604 author.clone(),
1605 permissions,
1606 tilo_actions::ActionContext::default(),
1607 )
1608}
1609
1610#[cfg(not(target_arch = "wasm32"))]
1611pub(crate) fn role_permissions(
1614 author: &str,
1615 role: Role,
1616) -> ui_core::permissions::SharedPermissions {
1617 ui_core::permissions::SharedPermissions::new(PermissionContext::member(author, role))
1618}
1619
1620#[cfg(tilo_native_runner)]
1623pub(crate) fn prepare_on(
1624 host: &mut ApplicationHost,
1625 definition: &ApplicationDefinition,
1626 instance: u64,
1627 named: Option<RecordId>,
1628 inputs: ComponentRunnerInputs,
1629) -> Result<PreparedComponentInputs, LaunchError> {
1630 let kind = definition.record_kind.clone();
1631 match named {
1632 Some(id) if !(id.is_package_primary() && definition.keeps_workspace_record()) => {
1635 host.prepare_record(definition, instance, RecordCheckout::new(id, kind), inputs)
1636 }
1637 _ => host.prepare_default(
1638 definition,
1639 instance,
1640 RecordCheckout::new(RecordId::workspace_default(), kind),
1641 None,
1642 inputs,
1643 ),
1644 }
1645}
1646
1647#[cfg(tilo_native_runner)]
1652fn prepare_app<A: ResourceApp>(
1653 definition: &ApplicationDefinition,
1654 args: &LaunchArgs,
1655) -> Result<PreparedComponentInputs, LaunchError> {
1656 prepare_hooked(hooks::<A>(), definition, args)
1657}
1658
1659#[cfg(tilo_native_runner)]
1660fn prepare_hooked(
1661 app: &AppHooks,
1662 definition: &ApplicationDefinition,
1663 args: &LaunchArgs,
1664) -> Result<PreparedComponentInputs, LaunchError> {
1665 let (record, arguments) = record_argument(args.positionals());
1666 let options = launch_options(app, args);
1667 let named = record
1668 .or_else(|| (app.launch_record)(&LaunchInput::new(arguments, None).with_options(&options)));
1669 let private = (app.private_windows && args.has(PRIVATE_FLAG)) || names_recording(arguments);
1672 let mut prepared = prepare_named(definition, args, named, private)?;
1673 let drives = args.headless.is_some()
1676 || args.shots.is_some()
1677 || args.responsive_matrix.is_some()
1678 || args.native_audit;
1679 if drives && let Some(record) = prepared.context.record_opening() {
1680 record.take_in(true).map_err(LaunchError::Record)?;
1681 }
1682 hand_launch(definition, arguments, &mut prepared)?;
1683 prepared.context.provide_launch_options(options);
1684 if args.shots.is_none()
1688 && ui_core::window_door::requested_instance().is_none()
1689 && let Some(handoff) = process_handoff()
1690 {
1691 prepared.context.provide_handoff(handoff);
1692 }
1693 Ok(prepared)
1694}
1695
1696#[cfg(tilo_native_runner)]
1699fn names_recording(arguments: &[String]) -> bool {
1700 arguments
1701 .first()
1702 .filter(|argument| !argument.contains("://"))
1703 .and_then(|path| ui_core::dnd::mime_for_path(std::path::Path::new(path)))
1704 == Some(tilo_content::RECORDING.mime)
1705}
1706
1707#[cfg(tilo_native_runner)]
1709fn process_handoff() -> Option<ui_core::application::PlaybackHandoff> {
1710 let position_ms = std::env::var("TILO_MEDIA_HANDOFF_POSITION_MS")
1711 .ok()?
1712 .parse()
1713 .ok()?;
1714 Some(ui_core::application::PlaybackHandoff {
1715 position_ms,
1716 resume_playing: std::env::var("TILO_MEDIA_HANDOFF_RESUME_PLAYING")
1717 .is_ok_and(|value| value == "1"),
1718 title: std::env::var("TILO_MEDIA_HANDOFF_TITLE")
1719 .ok()
1720 .filter(|title| !title.trim().is_empty()),
1721 })
1722}
1723
1724#[cfg(tilo_native_runner)]
1726fn launch_options(app: &AppHooks, args: &LaunchArgs) -> Vec<ui_core::application::LaunchOption> {
1727 let options = app.options.iter().filter_map(|&name| {
1728 args.value(name)
1729 .map(|value| ui_core::application::LaunchOption {
1730 name: name.to_owned(),
1731 value: Some(value.to_owned()),
1732 })
1733 });
1734 let flags = app
1735 .flags
1736 .iter()
1737 .filter(|&&name| args.has(name))
1738 .map(|&name| ui_core::application::LaunchOption {
1739 name: name.to_owned(),
1740 value: None,
1741 });
1742 options.chain(flags).collect()
1743}
1744
1745#[cfg(tilo_native_runner)]
1749pub(crate) fn hand_launch(
1750 definition: &ApplicationDefinition,
1751 arguments: &[String],
1752 prepared: &mut PreparedComponentInputs,
1753) -> Result<(), LaunchError> {
1754 hand_launch_to(definition, arguments, &mut prepared.context)
1755}
1756
1757#[cfg_attr(
1761 not(tilo_native_runner),
1762 expect(
1763 clippy::unnecessary_wraps,
1764 reason = "only a device with paths reads a file"
1765 )
1766)]
1767fn hand_launch_to(
1768 definition: &ApplicationDefinition,
1769 arguments: &[String],
1770 context: &mut HostContext,
1771) -> Result<(), LaunchError> {
1772 #[cfg(tilo_native_runner)]
1775 if let Some(path) = arguments
1776 .first()
1777 .filter(|argument| !argument.contains("://"))
1778 .map(std::path::Path::new)
1779 && let Some(file) = launched_file(definition, path)?
1780 {
1781 context.provide_open_file(file);
1782 }
1783 #[cfg(not(tilo_native_runner))]
1784 let _ = definition;
1785 context.provide_launch_arguments(arguments.to_vec());
1786 Ok(())
1787}
1788
1789fn open_in_place(
1796 app: &'static AppHooks,
1797 reopener: &ui_core::application::Reopener,
1798 arguments: Vec<String>,
1799) -> ui_core::application::ReplacementOutcome {
1800 reopener.replace_with(move |reopener| {
1803 let definition = (app.definition)();
1804 let (named, rest) = record_argument(&arguments);
1807 let record = named
1808 .or_else(|| (app.launch_record)(&LaunchInput::new(rest, None)))
1809 .unwrap_or_else(RecordId::minted);
1810 let (mut context, target) = reopener.prepare(definition, record)?;
1811 hand_launch_to(definition, rest, &mut context)?;
1812 let component = definition.instantiate(context, target, RestoreInput::None)?;
1813 Ok(ui_core::application::ComponentReplacement::new(
1814 component, arguments,
1815 ))
1816 })
1817}
1818
1819#[cfg(tilo_native_runner)]
1822fn launched_file(
1823 definition: &ApplicationDefinition,
1824 path: &std::path::Path,
1825) -> Result<Option<ui_core::application::OpenFile>, LaunchError> {
1826 let Some(media_type) = ui_core::dnd::mime_for_path(path)
1828 .or_else(|| path.is_file().then_some("application/octet-stream"))
1829 .filter(|media_type| definition.opens_media_type(media_type))
1830 else {
1831 return Ok(None);
1832 };
1833 let name = path.file_name().map_or_else(
1834 || path.display().to_string(),
1835 |name| name.to_string_lossy().into_owned(),
1836 );
1837 let unreadable = |error: String| {
1838 LaunchError::Operation(format!("{} cannot be read: {error}", path.display()))
1839 };
1840 let bytes = tilo_files::file_at(path)
1841 .and_then(|(dir, file)| tilo_files::blocking::read(&dir, &file))
1842 .map_err(|error| unreadable(error.to_string()))?;
1843 let (file, _receipt) =
1844 ui_core::application::OpenFile::new(&name, media_type, bytes).map_err(unreadable)?;
1845 Ok(Some(file))
1846}
1847
1848#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
1849fn prepare_definition(
1850 definition: &ApplicationDefinition,
1851 args: &LaunchArgs,
1852) -> Result<PreparedComponentInputs, LaunchError> {
1853 let (record, _) = record_argument(args.positionals());
1854 prepare_named(definition, args, record, false)
1855}
1856
1857fn record_argument(arguments: &[String]) -> (Option<RecordId>, &[String]) {
1861 match arguments.split_first() {
1862 Some((first, rest)) => match RecordId::from_uri(first) {
1863 Some(record) => (Some(record), rest),
1864 None => (None, arguments),
1865 },
1866 None => (None, arguments),
1867 }
1868}
1869
1870#[cfg(all(test, tilo_native_runner))]
1871mod record_argument_tests {
1872 use super::*;
1873
1874 #[test]
1875 fn a_record_uri_names_the_record_and_is_not_handed_to_the_app() {
1876 let record = RecordId::minted();
1877 let arguments = vec![record.uri(), "--then".to_owned()];
1878 let (named, rest) = record_argument(&arguments);
1879 assert_eq!(named, Some(record));
1880 assert_eq!(rest, ["--then".to_owned()]);
1881
1882 let plain = vec!["notes.md".to_owned()];
1883 assert_eq!(record_argument(&plain), (None, plain.as_slice()));
1884 assert_eq!(record_argument(&[]), (None, [].as_slice()));
1885 }
1886}
1887
1888#[cfg(tilo_native_runner)]
1889fn prepare_named(
1890 definition: &ApplicationDefinition,
1891 args: &LaunchArgs,
1892 named: Option<RecordId>,
1893 private: bool,
1894) -> Result<PreparedComponentInputs, LaunchError> {
1895 let inputs = ComponentRunnerInputs::from_native_process();
1896 let captured = args.shots.is_some()
1903 || args.responsive_matrix.is_some()
1904 || (args.headless.is_some() && args.scenario.is_some());
1905 let isolated = private
1906 || captured
1907 || args.native_audit
1908 || ((args.headless.is_some() || args.first_frame) && args.value("--data-dir").is_none());
1909 if isolated {
1910 let profile = tempfile::Builder::new()
1911 .prefix("tilo-capture-")
1912 .tempdir()
1913 .map_err(super::headless::operation)?;
1914 let root = profile.path().join("state");
1915 let profile_root = profile.path().to_path_buf();
1916 let content = profile_content(profile.path());
1917 let mut host = ApplicationHost::new_with_profile_lifetime(
1918 root,
1919 "capture-device".into(),
1920 "capture-workspace",
1921 UserId("capture-author".into()),
1922 role_permissions("capture-author", Role::Owner),
1923 tilo_actions::ActionContext::default(),
1924 Rc::new(profile),
1925 )?;
1926 let mut prepared = prepare_on(&mut host, definition, CAPTURE_INSTANCE, named, inputs)?;
1927 prepared.context.provide_content(content);
1928 if captured {
1932 let scenario = args.scenario.clone();
1935 let capture = if args.headless.is_some() {
1936 Capture::driven(scenario)
1937 } else {
1938 Capture::new(scenario)
1939 };
1940 prepared.context.provide_capture(capture);
1941 ui_core::storage::SettingsDocument::Profile(profile_root.clone())
1944 .update_section(
1945 ui_core::theme::APPEARANCE_SECTION,
1946 ui_core::launch::capture_appearance(args),
1947 )
1948 .map_err(super::headless::operation)?;
1949 }
1950 if private {
1951 prepared.context.provide_private();
1952 }
1953 return Ok(super::headless::in_profile(
1954 prepared,
1955 &profile_root,
1956 definition.app_id(),
1957 ));
1958 }
1959 if let Some(directory) = args.value("--data-dir") {
1960 let author = ui_core::collaboration::local_user_id();
1961 let mut host = data_dir_host(
1962 std::path::Path::new(directory),
1963 &author,
1964 role_permissions(&author.0, Role::Owner),
1965 )?;
1966 return prepare_on(&mut host, definition, DATA_DIR_INSTANCE, named, inputs).map(
1967 |mut prepared| {
1968 prepared
1969 .context
1970 .provide_content(super::headless::data_dir_content(directory.as_ref()));
1971 super::headless::in_profile(
1972 prepared,
1973 &super::headless::data_dir_root(directory.as_ref()),
1974 definition.app_id(),
1975 )
1976 },
1977 );
1978 }
1979 let mut prepared = NativeProcessHost::prepare_window(definition, named, inputs)?;
1980 prepared
1981 .context
1982 .provide_content(tilo_content_device::for_app());
1983 Ok(prepared)
1984}
1985
1986#[cfg(tilo_native_runner)]
1989pub(crate) fn profile_content(directory: &std::path::Path) -> tilo_content::ContentStore {
1990 tilo_content::ContentStore::in_dir(directory.join("content"))
1991}
1992
1993#[cfg(tilo_native_runner)]
1996pub(crate) fn settle_launch(harness: &mut FrameHarness) {
1997 harness.wait_until(ui_core::time::Duration::ZERO, |_| true);
1998}
1999
2000#[cfg(not(target_arch = "wasm32"))]
2001fn capture<A: ResourceApp>(
2002 harness: &mut FrameHarness,
2003 args: &LaunchArgs,
2004) -> Result<(), LaunchError> {
2005 settle_launch(harness);
2006 super::stage::stage::<A>(harness, args.scenario.as_deref())
2007}
2008
2009#[cfg(not(target_arch = "wasm32"))]
2010fn shot_viewport<A: ResourceApp>(args: &LaunchArgs) -> (u32, u32) {
2011 A::shot_viewport(args.scenario.as_deref())
2012}
2013
2014#[cfg(tilo_native_runner)]
2015struct LiveOptionsKey<A>(std::marker::PhantomData<A>);
2016
2017#[cfg(tilo_native_runner)]
2020fn live_options<A: ResourceApp>() -> &'static [&'static str] {
2021 leaked(TypeId::of::<LiveOptionsKey<A>>(), || {
2022 let mut options = vec!["--data-dir"];
2023 options.extend(A::commands().iter().map(|command| command.option));
2024 options.extend(A::OPTIONS);
2025 options
2026 })
2027 .as_slice()
2028}
2029
2030#[cfg(tilo_native_runner)]
2031struct LiveFlagsKey<A>(std::marker::PhantomData<A>);
2032
2033#[cfg(tilo_native_runner)]
2036fn live_flags<A: ResourceApp>() -> &'static [&'static str] {
2037 leaked(TypeId::of::<LiveFlagsKey<A>>(), || {
2038 let mut flags = A::FLAGS.to_vec();
2039 if A::PRIVATE_WINDOWS {
2040 flags.push(PRIVATE_FLAG);
2041 }
2042 flags
2043 })
2044 .as_slice()
2045}
2046
2047#[cfg(tilo_native_runner)]
2049fn run_command<A: ResourceApp>(args: &LaunchArgs) -> Result<bool, LaunchError> {
2050 for command in A::commands() {
2051 if let Some(value) = args.value(command.option) {
2052 (command.run)(value)
2053 .map_err(|error| LaunchError::Operation(format!("{}: {error}", command.option)))?;
2054 return Ok(true);
2055 }
2056 }
2057 Ok(false)
2058}
2059
2060#[cfg(tilo_native_runner)]
2062const PRIVATE_FLAG: &str = "--private";
2063
2064#[cfg(tilo_native_runner)]
2065pub fn launch_spec<A: ResourceApp>() -> LaunchSpec {
2067 let spec = LaunchSpec::from_definition(definition::<A>())
2068 .component_host(prepare_app::<A>)
2069 .native_services()
2070 .capture_scenario(capture::<A>)
2071 .shot_viewport(shot_viewport::<A>)
2072 .scenarios(A::scenarios())
2073 .reads_host(A::READS_HOST)
2074 .max_positionals(A::ARGUMENTS)
2075 .command_handler(run_command::<A>)
2076 .allowed_options(&["--data-dir"])
2077 .allowed_live_options(live_options::<A>())
2078 .allowed_live_flags(live_flags::<A>());
2079 let spec = if !A::ATTACHES_RUNTIME {
2080 spec.runtime_attachment(|_| false)
2081 } else if A::PRIVATE_WINDOWS {
2082 spec.runtime_attachment(|args| !args.has(PRIVATE_FLAG))
2085 } else {
2086 spec
2087 };
2088 if A::BEHAVIOUR.window == ui_core::application::WindowRole::InputSurface {
2092 spec.disable_headless().runtime_attachment(|_| false)
2093 } else {
2094 spec
2095 }
2096}
2097
2098#[cfg(not(tilo_native_runner))]
2099pub fn launch_spec<A: ResourceApp>() -> crate::launch::LaunchSpec {
2102 crate::launch::LaunchSpec::from_definition(definition::<A>())
2103}
2104
2105#[cfg(feature = "native")]
2106#[cfg(not(target_arch = "wasm32"))]
2110pub fn definition_launch_spec(definition: &'static ApplicationDefinition) -> LaunchSpec {
2111 LaunchSpec::from_definition(definition)
2112 .component_host(prepare_definition)
2113 .native_services()
2114 .allowed_options(&["--data-dir"])
2115 .allowed_live_options(&["--data-dir"])
2116}
2117
2118#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
2122pub fn run<A: ResourceApp>() {
2123 #[inline(never)]
2127 fn print_surface_report(report: &serde_json::Value) -> ! {
2128 let code = match serde_json::to_string_pretty(report) {
2129 Ok(report) => {
2130 println!("{report}");
2131 0
2132 }
2133 Err(error) => {
2134 eprintln!("Error: {error}");
2135 1
2136 }
2137 };
2138 #[allow(
2139 clippy::exit,
2140 reason = "the surface report is the whole run of the binary"
2141 )]
2142 std::process::exit(code);
2143 }
2144
2145 let args: Vec<String> = std::env::args().collect();
2146 if args.iter().skip(1).any(|arg| arg == SURFACE_FLAG) {
2147 print_surface_report(&super::headless::surface_report::<A>());
2148 }
2149 if let Some(code) = super::record_index::serve::<A>() {
2152 #[allow(
2153 clippy::exit,
2154 reason = "the record reader is the whole run of the binary"
2155 )]
2156 std::process::exit(code);
2157 }
2158 if let Some(code) = run_helper::<A>(&args) {
2159 #[allow(
2160 clippy::exit,
2161 reason = "a helper the binary is ends the process with its own exit code"
2162 )]
2163 std::process::exit(code);
2164 }
2165 crate::launch::run_or_exit(&launch_spec::<A>());
2166}
2167
2168pub const SURFACE_FLAG: &str = "--surface";
2172
2173pub fn run_helper<A: ResourceApp>(args: &[String]) -> Option<i32> {
2177 for helper in A::helpers() {
2178 match (helper.run)(args) {
2179 Ok(None) => {}
2180 Ok(Some(code)) => return Some(code),
2181 Err(error) => {
2182 eprintln!("Error: {error}");
2183 return Some(1);
2184 }
2185 }
2186 }
2187 None
2188}