1use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result};
11
12use crate::browser::basket::Cart;
13use crate::browser::session::{SHOP_URL, default_profile_dir, evaluate};
14use crate::browser::{LaunchMode, Session};
15
16const LOGIN_TIMEOUT: Duration = Duration::from_secs(15 * 60);
18const POLL_EVERY: Duration = Duration::from_secs(3);
19
20pub const DEFAULT_PROBE_STORE: &str = "N137";
22
23const POLLER_TITLE: &str = "[k-ruoka-mcp] poller - do NOT use this tab";
27const USER_TAB_TITLE: &str = "Tuotteet | K-Ruoka Verkkokauppa";
28
29const UNDER_XVFB_ENV: &str = "K_RUOKA_UNDER_XVFB";
32
33pub async fn run(debug_port: u16, store_id: &str) -> Result<()> {
34 let display = Display::detect();
35 reexec_under_xvfb_if_headless()?;
36 ensure_port_free(debug_port)?;
37
38 let profile = default_profile_dir()?;
39 let session = Session::new(&profile, LaunchMode::Headful { debug_port })?;
40
41 println!("Opening a browser against {}", profile.display());
42
43 let _user_page = session
46 .open_extra_page(SHOP_URL)
47 .await
48 .context("launching the login browser")?;
49
50 print_instructions(display, debug_port);
51
52 let cart = Cart::new(&session);
53
54 if let Err(e) = cart.active(store_id).await {
59 anyhow::bail!("cannot reach K-Ruoka, so signing in would not be detected: {e}");
60 }
61
62 let deadline = Instant::now() + LOGIN_TIMEOUT;
63 let mut result = Err(anyhow::anyhow!(
64 "timed out after {} minutes without seeing a signed-in account",
65 LOGIN_TIMEOUT.as_secs() / 60
66 ));
67
68 let mut signalled = false;
69 while Instant::now() < deadline {
70 tokio::select! {
75 _ = tokio::time::sleep(POLL_EVERY) => {}
76 name = terminate_signal() => {
77 println!("\n{name}, closing the browser.");
78 signalled = true;
79 break;
80 }
81 }
82 mark_poller_tab(&session).await;
83
84 if let Ok(basket) = cart.active(store_id).await
87 && let Some(who) = basket.user_info.display()
88 {
89 println!("\nSigned in as {who}.");
90 result = Ok(());
91 break;
92 }
93 }
94 if signalled {
95 result = Err(anyhow::anyhow!("cancelled before signing in"));
96 }
97
98 session.close().await.ok();
101
102 match result {
103 Ok(()) => {
104 println!("Session saved to {}.", profile.display());
105 println!("`k-ruoka-mcp serve` will now use it. Re-run `login` if it expires.");
106 Ok(())
107 }
108 Err(e) => Err(e),
109 }
110}
111
112async fn terminate_signal() -> &'static str {
115 #[cfg(unix)]
116 {
117 use tokio::signal::unix::{SignalKind, signal};
118 let mut term = match signal(SignalKind::terminate()) {
119 Ok(s) => s,
120 Err(_) => return std::future::pending().await,
121 };
122 let mut int = match signal(SignalKind::interrupt()) {
123 Ok(s) => s,
124 Err(_) => return std::future::pending().await,
125 };
126 tokio::select! {
127 _ = term.recv() => "SIGTERM",
128 _ = int.recv() => "SIGINT",
129 }
130 }
131 #[cfg(not(unix))]
132 {
133 match tokio::signal::ctrl_c().await {
134 Ok(()) => "Ctrl-C",
135 Err(_) => std::future::pending().await,
136 }
137 }
138}
139
140async fn mark_poller_tab(session: &Session) {
142 let js = format!("document.title = {}", serde_json::json!(POLLER_TITLE));
143 let _ = session
144 .with_page(|page| async move {
145 evaluate(&page, &js).await?;
146 Ok(())
147 })
148 .await;
149}
150
151#[derive(Clone, Copy)]
155enum Display {
156 Real,
158 Virtual,
160}
161
162impl Display {
163 fn detect() -> Self {
166 if std::env::var_os(UNDER_XVFB_ENV).is_some() {
167 Self::Virtual
168 } else if std::env::var_os("DISPLAY").is_some() {
169 Self::Real
170 } else {
171 Self::Virtual
173 }
174 }
175}
176
177fn print_instructions(display: Display, port: u16) {
178 let steps = match display {
179 Display::Real => real_display_steps(),
180 Display::Virtual => virtual_display_steps(port),
181 };
182 println!(
183 "\n{steps}\n\
184 Nothing here types your credentials for you, and this process never sees\n\
185 them -- it only watches for K-Ruoka to start reporting an account.\n\
186 Waiting (Ctrl-C to give up)...\n"
187 );
188}
189
190fn real_display_steps() -> String {
191 format!(
192 "┌─ Sign in by hand ────────────────────────────────────────────────────┐\n\
193 │ A Chrome window has just opened on this machine. │\n\
194 └──────────────────────────────────────────────────────────────────────┘\n\
195 \n\
196 1. Switch to it and pick the tab titled\n\
197 \n {USER_TAB_TITLE}\n\
198 \n \
199 and NOT the one marked \"{POLLER_TITLE}\" -- that one is this process\n \
200 checking whether you are signed in yet, and it gets navigated out from\n \
201 under you every {poll} seconds.\n\
202 \n\
203 2. Click \"Kirjaudu\" and sign in to K-Plussa as you normally would. It\n \
204 hands off to login.kesko.fi; that is expected.\n",
205 poll = POLL_EVERY.as_secs()
206 )
207}
208
209fn virtual_display_steps(port: u16) -> String {
210 let host = hostname();
211 format!(
212 "┌─ Sign in by hand ────────────────────────────────────────────────────┐\n\
213 │ The browser is running on this machine with no screen attached, so │\n\
214 │ reach it over an SSH tunnel and drive it from your own Chrome. │\n\
215 └──────────────────────────────────────────────────────────────────────┘\n\
216 \n\
217 1. On your laptop:\n\
218 \n ssh -N -L {port}:localhost:{port} {host}\n\
219 \n\
220 2. Open chrome://inspect in your local Chrome. Under \"Discover network\n \
221 targets\", click Configure and add localhost:{port}\n\
222 \n\
223 3. Under \"Remote Target\" there will be two k-ruoka.fi tabs. Click\n \
224 \"inspect\" on the one titled\n\
225 \n {USER_TAB_TITLE}\n\
226 \n \
227 and NOT the one marked \"{POLLER_TITLE}\" -- that one is this process\n \
228 checking whether you are signed in yet.\n\
229 \n\
230 4. In the inspector's screencast view, click \"Kirjaudu\" and sign in to\n \
231 K-Plussa as you normally would. It hands off to login.kesko.fi; that\n \
232 is expected.\n"
233 )
234}
235
236fn hostname() -> String {
238 std::fs::read_to_string("/etc/hostname")
239 .map(|h| h.trim().to_string())
240 .ok()
241 .filter(|h| !h.is_empty())
242 .or_else(|| std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()))
243 .or_else(|| std::env::var("COMPUTERNAME").ok().filter(|h| !h.is_empty()))
244 .unwrap_or_else(|| "<this-host>".to_string())
245}
246
247fn ensure_port_free(port: u16) -> Result<()> {
250 use std::net::{Ipv4Addr, SocketAddr, TcpStream};
251 let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
252 if TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok() {
253 anyhow::bail!(
254 "something is already listening on port {port} -- most likely a Chrome left \
255 over from an earlier `login`. Close it (pkill -f remote-debugging-port={port}) \
256 or pass --port with a different one."
257 );
258 }
259 Ok(())
260}
261
262#[cfg(not(target_os = "linux"))]
267fn reexec_under_xvfb_if_headless() -> Result<()> {
268 Ok(())
269}
270
271#[cfg(target_os = "linux")]
275fn reexec_under_xvfb_if_headless() -> Result<()> {
276 use std::os::unix::process::CommandExt;
277
278 if std::env::var_os("DISPLAY").is_some()
279 || std::env::var_os("WAYLAND_DISPLAY").is_some()
280 || std::env::var_os("K_RUOKA_NO_XVFB").is_some()
281 {
282 return Ok(());
283 }
284 let xvfb = which("xvfb-run").context(
285 "no DISPLAY and `xvfb-run` is not installed. Install it (apt install xvfb) or run \
286 `login` somewhere with a display.",
287 )?;
288
289 let exe = std::env::current_exe()?;
290 let args: Vec<String> = std::env::args().skip(1).collect();
291 println!("No DISPLAY set; re-running under xvfb-run.");
292
293 let err = std::process::Command::new(xvfb)
296 .arg("-a")
297 .arg(exe)
298 .args(args)
299 .env("K_RUOKA_NO_XVFB", "1")
300 .env(UNDER_XVFB_ENV, "1")
303 .exec();
304 Err(anyhow::anyhow!("failed to exec xvfb-run: {err}"))
305}
306
307#[cfg(target_os = "linux")]
309fn which(bin: &str) -> Option<std::path::PathBuf> {
310 std::env::var_os("PATH").and_then(|paths| {
311 std::env::split_paths(&paths)
312 .map(|p| p.join(bin))
313 .find(|p| p.is_file())
314 })
315}