Skip to main content

cranpose_services/
incoming_share.rs

1//! Content another application shares into this one.
2//!
3//! A share, a document intent, an "open with", a dropped file: all of them
4//! arrive as [`IncomingContent`] and are collected as a stream scoped to the
5//! composition. Nothing polls and nothing drains a queue — an item published
6//! before any screen is listening waits in the framework's own backlog and is
7//! handed to the first collector.
8
9use std::{
10    collections::VecDeque,
11    sync::{
12        Arc, Mutex, OnceLock,
13        atomic::{AtomicU64, Ordering},
14    },
15};
16
17use cranpose_core::{EventStream, rememberEventStream};
18
19use crate::content::{BytesContent, ContentHandle, ContentMetadata, resolve_content};
20
21/// Where the bytes of an incoming item live.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum IncomingSource {
24    /// The platform handed over the bytes directly.
25    Bytes(Vec<u8>),
26    /// The platform named the content; it is opened through the content
27    /// resolver when it is read.
28    Uri(String),
29}
30
31/// One item shared into the application.
32///
33/// This is `Send` on purpose: platform hosts publish from whatever thread
34/// received the intent, and the framework hops it onto the UI thread before the
35/// composition turns it into a [`ContentHandle`].
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct IncomingContent {
38    /// Display name, when the sender provided one.
39    pub name: Option<String>,
40    /// MIME type, when the sender provided one.
41    pub mime_type: Option<String>,
42    /// Where the bytes are.
43    pub source: IncomingSource,
44}
45
46impl IncomingContent {
47    /// An item whose bytes the platform already has.
48    pub fn from_bytes(bytes: Vec<u8>) -> Self {
49        Self {
50            name: None,
51            mime_type: None,
52            source: IncomingSource::Bytes(bytes),
53        }
54    }
55
56    /// An item the platform named rather than materialised.
57    pub fn from_uri(uri: impl Into<String>) -> Self {
58        Self {
59            name: None,
60            mime_type: None,
61            source: IncomingSource::Uri(uri.into()),
62        }
63    }
64
65    /// Sets the display name.
66    pub fn with_name(mut self, name: impl Into<String>) -> Self {
67        self.name = Some(name.into());
68        self
69    }
70
71    /// Sets the MIME type.
72    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
73        self.mime_type = Some(mime_type.into());
74        self
75    }
76
77    /// The display name the platform reported, or the last URI segment.
78    pub fn display_name(&self) -> String {
79        if let Some(name) = &self.name {
80            return name.clone();
81        }
82        match &self.source {
83            IncomingSource::Bytes(_) => "shared".to_string(),
84            IncomingSource::Uri(uri) => uri
85                .rsplit(['/', ':'])
86                .find(|segment| !segment.is_empty())
87                .unwrap_or(uri)
88                .to_string(),
89        }
90    }
91
92    /// Turns this item into readable content. Call it on the UI thread, where
93    /// the platform's content resolver lives.
94    ///
95    /// Returns `None` only when the item names a URI this platform cannot open.
96    pub fn content(self) -> Option<ContentHandle> {
97        let name = self.display_name();
98        match self.source {
99            IncomingSource::Bytes(bytes) => {
100                let mut metadata = ContentMetadata::named(name);
101                metadata.mime_type = self.mime_type;
102                Some(BytesContent::new(metadata, bytes).handle())
103            }
104            IncomingSource::Uri(uri) => resolve_content(&uri),
105        }
106    }
107}
108
109type Observer = Arc<dyn Fn(IncomingContent) + Send + Sync>;
110
111struct Inbox {
112    observers: Vec<(u64, Observer)>,
113    backlog: VecDeque<IncomingContent>,
114}
115
116impl Inbox {
117    fn new() -> Self {
118        Self {
119            observers: Vec::new(),
120            backlog: VecDeque::new(),
121        }
122    }
123
124    fn observe(&mut self, id: u64, observer: Observer) -> Vec<IncomingContent> {
125        self.observers.push((id, observer));
126        self.backlog.drain(..).collect()
127    }
128
129    fn publish(&mut self, content: IncomingContent) -> Option<Vec<Observer>> {
130        if self.observers.is_empty() {
131            self.backlog.push_back(content);
132            return None;
133        }
134        Some(
135            self.observers
136                .iter()
137                .map(|(_, observer)| Arc::clone(observer))
138                .collect(),
139        )
140    }
141
142    fn remove_observer(&mut self, id: u64) {
143        self.observers.retain(|(existing, _)| *existing != id);
144    }
145
146    fn clear(&mut self) {
147        self.backlog.clear();
148    }
149}
150
151fn inbox() -> &'static Mutex<Inbox> {
152    static INBOX: OnceLock<Mutex<Inbox>> = OnceLock::new();
153    INBOX.get_or_init(|| Mutex::new(Inbox::new()))
154}
155
156static NEXT_ID: AtomicU64 = AtomicU64::new(1);
157
158/// Keeps an observer registered until it is dropped.
159pub struct IncomingContentObserver {
160    id: u64,
161}
162
163impl Drop for IncomingContentObserver {
164    fn drop(&mut self) {
165        if let Ok(mut inbox) = inbox().lock() {
166            inbox.remove_observer(self.id);
167        }
168    }
169}
170
171/// Registers `observer` for incoming content, replaying anything that arrived
172/// before there was anywhere to put it.
173///
174/// Applications collect the stream from
175/// [`rememberIncomingContent`] instead of calling this.
176pub fn observe_incoming_content(
177    observer: impl Fn(IncomingContent) + Send + Sync + 'static,
178) -> IncomingContentObserver {
179    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
180    let observer: Observer = Arc::new(observer);
181    let replay = {
182        let Ok(mut inbox) = inbox().lock() else {
183            return IncomingContentObserver { id };
184        };
185        inbox.observe(id, Arc::clone(&observer))
186    };
187    log::info!(
188        "incoming share: observer {id} registered, replays {} item(s)",
189        replay.len()
190    );
191    for item in replay {
192        observer(item);
193    }
194    IncomingContentObserver { id }
195}
196
197/// Publishes content shared into the application. Callable from any thread; the
198/// framework moves each item onto the UI thread before a composition sees it.
199pub fn publish_incoming_content(content: IncomingContent) {
200    let Some(observers) = ({
201        let Ok(mut inbox) = inbox().lock() else {
202            return;
203        };
204        inbox.publish(content.clone())
205    }) else {
206        log::info!(
207            "incoming share: no observer yet, item {} waits in the backlog",
208            content.display_name()
209        );
210        return;
211    };
212    log::info!(
213        "incoming share: item {} goes to {} observer(s)",
214        content.display_name(),
215        observers.len()
216    );
217    for observer in observers {
218        observer(content.clone());
219    }
220}
221
222/// Discards anything waiting for a collector. Used by tests and host teardown.
223pub fn clear_incoming_content() {
224    if let Ok(mut inbox) = inbox().lock() {
225        inbox.clear();
226    }
227}
228
229/// Collects content shared into the application for as long as this call stays
230/// in the composition.
231///
232/// ```rust,no_run
233/// use cranpose_macros::composable;
234/// use cranpose_services::rememberIncomingContent;
235///
236/// #[composable]
237/// fn Inbox() {
238///     let shared = rememberIncomingContent();
239///     cranpose_core::CollectEvents(shared, (), |item| {
240///         log::info!("received {}", item.display_name());
241///     });
242/// }
243/// ```
244#[expect(non_snake_case)]
245#[track_caller]
246pub fn rememberIncomingContent() -> EventStream<IncomingContent> {
247    rememberEventStream((), |sender| {
248        observe_incoming_content(move |content| sender.send(content))
249    })
250}
251
252#[cfg(test)]
253#[path = "tests/incoming_share_tests.rs"]
254mod tests;