1use std::any::TypeId;
4use std::collections::HashMap;
5use std::rc::Rc;
6use std::sync::{Mutex, OnceLock};
7
8use tilo_resource::{Invoke, Reader, Resource};
9use ui_core::application::{
10 AppComponent, ApplicationDefinition, ComponentBehaviour, HostContext, LaunchTarget,
11 RestoreInput,
12};
13use ui_core::history_mode::checked_files::FileHistorySource;
14use ui_core::i18n::EmbeddedCatalog;
15use ui_core::launch::LaunchError;
16use ui_core::permissions::{ActionKind, LivePermissions, PermissionScope};
17use ui_core::record::{RecordId, RecordKind};
18use ui_core::storage::AppStorage;
19use ui_core::widget::{Element, IconDescriptor};
20
21#[cfg(not(target_arch = "wasm32"))]
24use {
25 std::cell::RefCell,
26 ui_core::application::{
27 ApplicationHost, Capture, ComponentRunnerInputs, NativeProcessHost, PreparedComponentInputs,
28 },
29 ui_core::headless::FrameHarness,
30 ui_core::launch::{LaunchArgs, LaunchSpec},
31 ui_core::permissions::{PermissionContext, Role, UserId},
32 ui_core::record::RecordCheckout,
33};
34
35use super::dispatch::Dispatcher;
36use super::effect::Effects;
37use super::launch::{Command, Helper, LaunchInput};
38use super::live::Live;
39use super::root::ResourceRoot;
40use super::scenario::{Fixture, Launched, Run};
41use super::view::ViewCx;
42
43pub trait ResourceApp: 'static {
49 type Resource: Resource;
51 const ID: &'static str;
53 const TITLE: &'static str;
55 const ICON: Option<&'static str> = None;
57 const BEHAVIOUR: ComponentBehaviour = ComponentBehaviour::DEFAULT;
64 const OPENS: &'static [&'static str] = &[];
72 const ARGUMENTS: usize = 0;
75 const DOCUMENT_HISTORY: bool = true;
80 const ROOM_RECORD: bool = false;
85 const OPTIONS: &'static [&'static str] = &[];
91 const FLAGS: &'static [&'static str] = &[];
94 const PRIVATE_WINDOWS: bool = false;
101 const FILE_HISTORY: Option<FileHistory> = None;
107
108 fn view(cx: &mut ViewCx<Self::Resource>) -> Element;
112
113 fn label(state: &<Self::Resource as Resource>::State) -> String {
115 let _ = state;
116 <Self::Resource as Resource>::LABEL.to_owned()
117 }
118
119 fn local_operations() -> Vec<tilo_actions::ActionSpec> {
125 Vec::new()
126 }
127
128 fn launch_record(input: &LaunchInput<'_>) -> Option<RecordId> {
136 let _ = input;
137 None
138 }
139
140 fn launch(input: &LaunchInput<'_>, created: bool) -> Vec<Invoke<Self::Resource>> {
155 let _ = (input, created);
156 Vec::new()
157 }
158
159 fn genesis(input: &LaunchInput<'_>) -> Vec<Invoke<Self::Resource>> {
167 let _ = input;
168 Vec::new()
169 }
170
171 fn commands() -> &'static [Command] {
173 &[]
174 }
175
176 fn helpers() -> &'static [Helper] {
179 &[]
180 }
181
182 const ATTACHES_RUNTIME: bool = true;
186
187 fn catalog() -> Option<&'static EmbeddedCatalog> {
190 None
191 }
192
193 fn scenarios() -> &'static [&'static str] {
196 &[]
197 }
198
199 fn shot_viewport(scenario: Option<&str>) -> (u32, u32) {
203 let _ = scenario;
204 (1280, 800)
205 }
206
207 fn fixture(fixture: &mut super::Fixture<Self::Resource>) {
213 let _ = fixture;
214 }
215
216 fn scenario(name: Option<&str>) -> Vec<Invoke<Self::Resource>> {
222 let _ = name;
223 Vec::new()
224 }
225
226 fn scenario_input(
236 name: Option<&str>,
237 input: &mut ui_core::headless::FrameHarness,
238 ) -> Result<(), String> {
239 let _ = (name, input);
240 Ok(())
241 }
242
243 fn seed(cx: &SeedCx<'_>) -> Vec<Invoke<Self::Resource>> {
256 let _ = cx;
257 Vec::new()
258 }
259
260 fn records(records: &mut super::Records<'_>) {
272 let _ = records;
273 }
274}
275
276#[derive(Clone, Copy)]
279pub struct FileHistory {
280 pub title: &'static str,
282 pub preview: ui_core::history_mode::checked_files::FilePreviewFactory,
285}
286
287pub struct SeedCx<'a> {
293 pub(crate) me: &'a str,
294 pub(crate) device: &'a str,
295 pub(crate) host: Option<&'a HostContext>,
296 pub(crate) room: Option<&'a str>,
297}
298
299impl SeedCx<'_> {
300 pub fn me(&self) -> &str {
303 self.me
304 }
305
306 pub fn device(&self) -> &str {
308 self.device
309 }
310
311 pub fn creation_workspace(&self) -> Option<(&str, &str)> {
315 self.host
316 .and_then(HostContext::creation_workspace)
317 .map(|workspace| (workspace.id.as_str(), workspace.name.as_str()))
318 .or_else(|| self.room.map(|room| (room, room)))
319 }
320
321 pub fn host(&self) -> Option<&HostContext> {
324 self.host
325 }
326}
327
328type Leaked = &'static (dyn std::any::Any + Send + Sync);
329
330pub(crate) fn leaked<T: Send + Sync + 'static>(
331 key: TypeId,
332 make: impl FnOnce() -> T,
333) -> &'static T {
334 static TABLE: OnceLock<Mutex<HashMap<TypeId, Leaked>>> = OnceLock::new();
335 let table = TABLE.get_or_init(Mutex::default);
336 let mut table = table
337 .lock()
338 .unwrap_or_else(std::sync::PoisonError::into_inner);
339 let entry = *table
340 .entry(key)
341 .or_insert_with(|| Box::leak(Box::new(make())) as Leaked);
342 drop(table);
343 entry
345 .downcast_ref::<T>()
346 .unwrap_or_else(|| unreachable_definition())
347}
348
349#[allow(
350 clippy::panic,
351 reason = "a keyed table entry of the wrong type is a framework bug"
352)]
353fn unreachable_definition() -> ! {
354 panic!("framework definition table holds a value of another type")
355}
356
357struct DefinitionKey<A>(std::marker::PhantomData<A>);
358
359pub fn definition<A: ResourceApp>() -> &'static ApplicationDefinition {
361 leaked(TypeId::of::<DefinitionKey<A>>(), || {
362 super::kinds::register::<A>();
363 ui_core::app_surface::register_kind(<A::Resource as Resource>::KIND, A::ID);
364 definition_of::<A>()
365 })
366}
367
368fn definition_of<A: ResourceApp>() -> ApplicationDefinition {
369 ApplicationDefinition {
370 app_id: A::ID,
371 title: A::TITLE,
372 icon: icon(hooks::<A>()),
373 record_kind: RecordKind::new(<A::Resource as Resource>::KIND),
374 required: vec![],
375 optional: vec![],
376 opens: A::OPENS,
377 room_record: A::ROOM_RECORD,
378 create: create::<A>,
379 }
380}
381
382fn icon(app: &AppHooks) -> IconDescriptor {
383 IconDescriptor::Named(
384 app.icon
385 .unwrap_or_else(|| app.id.rsplit('.').next().unwrap_or(app.id))
386 .to_owned(),
387 )
388}
389
390pub(crate) struct AppHooks {
394 pub(crate) id: &'static str,
395 pub(crate) title: &'static str,
396 icon: Option<&'static str>,
397 pub(crate) kind: &'static str,
399 label: &'static str,
401 behaviour: ComponentBehaviour,
402 document_history: bool,
403 file_history: Option<FileHistory>,
404 #[cfg(tilo_native_runner)]
405 private_windows: bool,
406 #[cfg(tilo_native_runner)]
407 options: &'static [&'static str],
408 #[cfg(tilo_native_runner)]
409 flags: &'static [&'static str],
410 catalog: fn() -> Option<&'static EmbeddedCatalog>,
411 definition: fn() -> &'static ApplicationDefinition,
412 launch_record: fn(&LaunchInput<'_>) -> Option<RecordId>,
413 local_operations: fn() -> Vec<tilo_actions::ActionSpec>,
414 declarations: fn() -> Rc<[tilo_resource::Declaration]>,
415 new_record: NewRecord,
418 prepare_capture: fn(&mut HostContext, Option<&str>) -> Result<Run, LaunchError>,
420 open: OpenRoot,
423 pub(crate) action_registry: fn() -> tilo_actions::ActionRegistry,
424 history: fn() -> &'static ui_core::history_mode::HistoryDefinition,
425 file_history_witness: fn() -> &'static ui_core::history_mode::HistoryDefinition,
426}
427
428type NewRecord = fn(
430 &mut HostContext,
431 &[String],
432 Option<&ui_core::application::OpenFile>,
433 &[ui_core::application::LaunchOption],
434) -> Result<(), LaunchError>;
435
436type OpenRoot = fn(
439 &mut HostContext,
440 Editing,
441 bool,
442 bool,
443) -> Result<Result<Box<dyn ui_core::widget::AppWidget>, String>, LaunchError>;
444
445struct Hooks<A>(std::marker::PhantomData<A>);
446
447impl<A: ResourceApp> Hooks<A> {
448 const HOOKS: AppHooks = AppHooks {
449 id: A::ID,
450 title: A::TITLE,
451 icon: A::ICON,
452 kind: <A::Resource as Resource>::KIND,
453 label: <A::Resource as Resource>::LABEL,
454 behaviour: A::BEHAVIOUR,
455 document_history: A::DOCUMENT_HISTORY,
456 file_history: A::FILE_HISTORY,
457 #[cfg(tilo_native_runner)]
458 private_windows: A::PRIVATE_WINDOWS,
459 #[cfg(tilo_native_runner)]
460 options: A::OPTIONS,
461 #[cfg(tilo_native_runner)]
462 flags: A::FLAGS,
463 catalog: A::catalog,
464 definition: definition::<A>,
465 launch_record: A::launch_record,
466 local_operations: A::local_operations,
467 declarations: declarations_of::<A::Resource>,
468 new_record: new_record::<A>,
469 prepare_capture: prepare_capture::<A>,
470 open: open_root::<A>,
471 action_registry: action_registry::<A>,
472 history: super::preview::history_definition::<A>,
473 file_history_witness: super::preview::file_history_witness::<A>,
474 };
475}
476
477pub(crate) fn hooks<A: ResourceApp>() -> &'static AppHooks {
479 &Hooks::<A>::HOOKS
480}
481
482fn declarations_of<R: Resource>() -> Rc<[tilo_resource::Declaration]> {
483 tilo_resource::vocabulary::<R>().declarations()
484}
485
486pub(crate) enum Editing {
488 Live {
491 permissions: LivePermissions,
492 me: String,
493 run: Run,
494 },
495 ReadOnly,
497}
498
499struct RootShell {
502 scope: PermissionScope,
504 permissions: Option<LivePermissions>,
505 read_only: bool,
506 me: Option<String>,
507 run: Run,
508 storage: Rc<super::settings::ViewStorage>,
509}
510
511impl RootShell {
512 fn new(
513 app: &AppHooks,
514 editing: Editing,
515 storage: AppStorage,
516 hosted: super::settings::Hosted,
517 ) -> Self {
518 let scope = PermissionScope::app(app.id, ActionKind::Undoable);
519 let (permissions, read_only, me, run) = match editing {
520 Editing::Live {
521 permissions,
522 me,
523 run,
524 } => {
525 let read_only = !permissions.check(&scope);
526 (Some(permissions), read_only, Some(me), run)
527 }
528 Editing::ReadOnly => (None, true, None, Run::past()),
529 };
530 let run = run.for_participant(me.as_deref());
531 let storage = super::settings::ViewStorage::new(storage, hosted.attach(app.id, app.title));
532 Self {
533 scope,
534 permissions,
535 read_only,
536 me,
537 run,
538 storage,
539 }
540 }
541
542 fn runs_effects(&self) -> bool {
546 self.permissions.is_some() && self.run.mode.is_live()
547 }
548}
549
550pub(crate) fn build_root<A: ResourceApp>(
553 reader: Reader<A::Resource>,
554 editing: Editing,
555 storage: AppStorage,
556 hosted: super::settings::Hosted,
557) -> ResourceRoot {
558 let app = hooks::<A>();
559 if let Some(shape) = <A::Resource as Resource>::shape() {
561 ui_core::history_source::sentence::learn_field_titles(&shape);
562 }
563 let shell = RootShell::new(app, editing, storage, hosted);
564 let runs_effects = shell.runs_effects();
565 let RootShell {
566 scope,
567 permissions,
568 read_only,
569 me,
570 run,
571 storage,
572 } = shell;
573 let label = A::label(reader.state());
574 let published = tilo_resource::publish::<A::Resource>(app.kind, &label, reader.state());
575 let live = Live::new(reader);
576 let alive = Rc::new(());
577 let dispatch = Dispatcher::new(app.id, app.kind, read_only, me.as_deref());
578 let dispatch = if permissions.is_none() {
579 dispatch.past()
580 } else {
581 dispatch
582 };
583 if permissions.is_some() {
586 A::records(&mut super::Records::new(&storage));
587 }
588 let effects = runs_effects.then(|| Effects::live(dispatch.clone(), live.clone()));
589 let mut cx = ViewCx::root(live, &alive, dispatch, published, run, storage);
590 if let Some(effects) = effects {
591 cx.env.effects = effects;
592 }
593 let content = A::view(&mut cx);
594 let live = permissions.is_some();
595 let root = ResourceRoot::new(app.title, icon(app), label, cx, alive, content)
596 .following(permissions, scope)
597 .with_presentation((app.local_operations)());
598 if !app.document_history {
599 return root.without_history();
600 }
601 if live {
602 #[cfg(not(target_family = "wasm"))]
603 return root.drawing_past(super::preview::draw_past::<A>);
604 }
605 root
606}
607
608pub(crate) fn action_registry<A: ResourceApp>() -> tilo_actions::ActionRegistry {
611 let mut registry =
612 tilo_actions::ActionRegistry::with(tilo_resource::group::register::<A::Resource>);
613 registry
614 .register::<super::history::ResourceHistoryEdit<A::Resource>>(super::history::EDIT_TYPE);
615 registry
616}
617
618fn built_actions<R: Resource>(
621 invokes: &[Invoke<R>],
622 now_ms: i64,
623) -> Result<Vec<R::Action>, String> {
624 let mut actions = Vec::new();
625 for invoke in invokes {
626 if let Some(refusal) = invoke.refusal() {
627 return Err(refusal.to_owned());
628 }
629 if invoke.is_group() {
630 actions
631 .extend(tilo_resource::group::build_group::<R>(&invoke.arguments, now_ms)?.actions);
632 } else {
633 actions.push(invoke.build(now_ms)?);
634 }
635 }
636 Ok(actions)
637}
638
639pub(crate) fn apply_seed<A: ResourceApp>(
643 doc: &mut tilo_resource::Doc,
644 author: &str,
645 seed: &[Invoke<A::Resource>],
646) -> Result<(), String> {
647 let actions = built_actions(seed, tilo_resource::origin_now_ms())?;
648 let encoded = actions
649 .iter()
650 .map(|action| tilo_resource::encode::<A::Resource>(action).map_err(|e| e.to_string()))
651 .collect::<Result<Vec<_>, _>>()?;
652 let actions: Vec<&dyn tilo_actions::Action> = actions
653 .iter()
654 .map(|action| action as &dyn tilo_actions::Action)
655 .collect();
656 apply_seed_actions(hooks::<A>(), doc, author, &actions, encoded)
657}
658
659fn apply_seed_actions(
661 app: &AppHooks,
662 doc: &mut tilo_resource::Doc,
663 author: &str,
664 actions: &[&dyn tilo_actions::Action],
665 encoded: Vec<tilo_actions::EncodedAction>,
666) -> Result<(), String> {
667 let writer = ui_core::history_source::authors::device_actor(author, doc.get_actor());
668 for (action, encoded) in actions.iter().zip(encoded) {
669 let context = tilo_actions::ActionContext::for_resource(app.kind, app.kind, app.label)
670 .with_app(app.id, app.title)
671 .with_invoker(tilo_actions::ActionInvoker::actor(author))
672 .with_change(tilo_actions::ChangeKind::External);
673 tilo_actions::apply_local_with_context(doc, *action, encoded, Some(&writer), context)
674 .map_err(|error| format!("seeding the new record failed: {error}"))?;
675 }
676 Ok(())
677}
678
679fn write_genesis<R: Resource>(
683 doc: &mut tilo_resource::Doc,
684 genesis: &[Invoke<R>],
685) -> Result<(), String> {
686 for action in &built_actions(genesis, 0)? {
687 tilo_actions::Action::apply(action, doc).map_err(|error| error.to_string())?;
688 }
689 Ok(())
690}
691
692fn new_record<A: ResourceApp>(
696 context: &mut HostContext,
697 arguments: &[String],
698 file: Option<&ui_core::application::OpenFile>,
699 options: &[ui_core::application::LaunchOption],
700) -> Result<(), LaunchError> {
701 let genesis = A::genesis(
702 &LaunchInput::new(arguments, file)
703 .with_options(options)
704 .with_content(context.content()),
705 );
706 let author = context.author().0.clone();
707 let seed = A::seed(&SeedCx {
708 me: &author,
709 device: context.device_id(),
710 host: Some(context),
711 room: None,
712 });
713 initialize_record(
714 context,
715 &mut |doc| {
716 <A::Resource as Resource>::initialize(doc).map_err(|error| error.to_string())?;
717 write_genesis(doc, &genesis)
718 },
719 &mut |doc| apply_seed::<A>(doc, &author, &seed),
720 )
721}
722
723type RecordWrite<'a> = &'a mut dyn FnMut(&mut tilo_resource::Doc) -> Result<(), String>;
725
726fn initialize_record(
730 context: &mut HostContext,
731 genesis: RecordWrite<'_>,
732 seed: RecordWrite<'_>,
733) -> Result<(), LaunchError> {
734 context
735 .initialize_new_record_seeded(|doc| genesis(doc), |doc| seed(doc))
736 .map_err(LaunchError::Record)
737}
738
739fn migrate_older(
744 context: &HostContext,
745 version: u32,
746 migrate: &mut dyn FnMut(&mut tilo_resource::Doc) -> Result<bool, String>,
747) -> Result<Result<(), String>, LaunchError> {
748 let stored = context
749 .read_record(tilo_resource::evolution::stored_version)
750 .map_err(LaunchError::Record)?;
751 Ok(if stored < version {
752 context.migrate_record(|doc| migrate(doc)).map(drop)
753 } else {
754 Ok(())
755 })
756}
757
758fn open_root<A: ResourceApp>(
769 context: &mut HostContext,
770 editing: Editing,
771 capture: bool,
772 created: bool,
773) -> Result<Result<Box<dyn ui_core::widget::AppWidget>, String>, LaunchError> {
774 let migrated = migrate_older(context, <A::Resource as Resource>::VERSION, &mut |doc| {
775 tilo_resource::evolution::migrate::<A::Resource>(doc).map_err(|error| error.to_string())
776 })?;
777 let read = context
778 .read_record(|doc| {
779 Reader::<A::Resource>::read(doc)
780 .map_err(|error| tilo_resource::Unreadable::of::<A::Resource>(doc, &error).reason)
781 })
782 .map_err(LaunchError::Record)?;
783 let reader = match migrated.and(read) {
784 Ok(reader) => reader,
785 Err(reason) => return Ok(Err(reason)),
786 };
787 let root = build_root::<A>(reader, editing, context.storage(), hosted(context));
788 if let Some(typed) = root.typed::<A::Resource>() {
789 let record = typed.live.get();
790 let launch = root.launch().with_record(&*record);
791 if !capture || !launch.is_empty() {
792 for invoke in A::launch(&launch, created) {
793 typed.dispatch.submit(invoke);
794 }
795 }
796 }
797 Ok(Ok(root.into_widget()))
798}
799
800fn hosted(context: &HostContext) -> super::settings::Hosted {
802 super::settings::Hosted {
803 person: (!context.is_private())
806 .then(|| context.person_records().cloned())
807 .flatten(),
808 device: Some(context.device_id().to_owned()),
809 }
810}
811
812fn unopened(app: &AppHooks, context: HostContext, reason: String) -> AppComponent {
814 let unopened = super::root::Unopened::new(
815 app.title,
816 icon(app),
817 (app.kind, app.label),
818 &(app.declarations)(),
819 reason,
820 );
821 let mut component = AppComponent::new(context, unopened.boxed(), (app.action_registry)());
822 component.set_behaviour(app.behaviour);
823 component
824}
825
826#[allow(
827 clippy::needless_pass_by_value,
828 reason = "the toolkit's ComponentFactory signature passes the target owned"
829)]
830fn create<A: ResourceApp>(
831 context: HostContext,
832 target: LaunchTarget,
833 _restore: RestoreInput,
834) -> Result<AppComponent, LaunchError> {
835 create_component(hooks::<A>(), context, &target)
836}
837
838fn create_component(
840 app: &'static AppHooks,
841 mut context: HostContext,
842 target: &LaunchTarget,
843) -> Result<AppComponent, LaunchError> {
844 if let Some(catalog) = (app.catalog)() {
845 catalog.ensure();
846 }
847 let created = matches!(target, LaunchTarget::New | LaunchTarget::PrimaryNew)
850 || context.creation_workspace().is_some();
851 record_content(&mut context);
852 let file = context.take_open_file();
853 let arguments = context.take_launch_arguments();
854 let options = context.take_launch_options();
855 if created {
856 (app.new_record)(&mut context, &arguments, file.as_ref(), &options)?;
857 }
858 let capture = context.capture().is_some();
859 let file_history = app.file_history.map(|_| FileHistorySource::default());
860 let opener = (!capture).then(|| {
863 let reopener = context.reopener();
864 super::launch::OpenHere::new(move |arguments| open_in_place(app, &reopener, arguments))
865 });
866 let run = match context.capture().cloned() {
867 Some(capture) => (app.prepare_capture)(&mut context, capture.scenario())?,
868 None => Run::live(context.content().cloned()).in_private(context.is_private()),
869 };
870 if let Some(file) = &file {
871 file.report_opened(app.kind);
872 }
873 let run = run
874 .launched(Launched {
875 record: Some(context.record().id.clone()),
876 arguments,
877 options,
878 file,
879 handoff: context.take_handoff(),
880 lent: super::scenario::Lent::new(context.take_lent()),
881 })
882 .opening_with(opener)
883 .showing_file_history(
884 file_history
885 .clone()
886 .map(|source| (source, (app.file_history_witness)())),
887 );
888 let permissions = context.live_permissions();
889 let me = context.author().0.clone();
890 let editing = Editing::Live {
891 permissions,
892 me,
893 run,
894 };
895 let root = match (app.open)(&mut context, editing, capture, created)? {
896 Ok(root) => root,
897 Err(reason) => return Ok(unopened(app, context, reason)),
898 };
899 let mut component = AppComponent::new(context, root, (app.action_registry)());
900 component.set_behaviour(app.behaviour);
901 if app.document_history {
902 let history = (app.history)();
903 match file_history {
904 Some(files) => component.register_document_and_file_history(history, files),
905 None => component.register_document_history(history),
906 }
907 .map_err(|error| LaunchError::Operation(error.to_string()))?;
908 }
909 Ok(component)
910}
911
912fn record_content(context: &mut HostContext) {
917 if context.capture().is_some() {
918 return;
919 }
920 let Some(local) = context.content().cloned() else {
921 return;
922 };
923 let lane = ui_core::collaboration::record_content::ContentLane::of(
924 context.app_id(),
925 context.record().id.as_str(),
926 );
927 context
928 .provide_content(ui_core::collaboration::record_content::RecordContent::store(local, lane));
929}
930
931fn prepare_capture<A: ResourceApp>(
934 context: &mut HostContext,
935 scenario: Option<&str>,
936) -> Result<Run, LaunchError> {
937 let mut fixture = Fixture::<A::Resource>::new(scenario, context.content().cloned());
938 A::fixture(&mut fixture);
939 if context.content().is_none()
940 && let Some(content) = fixture.lent_content()
941 {
942 context.provide_content(content.clone());
943 }
944 if fixture.has_history() {
945 merge_peer(context, &mut |peer| {
946 fixture.write_history(peer, &action_registry::<A>())
947 })?;
948 }
949 Ok(fixture.into_run())
950}
951
952fn merge_peer(
955 context: &HostContext,
956 write: &mut dyn FnMut(&mut tilo_resource::Doc) -> Result<(), String>,
957) -> Result<(), LaunchError> {
958 let mut peer = context
959 .read_record(|doc| doc.clone().fork())
960 .map_err(LaunchError::Record)?;
961 write(&mut peer).map_err(LaunchError::Operation)?;
962 context
963 .merge_peer_document(peer.save())
964 .map_err(LaunchError::Operation)?;
965 Ok(())
966}
967
968#[cfg(not(target_arch = "wasm32"))]
969const CAPTURE_INSTANCE: u64 = 1;
970#[cfg(not(target_arch = "wasm32"))]
971pub(crate) const DATA_DIR_INSTANCE: u64 = 0;
972
973#[cfg(not(target_arch = "wasm32"))]
974pub(crate) fn data_dir_host(
978 directory: &std::path::Path,
979 author: &UserId,
980 permissions: Rc<RefCell<PermissionContext>>,
981) -> Result<ApplicationHost, LaunchError> {
982 let root = std::path::absolute(directory).map_err(operation)?;
983 ApplicationHost::new(
984 root,
985 "local-device".into(),
986 "personal",
987 author.clone(),
988 permissions,
989 tilo_actions::ActionContext::default(),
990 )
991}
992
993#[cfg(not(target_arch = "wasm32"))]
994pub(crate) fn role_permissions(author: &str, role: Role) -> Rc<RefCell<PermissionContext>> {
997 Rc::new(RefCell::new(PermissionContext::member(author, role)))
998}
999
1000#[cfg(not(target_arch = "wasm32"))]
1001fn operation(error: impl std::fmt::Display) -> LaunchError {
1002 LaunchError::Operation(error.to_string())
1003}
1004
1005#[cfg(tilo_native_runner)]
1008pub(crate) fn prepare_on(
1009 host: &mut ApplicationHost,
1010 definition: &ApplicationDefinition,
1011 instance: u64,
1012 named: Option<RecordId>,
1013 inputs: ComponentRunnerInputs,
1014) -> Result<PreparedComponentInputs, LaunchError> {
1015 let kind = definition.record_kind.clone();
1016 match named {
1017 Some(id) => {
1018 host.prepare_record(definition, instance, RecordCheckout::new(id, kind), inputs)
1019 }
1020 None => host.prepare_default(
1021 definition,
1022 instance,
1023 RecordCheckout::new(RecordId::room_default(), kind),
1024 None,
1025 inputs,
1026 ),
1027 }
1028}
1029
1030#[cfg(tilo_native_runner)]
1035fn prepare_app<A: ResourceApp>(
1036 definition: &ApplicationDefinition,
1037 args: &LaunchArgs,
1038) -> Result<PreparedComponentInputs, LaunchError> {
1039 prepare_hooked(hooks::<A>(), definition, args)
1040}
1041
1042#[cfg(tilo_native_runner)]
1044fn prepare_hooked(
1045 app: &AppHooks,
1046 definition: &ApplicationDefinition,
1047 args: &LaunchArgs,
1048) -> Result<PreparedComponentInputs, LaunchError> {
1049 let (record, arguments) = record_argument(args.positionals());
1050 let options = launch_options(app, args);
1051 let named = record
1052 .or_else(|| (app.launch_record)(&LaunchInput::new(arguments, None).with_options(&options)));
1053 let private = app.private_windows && args.has(PRIVATE_FLAG);
1054 let mut prepared = prepare_named(definition, args, named, private)?;
1055 hand_launch(definition, arguments, &mut prepared)?;
1056 prepared.context.provide_launch_options(options);
1057 if args.shots.is_none()
1060 && let Some(handoff) = process_handoff()
1061 {
1062 prepared.context.provide_handoff(handoff);
1063 }
1064 Ok(prepared)
1065}
1066
1067#[cfg(tilo_native_runner)]
1069fn process_handoff() -> Option<ui_core::application::PlaybackHandoff> {
1070 let position_ms = std::env::var("TILO_MEDIA_HANDOFF_POSITION_MS")
1071 .ok()?
1072 .parse()
1073 .ok()?;
1074 Some(ui_core::application::PlaybackHandoff {
1075 position_ms,
1076 resume_playing: std::env::var("TILO_MEDIA_HANDOFF_RESUME_PLAYING")
1077 .is_ok_and(|value| value == "1"),
1078 title: std::env::var("TILO_MEDIA_HANDOFF_TITLE")
1079 .ok()
1080 .filter(|title| !title.trim().is_empty()),
1081 })
1082}
1083
1084#[cfg(tilo_native_runner)]
1086fn launch_options(app: &AppHooks, args: &LaunchArgs) -> Vec<ui_core::application::LaunchOption> {
1087 let options = app.options.iter().filter_map(|&name| {
1088 args.value(name)
1089 .map(|value| ui_core::application::LaunchOption {
1090 name: name.to_owned(),
1091 value: Some(value.to_owned()),
1092 })
1093 });
1094 let flags = app
1095 .flags
1096 .iter()
1097 .filter(|&&name| args.has(name))
1098 .map(|&name| ui_core::application::LaunchOption {
1099 name: name.to_owned(),
1100 value: None,
1101 });
1102 options.chain(flags).collect()
1103}
1104
1105#[cfg(tilo_native_runner)]
1112pub(crate) fn hand_launch(
1113 definition: &ApplicationDefinition,
1114 arguments: &[String],
1115 prepared: &mut PreparedComponentInputs,
1116) -> Result<(), LaunchError> {
1117 hand_launch_to(definition, arguments, &mut prepared.context)
1118}
1119
1120fn hand_launch_to(
1127 definition: &ApplicationDefinition,
1128 arguments: &[String],
1129 context: &mut HostContext,
1130) -> Result<(), LaunchError> {
1131 #[cfg(tilo_native_runner)]
1134 if let Some(path) = arguments
1135 .first()
1136 .filter(|argument| !argument.contains("://"))
1137 .map(std::path::Path::new)
1138 && let Some(file) = launched_file(definition, path)?
1139 {
1140 context.provide_open_file(file);
1141 }
1142 #[cfg(not(tilo_native_runner))]
1143 let _ = definition;
1144 context.provide_launch_arguments(arguments.to_vec());
1145 Ok(())
1146}
1147
1148fn open_in_place(
1155 app: &'static AppHooks,
1156 reopener: &ui_core::application::Reopener,
1157 arguments: Vec<String>,
1158) -> ui_core::application::ReplacementOutcome {
1159 reopener.replace_with(move |reopener| {
1162 let definition = (app.definition)();
1163 let record = (app.launch_record)(&LaunchInput::new(&arguments, None))
1164 .unwrap_or_else(RecordId::minted);
1165 let (mut context, target) = reopener.prepare(definition, record)?;
1166 hand_launch_to(definition, &arguments, &mut context)?;
1167 let component = definition.instantiate(context, target, RestoreInput::None)?;
1168 Ok(ui_core::application::ComponentReplacement::new(
1169 component, arguments,
1170 ))
1171 })
1172}
1173
1174#[cfg(tilo_native_runner)]
1180fn launched_file(
1181 definition: &ApplicationDefinition,
1182 path: &std::path::Path,
1183) -> Result<Option<ui_core::application::OpenFile>, LaunchError> {
1184 let Some(media_type) = ui_core::dnd::mime_for_path(path)
1186 .or_else(|| path.is_file().then_some("application/octet-stream"))
1187 .filter(|media_type| definition.opens_media_type(media_type))
1188 else {
1189 return Ok(None);
1190 };
1191 let name = path.file_name().map_or_else(
1192 || path.display().to_string(),
1193 |name| name.to_string_lossy().into_owned(),
1194 );
1195 let unreadable = |error: String| {
1196 LaunchError::Operation(format!("{} cannot be read: {error}", path.display()))
1197 };
1198 let bytes = tilo_files::file_at(path)
1199 .and_then(|(dir, file)| tilo_files::blocking::read(&dir, &file))
1200 .map_err(|error| unreadable(error.to_string()))?;
1201 let (file, _receipt) =
1202 ui_core::application::OpenFile::new(&name, media_type, bytes).map_err(unreadable)?;
1203 Ok(Some(file))
1204}
1205
1206#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
1207fn prepare_definition(
1208 definition: &ApplicationDefinition,
1209 args: &LaunchArgs,
1210) -> Result<PreparedComponentInputs, LaunchError> {
1211 let (record, _) = record_argument(args.positionals());
1212 prepare_named(definition, args, record, false)
1213}
1214
1215#[cfg(tilo_native_runner)]
1219fn record_argument(arguments: &[String]) -> (Option<RecordId>, &[String]) {
1220 match arguments.split_first() {
1221 Some((first, rest)) => match RecordId::from_uri(first) {
1222 Some(record) => (Some(record), rest),
1223 None => (None, arguments),
1224 },
1225 None => (None, arguments),
1226 }
1227}
1228
1229#[cfg(all(test, tilo_native_runner))]
1230mod record_argument_tests {
1231 use super::*;
1232
1233 #[test]
1234 fn a_record_uri_names_the_record_and_is_not_handed_to_the_app() {
1235 let record = RecordId::minted();
1236 let arguments = vec![record.uri(), "--then".to_owned()];
1237 let (named, rest) = record_argument(&arguments);
1238 assert_eq!(named, Some(record));
1239 assert_eq!(rest, ["--then".to_owned()]);
1240
1241 let plain = vec!["notes.md".to_owned()];
1242 assert_eq!(record_argument(&plain), (None, plain.as_slice()));
1243 assert_eq!(record_argument(&[]), (None, [].as_slice()));
1244 }
1245}
1246
1247#[cfg(tilo_native_runner)]
1248fn prepare_named(
1249 definition: &ApplicationDefinition,
1250 args: &LaunchArgs,
1251 named: Option<RecordId>,
1252 private: bool,
1253) -> Result<PreparedComponentInputs, LaunchError> {
1254 let inputs = ComponentRunnerInputs::from_native_process();
1255 let isolated = private
1259 || args.shots.is_some()
1260 || args.responsive_matrix.is_some()
1261 || args.native_audit
1262 || (args.headless.is_some() && args.value("--data-dir").is_none());
1263 if isolated {
1264 let profile = tempfile::Builder::new()
1265 .prefix("tilo-capture-")
1266 .tempdir()
1267 .map_err(operation)?;
1268 let root = profile.path().join("state");
1269 let profile_root = profile.path().to_path_buf();
1270 let content = profile_content(profile.path());
1271 let mut host = ApplicationHost::new_with_profile_lifetime(
1272 root,
1273 "capture-device".into(),
1274 "capture-workspace",
1275 UserId("capture-author".into()),
1276 role_permissions("capture-author", Role::Owner),
1277 tilo_actions::ActionContext::default(),
1278 Rc::new(profile),
1279 )?;
1280 let mut prepared = prepare_on(&mut host, definition, CAPTURE_INSTANCE, named, inputs)?;
1281 prepared.context.provide_content(content);
1282 if args.shots.is_some() || args.responsive_matrix.is_some() {
1285 prepared
1286 .context
1287 .provide_capture(Capture::new(args.scenario.clone()));
1288 }
1289 if private {
1290 prepared.context.provide_private();
1291 }
1292 return Ok(super::testing::in_profile(
1293 prepared,
1294 &profile_root,
1295 definition.app_id,
1296 ));
1297 }
1298 if let Some(directory) = args.value("--data-dir") {
1299 let author = ui_core::collaboration::local_user_id();
1300 let mut host = data_dir_host(
1301 std::path::Path::new(directory),
1302 &author,
1303 role_permissions(&author.0, Role::Owner),
1304 )?;
1305 return prepare_on(&mut host, definition, DATA_DIR_INSTANCE, named, inputs).map(
1306 |mut prepared| {
1307 prepared
1308 .context
1309 .provide_content(super::testing::data_dir_content(directory.as_ref()));
1310 super::testing::in_profile(
1311 prepared,
1312 &super::testing::data_dir_root(directory.as_ref()),
1313 definition.app_id,
1314 )
1315 },
1316 );
1317 }
1318 let mut native = NativeProcessHost::open(definition)?;
1319 let mut prepared = native.prepare_launch(definition, named, inputs)?;
1320 prepared
1321 .context
1322 .provide_content(tilo_content::ContentStore::for_app());
1323 Ok(prepared)
1324}
1325
1326#[cfg(tilo_native_runner)]
1329pub(crate) fn profile_content(directory: &std::path::Path) -> tilo_content::ContentStore {
1330 tilo_content::ContentStore::in_dir(directory.join("content"))
1331}
1332
1333#[cfg(tilo_native_runner)]
1336pub(crate) fn settle_launch(harness: &mut FrameHarness) {
1337 harness.wait_until(ui_core::time::Duration::ZERO, |_| true);
1338}
1339
1340#[cfg(not(target_arch = "wasm32"))]
1341fn capture<A: ResourceApp>(
1342 harness: &mut FrameHarness,
1343 args: &LaunchArgs,
1344) -> Result<(), LaunchError> {
1345 settle_launch(harness);
1346 super::stage::stage::<A>(harness, args.scenario.as_deref())
1347}
1348
1349#[cfg(not(target_arch = "wasm32"))]
1350fn shot_viewport<A: ResourceApp>(args: &LaunchArgs) -> (u32, u32) {
1351 A::shot_viewport(args.scenario.as_deref())
1352}
1353
1354#[cfg(tilo_native_runner)]
1355struct LiveOptionsKey<A>(std::marker::PhantomData<A>);
1356
1357#[cfg(tilo_native_runner)]
1360fn live_options<A: ResourceApp>() -> &'static [&'static str] {
1361 leaked(TypeId::of::<LiveOptionsKey<A>>(), || {
1362 let mut options = vec!["--data-dir"];
1363 options.extend(A::commands().iter().map(|command| command.option));
1364 options.extend(A::OPTIONS);
1365 options
1366 })
1367 .as_slice()
1368}
1369
1370#[cfg(tilo_native_runner)]
1371struct LiveFlagsKey<A>(std::marker::PhantomData<A>);
1372
1373#[cfg(tilo_native_runner)]
1376fn live_flags<A: ResourceApp>() -> &'static [&'static str] {
1377 leaked(TypeId::of::<LiveFlagsKey<A>>(), || {
1378 let mut flags = A::FLAGS.to_vec();
1379 if A::PRIVATE_WINDOWS {
1380 flags.push(PRIVATE_FLAG);
1381 }
1382 flags
1383 })
1384 .as_slice()
1385}
1386
1387#[cfg(tilo_native_runner)]
1389fn run_command<A: ResourceApp>(args: &LaunchArgs) -> Result<bool, LaunchError> {
1390 for command in A::commands() {
1391 if let Some(value) = args.value(command.option) {
1392 (command.run)(value)
1393 .map_err(|error| LaunchError::Operation(format!("{}: {error}", command.option)))?;
1394 return Ok(true);
1395 }
1396 }
1397 Ok(false)
1398}
1399
1400#[cfg(tilo_native_runner)]
1402const PRIVATE_FLAG: &str = "--private";
1403
1404#[cfg(tilo_native_runner)]
1405pub fn launch_spec<A: ResourceApp>() -> LaunchSpec {
1407 let spec = LaunchSpec::from_definition(definition::<A>())
1408 .component_host(prepare_app::<A>)
1409 .native_services()
1410 .capture_scenario(capture::<A>)
1411 .shot_viewport(shot_viewport::<A>)
1412 .scenarios(A::scenarios())
1413 .max_positionals(A::ARGUMENTS)
1414 .command_handler(run_command::<A>)
1415 .allowed_options(&["--data-dir"])
1416 .allowed_live_options(live_options::<A>())
1417 .allowed_live_flags(live_flags::<A>());
1418 let spec = if !A::ATTACHES_RUNTIME {
1419 spec.runtime_attachment(|_| false)
1420 } else if A::PRIVATE_WINDOWS {
1421 spec.runtime_attachment(|args| !args.has(PRIVATE_FLAG))
1424 } else {
1425 spec
1426 };
1427 if A::BEHAVIOUR.window == ui_core::application::WindowRole::InputSurface {
1431 spec.disable_headless().runtime_attachment(|_| false)
1432 } else {
1433 spec
1434 }
1435}
1436
1437#[cfg(not(tilo_native_runner))]
1438pub fn launch_spec<A: ResourceApp>() -> crate::launch::LaunchSpec {
1441 crate::launch::LaunchSpec::from_definition(definition::<A>())
1442}
1443
1444#[cfg(feature = "native")]
1445#[cfg(not(target_arch = "wasm32"))]
1449pub fn definition_launch_spec(definition: &'static ApplicationDefinition) -> LaunchSpec {
1450 LaunchSpec::from_definition(definition)
1451 .component_host(prepare_definition)
1452 .native_services()
1453 .allowed_options(&["--data-dir"])
1454 .allowed_live_options(&["--data-dir"])
1455}
1456
1457#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
1461pub fn run<A: ResourceApp>() {
1462 let args: Vec<String> = std::env::args().collect();
1463 if args.iter().skip(1).any(|arg| arg == SURFACE_FLAG) {
1464 let code = match serde_json::to_string_pretty(&super::testing::surface_report::<A>()) {
1465 Ok(report) => {
1466 println!("{report}");
1467 0
1468 }
1469 Err(error) => {
1470 eprintln!("Error: {error}");
1471 1
1472 }
1473 };
1474 #[allow(
1475 clippy::exit,
1476 reason = "the surface report is the whole run of the binary"
1477 )]
1478 std::process::exit(code);
1479 }
1480 if let Some(code) = run_helper::<A>(&args) {
1481 #[allow(
1482 clippy::exit,
1483 reason = "a helper the binary is ends the process with its own exit code"
1484 )]
1485 std::process::exit(code);
1486 }
1487 crate::launch::run_or_exit(&launch_spec::<A>());
1488}
1489
1490pub const SURFACE_FLAG: &str = "--surface";
1494
1495pub fn run_helper<A: ResourceApp>(args: &[String]) -> Option<i32> {
1499 for helper in A::helpers() {
1500 match (helper.run)(args) {
1501 Ok(None) => {}
1502 Ok(Some(code)) => return Some(code),
1503 Err(error) => {
1504 eprintln!("Error: {error}");
1505 return Some(1);
1506 }
1507 }
1508 }
1509 None
1510}