purview_gpui/install.rs
1//! Bridge setup: [`Bridge`] wires the `Send` channel, poster, build, background
2//! server, main-thread drain loop, and keep-alive, returning a ready
3//! [`AppHandle`].
4
5use std::{
6 io,
7 net::{SocketAddr, ToSocketAddrs},
8};
9
10use gpui::{App, Global};
11use purview::{AppHandle, BoundHttp, GuiBridge, RunningBridge};
12use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
13
14/// A closure posted from the MCP thread, to run on gpui's main thread.
15type Posted = Box<dyn FnOnce(&mut App) + Send>;
16
17/// Builder that installs a purview bridge onto a live gpui [`App`].
18///
19/// Call it inside `Application::run`'s `|cx: &mut App|` closure; installing
20/// returns a [`purview::AppHandle`] on which you declare windows and actions
21/// exactly as with the core crate. The channel, drain loop, and server-handle
22/// lifetime are all handled internally (the server stops when the `App` tears
23/// down).
24///
25/// Pick a transport with [`http`](Self::http) or
26/// [`install_stdio`](Self::install_stdio). The two shapes differ because HTTP
27/// has an address to report and a bind that can fail, and stdio has neither —
28/// the same split the core crate makes.
29#[derive(Default)]
30pub struct Bridge {
31 instructions: Option<String>,
32}
33
34impl Bridge {
35 /// Start a new bridge builder.
36 pub fn new() -> Self {
37 Bridge::default()
38 }
39
40 /// Append to the MCP `instructions` (mirrors
41 /// [`GuiBridgeBuilder::instructions_append`](purview::GuiBridgeBuilder::instructions_append)).
42 pub fn instructions_append(mut self, extra: impl Into<String>) -> Self {
43 let extra = extra.into();
44 self.instructions = Some(match self.instructions.take() {
45 Some(s) => format!("{s}\n\n{extra}"),
46 None => extra,
47 });
48 self
49 }
50
51 /// Switch to the Streamable HTTP builder — the door to a token and to the
52 /// served address.
53 ///
54 /// ```ignore
55 /// let papp = Bridge::new()
56 /// .instructions_append("A single-window counter.")
57 /// .http()
58 /// .bind("127.0.0.1:8931")
59 /// .expect("bind the MCP port")
60 /// .install(cx);
61 /// ```
62 pub fn http(self) -> HttpBridge {
63 HttpBridge {
64 instructions: self.instructions,
65 token: None,
66 }
67 }
68
69 /// Serve MCP over stdio on a background thread and return the
70 /// [`AppHandle`].
71 ///
72 /// One call, unlike the HTTP path: there is no address to hand back, and
73 /// nothing here that can fail.
74 pub fn install_stdio(self, cx: &mut App) -> AppHandle<App> {
75 let (bridge, app, rx) = build(self.instructions);
76 install(cx, app, bridge.spawn_stdio(), None, rx)
77 }
78}
79
80/// The Streamable HTTP half of [`Bridge`], from [`Bridge::http`].
81///
82/// It exists so that HTTP-only settings do not sit on a builder a stdio app
83/// also uses: a token is meaningless without a header to carry it.
84pub struct HttpBridge {
85 instructions: Option<String>,
86 token: Option<String>,
87}
88
89impl HttpBridge {
90 /// Require every request to carry `Authorization: Bearer <token>` (mirrors
91 /// [`HttpBuilder::token`](purview::HttpBuilder::token)).
92 ///
93 /// Every process on the machine can reach a loopback port, browser pages
94 /// included; a token is what makes "on this machine" mean "authorized".
95 pub fn token(mut self, token: impl Into<String>) -> Self {
96 self.token = Some(token.into());
97 self
98 }
99
100 /// Build the bridge and bind the listener, without installing anything on
101 /// the `App` yet.
102 ///
103 /// Binding is its own step so that [`BoundBridge::local_addr`] is readable
104 /// before the server runs — pass port `0`, let the OS choose, and you still
105 /// know what to tell a client. It also puts the one fallible part of setup
106 /// here, which leaves [`BoundBridge::install`] unable to fail.
107 ///
108 /// # Errors
109 ///
110 /// [`io::Error`](std::io::Error): binding a socket is the only thing here
111 /// that touches the outside world. Usually the port is already in use.
112 pub fn bind(self, addr: impl ToSocketAddrs) -> io::Result<BoundBridge> {
113 let (bridge, app, rx) = build(self.instructions);
114 let mut http = bridge.http();
115 if let Some(token) = self.token {
116 http = http.token(token);
117 }
118 let bound = http.bind(addr)?;
119 Ok(BoundBridge {
120 local_addr: bound.local_addr(),
121 app,
122 bound,
123 rx,
124 })
125 }
126}
127
128/// A bridge that is built and bound but not yet installed on an `App`, from
129/// [`HttpBridge::bind`].
130///
131/// Holding this is what lets you learn the address, and declare your windows,
132/// before a single client can read anything.
133pub struct BoundBridge {
134 app: AppHandle<App>,
135 bound: BoundHttp<App>,
136 rx: UnboundedReceiver<Posted>,
137 local_addr: SocketAddr,
138}
139
140impl BoundBridge {
141 /// The address the server will listen on — the port the OS chose, when `0`
142 /// was asked for.
143 ///
144 /// This is what to render in the UI, put behind a "copy the connect
145 /// command" button, or write wherever a client will look for it. Unlike
146 /// [`PurviewAppExt::purview_addr`](crate::PurviewAppExt::purview_addr) it
147 /// is no `Option`: here the transport is known.
148 pub fn local_addr(&self) -> SocketAddr {
149 self.local_addr
150 }
151
152 /// The [`AppHandle`], before the server starts.
153 ///
154 /// Declaring windows through this and *then* calling [`install`] means the
155 /// projection is complete before the first client can read it — the same
156 /// order the core crate's `build()` / `serve()` pair gives you. `install`
157 /// returns the same handle, so ignore this unless you want that ordering.
158 ///
159 /// [`install`]: Self::install
160 pub fn app(&self) -> AppHandle<App> {
161 self.app.clone()
162 }
163
164 /// Serve on a background thread, tie its lifetime to the `App`, and return
165 /// the [`AppHandle`].
166 ///
167 /// Infallible: the bind already happened.
168 pub fn install(self, cx: &mut App) -> AppHandle<App> {
169 let BoundBridge {
170 app,
171 bound,
172 rx,
173 local_addr,
174 } = self;
175 install(cx, app, bound.spawn(), Some(local_addr), rx)
176 }
177}
178
179/// Build the bridge, its [`AppHandle`], and the channel the poster writes to.
180///
181/// The poster (running on the background MCP thread) sends each
182/// `FnOnce(&mut App)` over that `Send` channel; [`install`] drains it on the
183/// main thread. `AsyncApp` is `!Send`, so a `Send` channel is the necessary
184/// bridge.
185fn build(
186 instructions: Option<String>,
187) -> (GuiBridge<App>, AppHandle<App>, UnboundedReceiver<Posted>) {
188 let (tx, rx) = unbounded_channel::<Posted>();
189 let builder = GuiBridge::builder(move |task: Posted| {
190 let _ = tx.send(task);
191 });
192 let builder = match instructions {
193 Some(s) => builder.instructions_append(s),
194 None => builder,
195 };
196 let (bridge, app) = builder.build();
197 (bridge, app, rx)
198}
199
200/// Stash the running server on the `App` and start draining posted closures.
201fn install(
202 cx: &mut App,
203 app: AppHandle<App>,
204 running: RunningBridge,
205 addr: Option<SocketAddr>,
206 mut rx: UnboundedReceiver<Posted>,
207) -> AppHandle<App> {
208 debug_assert!(
209 cx.try_global::<Installed>().is_none(),
210 "purview-gpui: install a bridge only once per App"
211 );
212
213 // The handle lives in a gpui Global, so its lifetime is exactly the `App`'s
214 // (drop = graceful stop).
215 cx.set_global(Installed {
216 app: app.clone(),
217 addr,
218 _running: running,
219 });
220
221 // Drain posted closures on the main thread for the App's lifetime.
222 cx.spawn(async move |cx| {
223 while let Some(task) = rx.recv().await {
224 let _ = cx.update(|app| task(app));
225 }
226 })
227 .detach();
228
229 app
230}
231
232/// gpui global holding the running server (keep-alive tied to the `App`) and
233/// the [`AppHandle`] read back by [`PurviewAppExt`](crate::PurviewAppExt).
234pub(crate) struct Installed {
235 pub(crate) app: AppHandle<App>,
236 /// The served address, for an HTTP bridge; `None` for stdio.
237 pub(crate) addr: Option<SocketAddr>,
238 _running: RunningBridge,
239}
240
241impl Global for Installed {}