1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
//! Content another application shares into this one.
//!
//! A share, a document intent, an "open with", a dropped file: all of them
//! arrive as [`IncomingContent`] and are collected as a stream scoped to the
//! composition. Nothing polls and nothing drains a queue — an item published
//! before any screen is listening waits in the framework's own backlog and is
//! handed to the first collector.
use cranpose_core::{EventStream, rememberEventStream};
use crate::{
content::{BytesContent, ContentHandle, ContentMetadata, resolve_content},
inbox::{Inbox, InboxObserver},
};
/// Where the bytes of an incoming item live.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IncomingSource {
/// The platform handed over the bytes directly.
Bytes(Vec<u8>),
/// The platform named the content; it is opened through the content
/// resolver when it is read.
Uri(String),
}
/// One item shared into the application.
///
/// This is `Send` on purpose: platform hosts publish from whatever thread
/// received the intent, and the framework hops it onto the UI thread before the
/// composition turns it into a [`ContentHandle`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IncomingContent {
/// Display name, when the sender provided one.
pub name: Option<String>,
/// MIME type, when the sender provided one.
pub mime_type: Option<String>,
/// Where the bytes are.
pub source: IncomingSource,
}
impl IncomingContent {
/// An item whose bytes the platform already has.
pub fn from_bytes(bytes: Vec<u8>) -> Self {
Self {
name: None,
mime_type: None,
source: IncomingSource::Bytes(bytes),
}
}
/// An item the platform named rather than materialised.
pub fn from_uri(uri: impl Into<String>) -> Self {
Self {
name: None,
mime_type: None,
source: IncomingSource::Uri(uri.into()),
}
}
/// A file the platform handed over by its path, named after the file.
pub fn from_file(path: &std::path::Path) -> Self {
let content = Self::from_uri(crate::media::uri_for_path(path));
match path.file_name().and_then(|name| name.to_str()) {
Some(name) => content.with_name(name),
None => content,
}
}
/// Sets the display name.
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// Sets the MIME type.
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
/// The display name the platform reported, or the last URI segment.
pub fn display_name(&self) -> String {
if let Some(name) = &self.name {
return name.clone();
}
match &self.source {
IncomingSource::Bytes(_) => "shared".to_string(),
IncomingSource::Uri(uri) => uri
.rsplit(['/', ':'])
.find(|segment| !segment.is_empty())
.unwrap_or(uri)
.to_string(),
}
}
/// Turns this item into readable content. Call it on the UI thread, where
/// the platform's content resolver lives.
///
/// Returns `None` only when the item names a URI this platform cannot open.
pub fn content(self) -> Option<ContentHandle> {
let name = self.display_name();
match self.source {
IncomingSource::Bytes(bytes) => {
let mut metadata = ContentMetadata::named(name);
metadata.mime_type = self.mime_type;
Some(BytesContent::new(metadata, bytes).handle())
}
IncomingSource::Uri(uri) => resolve_content(&uri),
}
}
}
static INBOX: Inbox<IncomingContent> = Inbox::new("incoming share");
/// Registers `observer` for incoming content, replaying anything that arrived
/// before there was anywhere to put it.
///
/// Applications collect the stream from
/// [`rememberIncomingContent`] instead of calling this.
pub fn observe_incoming_content(
observer: impl Fn(IncomingContent) + Send + Sync + 'static,
) -> InboxObserver {
INBOX.observe(observer)
}
/// Publishes content shared into the application. Callable from any thread; the
/// framework moves each item onto the UI thread before a composition sees it.
pub fn publish_incoming_content(content: IncomingContent) {
log::info!("incoming share: item {}", content.display_name());
INBOX.publish(content);
}
/// Discards anything waiting for a collector. Used by tests and host teardown.
pub fn clear_incoming_content() {
INBOX.clear();
}
/// Collects content shared into the application for as long as this call stays
/// in the composition.
///
/// ```rust,no_run
/// use cranpose_macros::composable;
/// use cranpose_services::rememberIncomingContent;
///
/// #[composable]
/// fn Inbox() {
/// let shared = rememberIncomingContent();
/// cranpose_core::CollectEvents(shared, (), |item| {
/// log::info!("received {}", item.display_name());
/// });
/// }
/// ```
#[expect(non_snake_case)]
#[track_caller]
pub fn rememberIncomingContent() -> EventStream<IncomingContent> {
rememberEventStream((), |sender| {
observe_incoming_content(move |content| sender.send(content))
})
}
#[cfg(test)]
#[path = "tests/incoming_share_tests.rs"]
mod tests;