1use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28use serde::{Deserialize, Serialize};
29use serde_json::{Value, json};
30
31use crate::cdp::{self, Browser, PageInfo};
32use crate::error::{Error, ErrorKind, Result};
33use crate::jwt;
34use crate::paths;
35use crate::session::{LOCATION_COOKIE, Session};
36use crate::sso;
37
38pub const LOGIN_START_URL: &str = sso::FOOD_LOGIN;
40pub const PENDING_TTL_SECS: i64 = 10 * 60;
42pub const SEND_COOLDOWN_SECS: i64 = 60;
44pub const MAX_SENDS_PER_HOUR: usize = 3;
46
47const CALLING_CODES: &[&str] = &["855", "60", "62", "63", "65", "66", "84", "95"];
51
52pub fn split_phone(phone: &str) -> Result<(String, String)> {
55 let cleaned: String = phone
56 .chars()
57 .filter(|c| !matches!(c, ' ' | '-' | '.' | '(' | ')'))
58 .collect();
59 let digits = cleaned.strip_prefix('+').ok_or_else(|| {
60 Error::invalid("phone must be in international form, e.g. +63 9XX XXX XXXX")
61 })?;
62 if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
63 return Err(Error::invalid("phone must contain only digits after the +"));
64 }
65 let cc = CALLING_CODES
66 .iter()
67 .find(|cc| digits.starts_with(**cc))
68 .ok_or_else(|| Error::invalid("phone country code is not a Grab market"))?;
69 let national = digits[cc.len()..].trim_start_matches('0').to_string();
70 if !(6..=12).contains(&national.len()) {
71 return Err(Error::invalid("phone number has the wrong length"));
72 }
73 Ok(((*cc).to_string(), national))
74}
75
76pub fn mask_phone(cc: &str, national: &str) -> String {
78 let n = national.len();
79 let keep = n.min(2);
80 format!("+{cc}{}{}", "•".repeat(n - keep), &national[n - keep..])
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum Channel {
89 App,
91 Sms,
93 Whatsapp,
95}
96
97impl Channel {
98 pub fn parse(s: &str) -> Result<Self> {
99 match s.to_ascii_lowercase().as_str() {
100 "app" | "push" => Ok(Self::App),
101 "sms" => Ok(Self::Sms),
102 "whatsapp" | "wa" => Ok(Self::Whatsapp),
103 _ => Err(Error::invalid("--channel must be app, sms or whatsapp")),
104 }
105 }
106
107 pub fn as_str(self) -> &'static str {
108 match self {
109 Self::App => "app",
110 Self::Sms => "sms",
111 Self::Whatsapp => "whatsapp",
112 }
113 }
114
115 pub fn describe(self) -> &'static str {
117 match self {
118 Self::App => "one approval request in the Grab app",
119 Self::Sms | Self::Whatsapp => {
120 "one OTP code (Grab chooses SMS or WhatsApp delivery; the CLI never resends to switch)"
121 }
122 }
123 }
124
125 pub fn challenge_types(self) -> &'static [&'static str] {
128 match self {
129 Self::App => &["PUSH_V2", "PUSH"],
130 Self::Sms | Self::Whatsapp => &["CHANNEL_OPTIMIZED_PHONE_OTP", "SMS_OTP"],
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum PendingKind {
141 OtpSent,
143 ApprovalPending,
145}
146
147impl PendingKind {
148 pub fn as_str(self) -> &'static str {
149 match self {
150 Self::OtpSent => "otp_sent",
151 Self::ApprovalPending => "approval_pending",
152 }
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct PendingLogin {
160 pub kind: PendingKind,
161 pub cdp_base: String,
162 pub browser_context_id: String,
163 pub target_id: String,
164 pub created_at: i64,
165 pub expires_at: i64,
166 pub phone_masked: String,
167 pub channel_requested: Channel,
168 #[serde(default)]
170 pub channel_sent: Option<String>,
171}
172
173impl PendingLogin {
174 pub fn path() -> Result<PathBuf> {
175 Ok(paths::state_dir()?.join("login-pending.json"))
176 }
177
178 pub fn read_from(path: &Path) -> Result<Option<Self>> {
180 match std::fs::read(path) {
181 Ok(b) => Ok(Some(serde_json::from_slice(&b)?)),
182 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
183 Err(e) => Err(e.into()),
184 }
185 }
186
187 pub fn read() -> Result<Option<Self>> {
188 Self::read_from(&Self::path()?)
189 }
190
191 pub fn save_to(&self, path: &Path) -> Result<()> {
192 paths::write_private(path, &serde_json::to_vec_pretty(self)?)
193 }
194
195 pub fn save(&self) -> Result<()> {
196 self.save_to(&Self::path()?)
197 }
198
199 pub fn clear() -> Result<()> {
200 let path = Self::path()?;
201 match std::fs::remove_file(path) {
202 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
203 _ => Ok(()),
204 }
205 }
206
207 pub fn is_expired(&self, now: i64) -> bool {
208 self.expires_at <= now
209 }
210
211 pub fn next_command(&self) -> &'static str {
214 match self.kind {
215 PendingKind::OtpSent => "grab auth login --otp <code>",
216 PendingKind::ApprovalPending => "grab auth login --poll",
217 }
218 }
219
220 pub fn summary(&self, now: i64) -> Value {
221 json!({
222 "kind": self.kind.as_str(),
223 "phone": self.phone_masked,
224 "channel_requested": self.channel_requested.as_str(),
225 "channel_sent": self.channel_sent,
226 "expires_in_secs": (self.expires_at - now).max(0),
227 "expired": self.is_expired(now),
228 })
229 }
230}
231
232pub fn pending_json(p: &PendingLogin, now: i64) -> Value {
234 let mut v = p.summary(now);
235 v["expires_at"] = json!(p.expires_at);
236 v["next"] = json!(p.next_command());
237 v
238}
239
240#[derive(Debug, Clone, Default, Serialize, Deserialize)]
245pub struct SendLog {
246 pub sends: Vec<i64>,
247}
248
249impl SendLog {
250 pub fn path() -> Result<PathBuf> {
251 Ok(paths::state_dir()?.join("login-sends.json"))
252 }
253
254 pub fn load_from(path: &Path) -> Result<Self> {
255 match std::fs::read(path) {
256 Ok(b) => Ok(serde_json::from_slice(&b).unwrap_or_default()),
257 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
258 Err(e) => Err(e.into()),
259 }
260 }
261
262 pub fn load() -> Result<Self> {
263 Self::load_from(&Self::path()?)
264 }
265
266 pub fn save_to(&self, path: &Path) -> Result<()> {
267 paths::write_private(path, &serde_json::to_vec_pretty(self)?)
268 }
269
270 pub fn save(&self) -> Result<()> {
271 self.save_to(&Self::path()?)
272 }
273
274 fn recent(&self, now: i64) -> impl Iterator<Item = i64> + '_ {
275 self.sends.iter().copied().filter(move |t| now - t < 3600)
276 }
277
278 pub fn sends_last_hour(&self, now: i64) -> usize {
279 self.recent(now).count()
280 }
281
282 pub fn wait_secs(&self, now: i64) -> i64 {
284 let mut recent: Vec<i64> = self.recent(now).collect();
285 recent.sort_unstable();
286 let cooldown = recent
287 .last()
288 .map_or(0, |last| last + SEND_COOLDOWN_SECS - now);
289 let hourly = if recent.len() >= MAX_SENDS_PER_HOUR {
290 recent[recent.len() - MAX_SENDS_PER_HOUR] + 3600 - now
291 } else {
292 0
293 };
294 cooldown.max(hourly).max(0)
295 }
296
297 pub fn check(&self, now: i64) -> Result<()> {
300 let wait = self.wait_secs(now);
301 if wait == 0 {
302 return Ok(());
303 }
304 let why = if self.sends_last_hour(now) >= MAX_SENDS_PER_HOUR {
305 format!("{MAX_SENDS_PER_HOUR} login challenges were already sent in the last hour")
306 } else {
307 format!("a login challenge was sent less than {SEND_COOLDOWN_SECS}s ago")
308 };
309 Err(Error::new(
310 ErrorKind::RateLimited,
311 format!("{why}; nothing was sent. Try again in {wait}s."),
312 )
313 .with_details(json!({"retry_after_secs": wait})))
314 }
315
316 pub fn record(&mut self, now: i64) {
319 self.sends.retain(|t| now - t < 3600);
320 self.sends.push(now);
321 }
322}
323
324pub fn order_tracking_tabs(pages: &[PageInfo]) -> Vec<String> {
333 pages
334 .iter()
335 .filter_map(|p| {
336 let u = reqwest::Url::parse(&p.url).ok()?;
337 let path = u.path();
338 (u.host_str() == Some("food.grab.com")
339 && (path.contains("/order/") || path.contains("/track")))
340 .then(|| format!("food.grab.com{path}"))
341 })
342 .collect()
343}
344
345pub fn tracking_tab_warning(tabs: &[String]) -> Option<String> {
346 if tabs.is_empty() {
347 return None;
348 }
349 Some(format!(
350 "{} food.grab.com order-tracking tab(s) may be open in the browser ({}). When the \
351 browser's food session lapses such tabs poll /track, get 401 and call Grab's logout \
352 every ~16s, which logs that browser out. If they are open, close them (grabcli won't \
353 touch other tabs).",
354 tabs.len(),
355 tabs.join(", ")
356 ))
357}
358
359#[derive(Debug, Clone, PartialEq, Eq)]
363pub enum Screen {
364 Loading,
366 Phone,
368 Options(Vec<String>),
370 Otp {
372 channel: Option<String>,
373 },
374 OtpRejected,
376 Approval,
378 Done,
380 StepUp(String),
382 Problem(String),
384 Unknown(String),
385}
386
387pub const PROBE_JS: &str = r#"(() => {
390 const root = document.getElementById('root');
391 const options = [...document.querySelectorAll('[role=button] img[alt^="Proceed to "]')]
392 .map(i => i.getAttribute('alt').slice('Proceed to '.length));
393 const captcha = [...document.querySelectorAll('iframe')].some(f =>
394 /recaptcha\/(api2|enterprise)\/bframe/.test(f.src || '') && f.getBoundingClientRect().height > 100);
395 return {
396 host: location.host, path: location.pathname, ready: document.readyState,
397 has_phone: !!document.getElementById('Phone'), options, captcha,
398 text: ((root && root.innerText) || (document.body && document.body.innerText) || '').slice(0, 3000),
399 };
400})()"#;
401
402const PROBLEMS: &[&str] = &[
403 "Account temporarily blocked",
404 "temporarily locked",
405 "You've tried too many times",
406 "Try again later",
407 "Too many attempts",
408 "Unable to resend code",
409 "OTP challenge expired",
410 "Request rejected",
411 "QR Code Expired",
412 "Account Not Found",
413 "Oops, something went wrong",
414];
415
416pub fn classify_screen(p: &Value) -> Screen {
417 let s = |k: &str| p.get(k).and_then(Value::as_str).unwrap_or_default();
418 let host = s("host");
419 let path = s("path");
420 let text = s("text");
421 if host == "food.grab.com" && !path.starts_with("/auth/") {
422 return Screen::Done;
423 }
424 if host != "weblogin.grab.com" {
425 return if host.is_empty() || host == "food.grab.com" {
426 Screen::Loading
427 } else {
428 Screen::Unknown(host.to_string())
429 };
430 }
431 if p.get("captcha").and_then(Value::as_bool) == Some(true) {
432 return Screen::StepUp("recaptcha".into());
433 }
434 if let Some(pr) = PROBLEMS.iter().find(|w| text.contains(**w)) {
435 return Screen::Problem((*pr).to_string());
436 }
437 if text.contains("Incorrect code") || text.contains("no longer valid") {
438 return Screen::OtpRejected;
439 }
440 let segs: Vec<&str> = path.split('/').filter(|x| !x.is_empty()).collect();
441 let has = |x: &str| segs.contains(&x);
442 let otp_channel = || {
443 ["SMS", "WhatsApp", "Viber", "Zalo"]
444 .iter()
445 .find(|c| text.contains(&format!("Check your {c}")))
446 .map(|c| (*c).to_string())
447 };
448 if has("optimised-sms-otp") || has("sms-otp") || has("email-otp") {
449 return Screen::Otp {
450 channel: otp_channel(),
451 };
452 }
453 if has("push-v2") || has("push") {
454 return Screen::Approval;
455 }
456 if let Some(seg) = [
457 "recaptcha",
458 "arkose",
459 "web-selfie",
460 "passkey",
461 "password",
462 "pin",
463 ]
464 .iter()
465 .find(|x| has(x))
466 {
467 return Screen::StepUp((*seg).to_string());
468 }
469 let options: Vec<String> = p
470 .get("options")
471 .and_then(Value::as_array)
472 .map(|a| {
473 a.iter()
474 .filter_map(Value::as_str)
475 .map(String::from)
476 .collect()
477 })
478 .unwrap_or_default();
479 if has("challenge-options") || (!options.is_empty() && text.contains("Choose")) {
480 return Screen::Options(options);
481 }
482 if text.contains("Approve in mobile app") || text.contains("App push notification sent") {
484 return Screen::Approval;
485 }
486 if text.contains("Check your") || text.contains("Enter verification code") {
487 return Screen::Otp {
488 channel: otp_channel(),
489 };
490 }
491 if p.get("has_phone").and_then(Value::as_bool) == Some(true) {
492 return Screen::Phone;
493 }
494 Screen::Loading
495}
496
497pub fn pick_option(channel: Channel, offered: &[String]) -> Option<&'static str> {
499 channel
500 .challenge_types()
501 .iter()
502 .copied()
503 .find(|t| offered.iter().any(|o| o == t))
504}
505
506pub fn dry_run_plan(
510 phone_masked: &str,
511 channel: Channel,
512 cdp_base: &str,
513 log: &SendLog,
514 now: i64,
515) -> Value {
516 json!({
517 "kind": "dry_run",
518 "dry_run": true,
519 "sent": false,
520 "phone": phone_masked,
521 "channel": channel.as_str(),
522 "cdp": cdp_base,
523 "would_send": channel.describe(),
524 "steps": [
525 "try a silent SSO refresh first (never sends anything); stop if it works",
526 "open food.grab.com/auth/login in a NEW isolated browser context (no shared cookies, other tabs untouched)",
527 "type the phone number, keep \"Remember me on this device for 60 days\" ticked, click Continue",
528 format!("if Grab offers a choice, pick {} (once; never resend or retry)", channel.as_str()),
529 format!("stop and keep the login tab alive for {} min; finish with `grab auth login --otp <code>` or `grab auth login --poll`", PENDING_TTL_SECS / 60),
530 ],
531 "sends_last_hour": log.sends_last_hour(now),
532 "max_sends_per_hour": MAX_SENDS_PER_HOUR,
533 "next_send_allowed_in_secs": log.wait_secs(now),
534 })
535}
536
537fn rect_js(find_expr: &str) -> String {
542 format!(
543 r#"(() => {{ const el = {find_expr}; if (!el) return null;
544 el.scrollIntoView({{block: 'center'}}); const r = el.getBoundingClientRect();
545 if (r.width === 0 || r.height === 0) return null;
546 return {{x: r.left + r.width / 2, y: r.top + r.height / 2, disabled: !!el.disabled}}; }})()"#
547 )
548}
549
550const CONTINUE_BTN: &str = "[...document.querySelectorAll('button.dui-btn-primary, button')].find(b => b.innerText.trim() === 'Continue')";
551const PHONE_INPUT: &str = "document.getElementById('Phone')";
552const REMEMBER_LABEL: &str =
553 "[...document.querySelectorAll('label')].find(l => /Remember me/.test(l.innerText))";
554const REMEMBER_STATE: &str = r#"(() => { const l = [...document.querySelectorAll('label')].find(l => /Remember me/.test(l.innerText));
555 if (!l) return 'absent'; const i = l.querySelector('input[type=checkbox]');
556 return (i && i.checked) || /checkbox-wrapper-checked/.test(l.className) ? 'checked' : 'unchecked'; })()"#;
557const OTP_INPUT: &str = "[...document.querySelectorAll('#root input')].filter(i => i.id !== 'Phone' && !['checkbox','search','hidden'].includes(i.type) && i.offsetParent !== null).find(i => !i.value) || [...document.querySelectorAll('#root input')].find(i => i.id !== 'Phone' && !['checkbox','search','hidden'].includes(i.type) && i.offsetParent !== null)";
559
560struct Tab {
561 browser: Browser,
562 session: String,
563}
564
565impl Tab {
566 async fn probe(&mut self) -> Result<Screen> {
567 self.browser.drain_events(Duration::ZERO).await.ok();
569 match self.browser.eval(&self.session, PROBE_JS).await {
570 Ok(v) => Ok(classify_screen(&v)),
571 Err(_) => Ok(Screen::Loading),
573 }
574 }
575
576 async fn wait_for(
578 &mut self,
579 timeout: Duration,
580 done: impl Fn(&Screen) -> bool,
581 ) -> Result<Screen> {
582 let deadline = tokio::time::Instant::now() + timeout;
583 loop {
584 let s = self.probe().await?;
585 if done(&s) || tokio::time::Instant::now() >= deadline {
586 return Ok(s);
587 }
588 tokio::time::sleep(Duration::from_millis(700)).await;
589 }
590 }
591
592 async fn rect(&mut self, find_expr: &str) -> Result<Option<(f64, f64, bool)>> {
593 let v = self
594 .browser
595 .eval(&self.session, &rect_js(find_expr))
596 .await?;
597 Ok(
598 match (
599 v.get("x").and_then(Value::as_f64),
600 v.get("y").and_then(Value::as_f64),
601 ) {
602 (Some(x), Some(y)) => Some((
603 x,
604 y,
605 v.get("disabled").and_then(Value::as_bool).unwrap_or(false),
606 )),
607 _ => None,
608 },
609 )
610 }
611
612 async fn click(&mut self, find_expr: &str, what: &str) -> Result<()> {
613 let (x, y, _) = self
614 .rect(find_expr)
615 .await?
616 .ok_or_else(|| Error::new(ErrorKind::Api, format!("login page: no {what} found")))?;
617 self.browser.click_at(&self.session, x, y).await
618 }
619}
620
621#[derive(Clone, Copy)]
623pub struct StartOpts<'a> {
624 pub cdp_base: &'a str,
625 pub phone: &'a str,
626 pub channel: Channel,
627}
628
629fn page_error(s: &Screen) -> Error {
630 match s {
631 Screen::StepUp(kind) => Error::new(
632 ErrorKind::CaptchaRequired,
633 format!(
634 "Grab asked for an extra check ({kind}) that needs a person. Log in at food.grab.com \
635 in a normal browser window instead, then run `grab auth import`"
636 ),
637 ),
638 Screen::Problem(p) if p.contains("block") || p.contains("many") || p.contains("later") => {
639 Error::new(
640 ErrorKind::RateLimited,
641 format!("Grab says: {p}. Wait before trying again."),
642 )
643 }
644 Screen::Problem(p) => Error::new(ErrorKind::LoginExpired, format!("Grab says: {p}")),
645 other => Error::new(
646 ErrorKind::Api,
647 format!(
648 "the login page is in an unexpected state ({})",
649 screen_name(other)
650 ),
651 ),
652 }
653}
654
655fn screen_name(s: &Screen) -> &'static str {
656 match s {
657 Screen::Loading => "loading",
658 Screen::Phone => "phone form",
659 Screen::Options(_) => "challenge options",
660 Screen::Otp { .. } => "code entry",
661 Screen::OtpRejected => "code rejected",
662 Screen::Approval => "in-app approval",
663 Screen::Done => "logged in",
664 Screen::StepUp(_) => "step-up",
665 Screen::Problem(_) => "error banner",
666 Screen::Unknown(_) => "unknown",
667 }
668}
669
670async fn dispose(cdp_base: &str, context: &str) {
672 if let Ok(mut b) = Browser::connect(cdp_base).await {
673 b.dispose_context(context).await.ok();
674 }
675}
676
677pub async fn start(opts: StartOpts<'_>) -> Result<PendingLogin> {
681 let now = jwt::now();
682 let (cc, national) = split_phone(opts.phone)?;
683 let masked = mask_phone(&cc, &national);
684 if let Some(p) = PendingLogin::read()? {
685 if !p.is_expired(now) {
686 return Err(Error::invalid(format!(
687 "a login for {} is already pending ({}, {}s left): finish it with `grab auth login --otp <code>` / `--poll`, or run `grab auth login --cancel`",
688 p.phone_masked,
689 p.kind.as_str(),
690 p.expires_at - now
691 )));
692 }
693 dispose(&p.cdp_base, &p.browser_context_id).await;
694 PendingLogin::clear()?;
695 }
696 let mut log = SendLog::load()?;
697 log.check(now)?;
698
699 let mut browser = Browser::connect(opts.cdp_base).await?;
700 let ctx = browser.create_context().await?;
701 let result = drive_send(browser, &ctx, opts, &cc, &national, &masked, &mut log).await;
702 match result {
703 Ok(p) => Ok(p),
704 Err(e) => {
705 dispose(opts.cdp_base, &ctx).await;
706 Err(e)
707 }
708 }
709}
710
711async fn drive_send(
712 mut browser: Browser,
713 ctx: &str,
714 opts: StartOpts<'_>,
715 cc: &str,
716 national: &str,
717 masked: &str,
718 log: &mut SendLog,
719) -> Result<PendingLogin> {
720 let target = browser.open_tab(ctx, "about:blank").await?;
721 let session = browser.attach(&target).await?;
722 browser.navigate(&session, LOGIN_START_URL).await?;
723 let mut tab = Tab { browser, session };
724
725 let s = tab
726 .wait_for(Duration::from_secs(45), |s| !matches!(s, Screen::Loading))
727 .await?;
728 if s != Screen::Phone {
729 return Err(page_error(&s));
730 }
731 tokio::time::sleep(Duration::from_secs(2)).await;
733
734 let shown = tab.probe_text().await?;
735 if !shown.contains(&format!("+{cc}")) {
736 return Err(Error::invalid(format!(
737 "the login page isn't set to +{cc}; only the page's default country is supported. Nothing was sent."
738 )));
739 }
740 if tab
741 .browser
742 .eval(&tab.session, REMEMBER_STATE)
743 .await?
744 .as_str()
745 == Some("unchecked")
746 {
747 tab.click(REMEMBER_LABEL, "remember-me checkbox").await?;
748 }
749 tab.click(PHONE_INPUT, "phone field").await?;
750 tab.browser.type_digits(&tab.session, national).await?;
751 let typed = tab
752 .browser
753 .eval(
754 &tab.session,
755 "(document.getElementById('Phone') || {}).value.replace(/\\D/g, '')",
756 )
757 .await?;
758 if typed.as_str() != Some(national) {
759 return Err(Error::new(
760 ErrorKind::Api,
761 "the phone field didn't take the number as typed; nothing was sent",
762 ));
763 }
764 tokio::time::sleep(Duration::from_millis(600)).await;
765 match tab.rect(CONTINUE_BTN).await? {
766 Some((_, _, false)) => {}
767 _ => {
768 return Err(Error::new(
769 ErrorKind::Api,
770 "the Continue button is missing or disabled; nothing was sent",
771 ));
772 }
773 }
774
775 log.record(jwt::now());
778 log.save()?;
779 tab.click(CONTINUE_BTN, "Continue button").await?;
780 let mut s = tab
781 .wait_for(Duration::from_secs(40), |s| {
782 !matches!(s, Screen::Phone | Screen::Loading)
783 })
784 .await?;
785 if let Screen::Options(offered) = &s {
786 let Some(kind) = pick_option(opts.channel, offered) else {
787 return Err(Error::invalid(format!(
788 "Grab didn't offer {} for this account (offered: {}). Nothing was sent; the attempt still counts toward the limit.",
789 opts.channel.as_str(),
790 offered.join(", ")
791 )));
792 };
793 let opt = format!(
794 "(() => {{ const i = document.querySelector('[role=button] img[alt=\"Proceed to {kind}\"]'); return i ? i.closest('[role=button]') : null; }})()"
795 );
796 tab.click(&opt, "challenge option").await?;
797 s = tab
798 .wait_for(Duration::from_secs(40), |s| {
799 !matches!(s, Screen::Options(_) | Screen::Loading)
800 })
801 .await?;
802 }
803 let (kind, channel_sent) = match &s {
804 Screen::Otp { channel } => (PendingKind::OtpSent, channel.clone()),
805 Screen::Approval => (PendingKind::ApprovalPending, Some("app".to_string())),
806 Screen::Phone => {
807 return Err(Error::new(
808 ErrorKind::Api,
809 "the login page stayed on the phone form after Continue (maybe a field error). Not retrying.",
810 ));
811 }
812 other => return Err(page_error(other)),
813 };
814 let now = jwt::now();
815 let p = PendingLogin {
816 kind,
817 cdp_base: opts.cdp_base.to_string(),
818 browser_context_id: ctx.to_string(),
819 target_id: target,
820 created_at: now,
821 expires_at: now + PENDING_TTL_SECS,
822 phone_masked: masked.to_string(),
823 channel_requested: opts.channel,
824 channel_sent,
825 };
826 p.save()?;
827 Ok(p)
828}
829
830impl Tab {
831 async fn probe_text(&mut self) -> Result<String> {
832 let v = self.browser.eval(&self.session, PROBE_JS).await?;
833 Ok(v.get("text")
834 .and_then(Value::as_str)
835 .unwrap_or_default()
836 .to_string())
837 }
838}
839
840async fn reattach(p: &PendingLogin) -> Result<Tab> {
842 if p.is_expired(jwt::now()) {
843 dispose(&p.cdp_base, &p.browser_context_id).await;
844 PendingLogin::clear()?;
845 return Err(Error::new(
846 ErrorKind::LoginExpired,
847 "the pending login expired; start again with `grab auth login --phone <number>` (mind the send limits)",
848 ));
849 }
850 let mut browser = Browser::connect(&p.cdp_base).await?;
851 if !browser.context_exists(&p.browser_context_id).await?
852 || !browser
853 .tab_in_context(&p.browser_context_id, &p.target_id)
854 .await?
855 {
856 PendingLogin::clear()?;
857 return Err(Error::new(
858 ErrorKind::LoginExpired,
859 "the login tab is gone (browser restarted or tab closed); start again with `grab auth login --phone <number>`",
860 ));
861 }
862 let session = browser.attach(&p.target_id).await?;
863 Ok(Tab { browser, session })
864}
865
866async fn collect_session(tab: &mut Tab, p: &PendingLogin) -> Result<Session> {
869 let deadline = tokio::time::Instant::now() + Duration::from_secs(45);
870 loop {
871 let cookies = tab.browser.context_cookies(&p.browser_context_id).await?;
872 let prev = Session::load_default().unwrap_or_default();
873 let mut s = prev.clone();
874 s.cookies = cookies;
875 if s.gfc_logged_in() {
876 s.carry_over_preferences(&prev);
880 if s.cookie(LOCATION_COOKIE).is_none()
881 && let Ok(def) = tab.browser.default_cookies().await
882 {
883 s.replace_named_cookies(LOCATION_COOKIE, &def);
884 }
885 if !tab.browser.user_agent.is_empty() {
886 s.user_agent = Some(tab.browser.user_agent.clone());
887 }
888 s.exported_at = Some(jwt::now() as f64);
889 s.save_default()?;
890 tab.browser
891 .dispose_context(&p.browser_context_id)
892 .await
893 .ok();
894 PendingLogin::clear()?;
895 return Ok(s);
896 }
897 if tokio::time::Instant::now() >= deadline {
898 return Err(Error::new(
899 ErrorKind::Api,
900 "the login page finished but food.grab.com set no logged-in gfc_session; the tab is kept until the pending login expires",
901 ));
902 }
903 tokio::time::sleep(Duration::from_secs(1)).await;
904 }
905}
906
907pub async fn finish_with_otp(otp: &str) -> Result<Session> {
909 let otp = otp.trim();
910 if !(4..=8).contains(&otp.len()) || !otp.chars().all(|c| c.is_ascii_digit()) {
911 return Err(Error::invalid("the code should be 4-8 digits"));
912 }
913 let p = PendingLogin::read()?.ok_or_else(no_pending)?;
914 if p.kind != PendingKind::OtpSent {
915 return Err(Error::invalid(
916 "this login is waiting for an in-app approval: run `grab auth login --poll`",
917 ));
918 }
919 let mut tab = reattach(&p).await?;
920 match tab.probe().await? {
921 Screen::Otp { .. } | Screen::OtpRejected => {}
922 Screen::Done => return collect_session(&mut tab, &p).await,
923 other => return Err(finish_error(&p, &other).await),
924 }
925 tab.click(OTP_INPUT, "code field").await?;
926 tab.browser.type_digits(&tab.session, otp).await?;
927 let s = tab
929 .wait_for(Duration::from_secs(45), |s| {
930 !matches!(s, Screen::Otp { .. } | Screen::Loading)
931 })
932 .await?;
933 match s {
934 Screen::Done => collect_session(&mut tab, &p).await,
935 Screen::OtpRejected => Err(Error::new(
936 ErrorKind::OtpInvalid,
937 "Grab didn't accept the code; check it and run `grab auth login --otp <code>` again (the page allows only a few tries)",
938 )),
939 Screen::Otp { .. } | Screen::Loading => {
940 collect_session(&mut tab, &p).await
942 }
943 other => Err(finish_error(&p, &other).await),
944 }
945}
946
947pub enum WaitOutcome {
949 LoggedIn(Session),
950 StillWaiting(PendingLogin),
951}
952
953pub async fn wait_for_approval(timeout: Duration) -> Result<WaitOutcome> {
955 let p = PendingLogin::read()?.ok_or_else(no_pending)?;
956 if p.kind != PendingKind::ApprovalPending {
957 return Err(Error::invalid(
958 "this login is waiting for a code: run `grab auth login --otp <code>`",
959 ));
960 }
961 let mut tab = reattach(&p).await?;
962 let left = u64::try_from(p.expires_at - jwt::now()).unwrap_or(0);
963 let timeout = timeout.min(Duration::from_secs(left));
964 let s = tab
965 .wait_for(timeout, |s| {
966 !matches!(s, Screen::Approval | Screen::Loading)
967 })
968 .await?;
969 match s {
970 Screen::Done => Ok(WaitOutcome::LoggedIn(collect_session(&mut tab, &p).await?)),
971 Screen::Approval | Screen::Loading => Ok(WaitOutcome::StillWaiting(p)),
972 other => Err(finish_error(&p, &other).await),
973 }
974}
975
976async fn finish_error(p: &PendingLogin, s: &Screen) -> Error {
978 dispose(&p.cdp_base, &p.browser_context_id).await;
979 PendingLogin::clear().ok();
980 page_error(s)
981}
982
983fn no_pending() -> Error {
984 Error::new(
985 ErrorKind::LoginExpired,
986 "no login in progress: start one with `grab auth login --phone <number>`",
987 )
988}
989
990pub async fn cancel() -> Result<bool> {
993 let Some(p) = PendingLogin::read()? else {
994 return Ok(false);
995 };
996 dispose(&p.cdp_base, &p.browser_context_id).await;
997 PendingLogin::clear()?;
998 Ok(true)
999}
1000
1001pub async fn shared_browser_tabs(cdp_base: &str) -> Vec<String> {
1006 let first = cdp::pages(cdp_base)
1007 .await
1008 .map(|p| order_tracking_tabs(&p))
1009 .unwrap_or_default();
1010 if first.is_empty() {
1011 return first;
1012 }
1013 tokio::time::sleep(Duration::from_millis(1500)).await;
1014 let second = cdp::pages(cdp_base)
1015 .await
1016 .map(|p| order_tracking_tabs(&p))
1017 .unwrap_or_default();
1018 still_open(&first, &second)
1019}
1020
1021pub fn still_open(first: &[String], second: &[String]) -> Vec<String> {
1023 first
1024 .iter()
1025 .filter(|t| second.contains(t))
1026 .cloned()
1027 .collect()
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032 use super::*;
1033
1034 #[test]
1035 fn splits_and_masks_phone() -> Result<()> {
1036 let (cc, n) = split_phone("+63 900 000 0012")?;
1037 assert_eq!((cc.as_str(), n.as_str()), ("63", "9000000012"));
1038 assert_eq!(mask_phone(&cc, &n), "+63••••••••12");
1039 assert!(!mask_phone(&cc, &n).contains("900"));
1040 let (cc, n) = split_phone("+65-8000-0000")?;
1041 assert_eq!((cc.as_str(), n.as_str()), ("65", "80000000"));
1042 assert!(split_phone("09000000000").is_err());
1043 assert!(split_phone("+1 555 000 0000").is_err());
1044 assert!(split_phone("+63 12").is_err());
1045 assert_eq!(mask_phone("63", "1"), "+631");
1046 Ok(())
1047 }
1048
1049 #[test]
1050 fn channels() -> Result<()> {
1051 assert_eq!(Channel::parse("APP")?, Channel::App);
1052 assert_eq!(Channel::parse("whatsapp")?, Channel::Whatsapp);
1053 assert!(Channel::parse("email").is_err());
1054 let offered = vec![
1055 "PUSH_V2".to_string(),
1056 "CHANNEL_OPTIMIZED_PHONE_OTP".to_string(),
1057 ];
1058 assert_eq!(pick_option(Channel::App, &offered), Some("PUSH_V2"));
1059 assert_eq!(
1060 pick_option(Channel::Sms, &offered),
1061 Some("CHANNEL_OPTIMIZED_PHONE_OTP")
1062 );
1063 assert_eq!(pick_option(Channel::Sms, &["PUSH".to_string()]), None);
1064 Ok(())
1065 }
1066
1067 #[test]
1068 fn cooldown_and_hourly_cap() {
1069 let mut log = SendLog::default();
1070 let t0 = 1_000_000;
1071 assert!(log.check(t0).is_ok());
1072 log.record(t0);
1073 assert_eq!(log.wait_secs(t0 + 10), 50);
1075 let e = log.check(t0 + 10).err();
1076 assert_eq!(e.as_ref().map(|e| e.kind), Some(ErrorKind::RateLimited));
1077 assert!(log.check(t0 + 60).is_ok());
1078 log.record(t0 + 60);
1079 log.record(t0 + 200);
1080 assert_eq!(log.sends_last_hour(t0 + 300), 3);
1082 assert_eq!(log.wait_secs(t0 + 300), 3600 - 300);
1083 assert!(log.check(t0 + 3599).is_err());
1084 assert!(log.check(t0 + 3600).is_ok());
1085 log.record(t0 + 3700);
1087 assert_eq!(log.sends.len(), 2);
1088 }
1089
1090 #[test]
1091 fn send_log_roundtrip_and_missing() -> Result<()> {
1092 let dir = tempfile::tempdir()?;
1093 let path = dir.path().join("login-sends.json");
1094 assert!(SendLog::load_from(&path)?.sends.is_empty());
1095 let mut log = SendLog::default();
1096 log.record(42);
1097 log.save_to(&path)?;
1098 assert_eq!(SendLog::load_from(&path)?.sends, vec![42]);
1099 Ok(())
1100 }
1101
1102 #[test]
1103 fn pending_roundtrip_is_private_and_has_no_phone() -> Result<()> {
1104 let dir = tempfile::tempdir()?;
1105 let path = dir.path().join("login-pending.json");
1106 assert!(PendingLogin::read_from(&path)?.is_none());
1107 let p = PendingLogin {
1108 kind: PendingKind::OtpSent,
1109 cdp_base: "http://127.0.0.1:9222".into(),
1110 browser_context_id: "CTX".into(),
1111 target_id: "TAB".into(),
1112 created_at: 100,
1113 expires_at: 100 + PENDING_TTL_SECS,
1114 phone_masked: mask_phone("63", "9000000012"),
1115 channel_requested: Channel::Sms,
1116 channel_sent: Some("SMS".into()),
1117 };
1118 p.save_to(&path)?;
1119 let raw = std::fs::read_to_string(&path)?;
1120 assert!(!raw.contains("9000000012"));
1121 let back = PendingLogin::read_from(&path)?.ok_or_else(no_pending)?;
1122 assert_eq!(back.kind, PendingKind::OtpSent);
1123 assert_eq!(back.browser_context_id, "CTX");
1124 assert!(!back.is_expired(200));
1125 assert!(back.is_expired(100 + PENDING_TTL_SECS));
1126 assert_eq!(back.summary(160)["expires_in_secs"], json!(540));
1127 let j = pending_json(&back, 160);
1128 assert_eq!(j["kind"], json!("otp_sent"));
1129 assert_eq!(j["expires_at"], json!(100 + PENDING_TTL_SECS));
1130 assert_eq!(j["next"], json!("grab auth login --otp <code>"));
1131 let approval = PendingLogin {
1132 kind: PendingKind::ApprovalPending,
1133 ..back
1134 };
1135 assert_eq!(
1136 pending_json(&approval, 160)["next"],
1137 json!("grab auth login --poll")
1138 );
1139 #[cfg(unix)]
1140 {
1141 use std::os::unix::fs::PermissionsExt;
1142 assert_eq!(
1143 std::fs::metadata(&path)?.permissions().mode() & 0o777,
1144 0o600
1145 );
1146 }
1147 Ok(())
1148 }
1149
1150 #[test]
1151 fn dry_run_sends_nothing_and_reports_limits() {
1152 let mut log = SendLog::default();
1153 log.record(1000);
1154 let v = dry_run_plan("+63••••••••12", Channel::App, "http://x:9222", &log, 1030);
1155 assert_eq!(v["kind"], json!("dry_run"));
1156 assert_eq!(v["dry_run"], json!(true));
1157 assert_eq!(v["sent"], json!(false));
1158 assert_eq!(v["phone"], json!("+63••••••••12"));
1159 assert_eq!(v["sends_last_hour"], json!(1));
1160 assert_eq!(v["next_send_allowed_in_secs"], json!(30));
1161 assert!(
1162 v["would_send"]
1163 .as_str()
1164 .unwrap_or_default()
1165 .contains("approval")
1166 );
1167 }
1168
1169 fn probe(host: &str, path: &str, text: &str) -> Value {
1170 json!({"host": host, "path": path, "text": text, "has_phone": false, "options": [], "captcha": false})
1171 }
1172
1173 #[test]
1174 fn classifies_screens() {
1175 let mut phone = probe(
1176 "weblogin.grab.com",
1177 "/passenger/login",
1178 "Sign in to Grab\n+63",
1179 );
1180 phone["has_phone"] = json!(true);
1181 assert_eq!(classify_screen(&phone), Screen::Phone);
1182 assert_eq!(
1183 classify_screen(&probe("weblogin.grab.com", "/passenger/login", "")),
1184 Screen::Loading
1185 );
1186 let mut opts = probe(
1187 "weblogin.grab.com",
1188 "/passenger/challenge-options",
1189 "Choose a preferred way to sign in",
1190 );
1191 opts["options"] = json!(["PUSH_V2", "CHANNEL_OPTIMIZED_PHONE_OTP"]);
1192 assert_eq!(
1193 classify_screen(&opts),
1194 Screen::Options(vec!["PUSH_V2".into(), "CHANNEL_OPTIMIZED_PHONE_OTP".into()])
1195 );
1196 assert_eq!(
1197 classify_screen(&probe(
1198 "weblogin.grab.com",
1199 "/passenger/optimised-sms-otp",
1200 "Check your WhatsApp\nWe've sent a verification code"
1201 )),
1202 Screen::Otp {
1203 channel: Some("WhatsApp".into())
1204 }
1205 );
1206 assert_eq!(
1207 classify_screen(&probe(
1208 "weblogin.grab.com",
1209 "/passenger/push-v2",
1210 "Approve in mobile app"
1211 )),
1212 Screen::Approval
1213 );
1214 assert_eq!(
1215 classify_screen(&probe(
1216 "weblogin.grab.com",
1217 "/passenger/optimised-sms-otp",
1218 "Incorrect code. You can try 2 more times."
1219 )),
1220 Screen::OtpRejected
1221 );
1222 assert!(matches!(
1223 classify_screen(&probe(
1224 "weblogin.grab.com",
1225 "/passenger/x",
1226 "Account temporarily blocked"
1227 )),
1228 Screen::Problem(_)
1229 ));
1230 assert_eq!(
1231 classify_screen(&probe("weblogin.grab.com", "/passenger/web-selfie", "")),
1232 Screen::StepUp("web-selfie".into())
1233 );
1234 let mut cap = probe("weblogin.grab.com", "/passenger/login", "");
1235 cap["captcha"] = json!(true);
1236 assert_eq!(classify_screen(&cap), Screen::StepUp("recaptcha".into()));
1237 assert_eq!(
1238 classify_screen(&probe("food.grab.com", "/ph/en/", "")),
1239 Screen::Done
1240 );
1241 assert_eq!(
1242 classify_screen(&probe("food.grab.com", "/auth/callback", "")),
1243 Screen::Loading
1244 );
1245 assert_eq!(
1247 classify_screen(&probe(
1248 "weblogin.grab.com",
1249 "/passenger/new",
1250 "Check your SMS"
1251 )),
1252 Screen::Otp {
1253 channel: Some("SMS".into())
1254 }
1255 );
1256 }
1257
1258 #[test]
1259 fn finds_tracking_tabs() {
1260 let pages = vec![
1261 PageInfo {
1262 id: "1".into(),
1263 url: "https://food.grab.com/ph/en/order/001-ABC".into(),
1264 },
1265 PageInfo {
1266 id: "2".into(),
1267 url: "https://food.grab.com/ph/en/restaurants".into(),
1268 },
1269 PageInfo {
1270 id: "3".into(),
1271 url: "https://example.com/order/1".into(),
1272 },
1273 ];
1274 let tabs = order_tracking_tabs(&pages);
1275 assert_eq!(tabs, vec!["food.grab.com/ph/en/order/001-ABC".to_string()]);
1276 assert!(tracking_tab_warning(&tabs).is_some_and(|w| w.contains("logout")));
1277 assert!(tracking_tab_warning(&[]).is_none());
1278 }
1279
1280 #[test]
1281 fn stale_tabs_are_dropped() {
1282 let a = "food.grab.com/ph/en/order/A".to_string();
1283 let b = "food.grab.com/ph/en/order/B".to_string();
1284 assert_eq!(
1285 still_open(&[a.clone(), b.clone()], std::slice::from_ref(&b)),
1286 vec![b.clone()]
1287 );
1288 assert!(still_open(&[a], &[]).is_empty());
1289 }
1290}