@@ -20,7 +20,11 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
2020
2121#[cfg(test)]
2222use loc_cli::connect::DEFAULT_NOTION_PROFILE_ID;
23- use loc_cli::connect::{BrokerOAuthConnectOptions, run_connect_notion_broker_oauth};
23+ use loc_cli::connect::{
24+ BrokerOAuthConnectOptions, GmailBrokerOAuthConnectOptions, GoogleDocsBrokerOAuthConnectOptions,
25+ run_connect_gmail_broker_oauth, run_connect_google_docs_broker_oauth,
26+ run_connect_notion_broker_oauth,
27+ };
2428use loc_cli::daemon::{DaemonRunState, run_daemon_control};
2529use loc_cli::diff::{DiffReport, run_diff};
2630#[cfg(target_os = "windows")]
@@ -47,6 +51,7 @@ use loc_cli::search::{
4751use loc_cli::status::{StatusOptions, StatusState, StatusSyncState, run_status};
4852#[cfg(test)]
4953use locality_connector::ConnectorCapabilities;
54+ use locality_connector::oauth_broker::OAuthBrokerStart;
5055use locality_core::canonical::parse_canonical_markdown;
5156use locality_core::conflict::{
5257 has_unresolved_conflict_markers, render_inline_conflict_markdown_with_base,
@@ -55,6 +60,14 @@ use locality_core::freshness::RemoteVersion;
5560use locality_core::hydration::{HydrationReason, HydrationRequest};
5661use locality_core::journal::{JournalEntry, JournalStatus};
5762use locality_core::model::{EntityKind, HydrationState, MountId, RemoteId, TreeEntry};
63+ use locality_gmail::{
64+ DEFAULT_GMAIL_OAUTH_BROKER_URL, DEFAULT_GMAIL_OAUTH_REDIRECT_URI, GMAIL_CONNECTOR_ID,
65+ HttpGmailOAuthBrokerClient,
66+ };
67+ use locality_google_docs::{
68+ DEFAULT_GOOGLE_DOCS_OAUTH_BROKER_URL, DEFAULT_GOOGLE_DOCS_OAUTH_REDIRECT_URI,
69+ GOOGLE_DOCS_CONNECTOR_ID, HttpGoogleDocsOAuthBrokerClient,
70+ };
5871#[cfg(test)]
5972use locality_notion::NotionConfig;
6073#[cfg(test)]
@@ -88,6 +101,7 @@ use locality_store::{
88101use locality_store::{ConnectorProfileId, ConnectorProfileRecord, ConnectorProfileRepository};
89102use localityd::autosave::auto_save_timestamp;
90103use localityd::file_provider::{self as daemon_file_provider, ROOT_CONTAINER_IDENTIFIER};
104+ use localityd::google_docs::resolve_google_docs_connector_for_mount;
91105use localityd::hydration::HydrationSource;
92106use localityd::ipc::{
93107 DaemonBuildInfo, DaemonDebugQueueStatus, DaemonRequest, DaemonStatusReport, send_request,
@@ -493,6 +507,8 @@ struct MountLiveModeChange {
493507}
494508
495509static CONNECT_NOTION_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
510+ static CONNECT_GOOGLE_DOCS_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
511+ static CONNECT_GMAIL_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
496512static DAEMON_LIFECYCLE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
497513static NOTION_LOGIN_LINK: OnceLock<Mutex<Option<String>>> = OnceLock::new();
498514static DESKTOP_SNAPSHOT_CACHE: OnceLock<Mutex<DesktopSnapshotCache>> = OnceLock::new();
@@ -916,6 +932,16 @@ async fn change_notion_access(app: AppHandle) -> ActionReport {
916932 run_notion_connection_flow(app, NotionConnectionAction::ChangeAccess, true).await
917933}
918934
935+ #[tauri::command]
936+ async fn connect_google_docs(app: AppHandle) -> ActionReport {
937+ run_google_docs_connection_flow(app, true).await
938+ }
939+
940+ #[tauri::command]
941+ async fn connect_gmail(app: AppHandle) -> ActionReport {
942+ run_gmail_connection_flow(app, true).await
943+ }
944+
919945#[derive(Clone, Copy)]
920946enum NotionConnectionAction {
921947 Connect,
@@ -1000,6 +1026,103 @@ async fn run_notion_connection_flow(
10001026 report
10011027}
10021028
1029+ async fn run_google_docs_connection_flow(app: AppHandle, open_browser: bool) -> ActionReport {
1030+ if CONNECT_GOOGLE_DOCS_IN_PROGRESS.swap(true, Ordering::AcqRel) {
1031+ return ActionReport {
1032+ ok: false,
1033+ message: "A Google Docs connection flow is already waiting for browser approval."
1034+ .to_string(),
1035+ };
1036+ }
1037+
1038+ let state_root = default_state_root();
1039+ let activity_state_root = state_root.clone();
1040+ let result = tauri::async_runtime::spawn_blocking(move || {
1041+ connect_google_docs_with_broker(state_root, open_browser)
1042+ })
1043+ .await
1044+ .map_err(|error| format!("Google Docs OAuth worker failed: {error}"));
1045+ CONNECT_GOOGLE_DOCS_IN_PROGRESS.store(false, Ordering::Release);
1046+
1047+ let report = match result {
1048+ Ok(Ok(message)) => {
1049+ if let Err(error) = record_desktop_activity(
1050+ &activity_state_root,
1051+ "Connected Google Docs",
1052+ &message,
1053+ "connect",
1054+ ) {
1055+ desktop_log(
1056+ "warn",
1057+ "activity.record_failed",
1058+ format!("could not record Google Docs access activity: {error}"),
1059+ );
1060+ }
1061+ ActionReport { ok: true, message }
1062+ }
1063+ Ok(Err(message)) | Err(message) => {
1064+ desktop_log(
1065+ "warn",
1066+ "google_docs_access.failed",
1067+ format!("connect google docs failed: {message}"),
1068+ );
1069+ ActionReport { ok: false, message }
1070+ }
1071+ };
1072+ if report.ok {
1073+ refresh_desktop_surfaces(&app);
1074+ }
1075+ report
1076+ }
1077+
1078+ async fn run_gmail_connection_flow(app: AppHandle, open_browser: bool) -> ActionReport {
1079+ if CONNECT_GMAIL_IN_PROGRESS.swap(true, Ordering::AcqRel) {
1080+ return ActionReport {
1081+ ok: false,
1082+ message: "A Gmail connection flow is already waiting for browser approval.".to_string(),
1083+ };
1084+ }
1085+
1086+ let state_root = default_state_root();
1087+ let activity_state_root = state_root.clone();
1088+ let result = tauri::async_runtime::spawn_blocking(move || {
1089+ connect_gmail_with_broker(state_root, open_browser)
1090+ })
1091+ .await
1092+ .map_err(|error| format!("Gmail OAuth worker failed: {error}"));
1093+ CONNECT_GMAIL_IN_PROGRESS.store(false, Ordering::Release);
1094+
1095+ let report = match result {
1096+ Ok(Ok(message)) => {
1097+ if let Err(error) = record_desktop_activity(
1098+ &activity_state_root,
1099+ "Connected Gmail",
1100+ &message,
1101+ "connect",
1102+ ) {
1103+ desktop_log(
1104+ "warn",
1105+ "activity.record_failed",
1106+ format!("could not record Gmail access activity: {error}"),
1107+ );
1108+ }
1109+ ActionReport { ok: true, message }
1110+ }
1111+ Ok(Err(message)) | Err(message) => {
1112+ desktop_log(
1113+ "warn",
1114+ "gmail_access.failed",
1115+ format!("connect gmail failed: {message}"),
1116+ );
1117+ ActionReport { ok: false, message }
1118+ }
1119+ };
1120+ if report.ok {
1121+ refresh_desktop_surfaces(&app);
1122+ }
1123+ report
1124+ }
1125+
10031126#[tauri::command]
10041127fn notion_login_link() -> Option<String> {
10051128 notion_login_link_slot()
@@ -5705,7 +5828,7 @@ fn resolve_desktop_mount_root(path: &str) -> Result<PathBuf, String> {
57055828 {
57065829 let path = path.trim();
57075830 if path.is_empty() {
5708- return Err("Choose a CloudStorage folder for the Notion mount.".to_string());
5831+ return Err("Choose a CloudStorage folder for the source mount.".to_string());
57095832 }
57105833 if !path.contains('/') && !path.starts_with('~') {
57115834 return Ok(macos_locality_cloud_storage_root().join(path));
@@ -6494,8 +6617,27 @@ fn create_desktop_mount_blocking(request: CreateDesktopMountRequest) -> Result<S
64946617 .ok_or_else(|| {
64956618 "Google Docs mounts need a workspace folder name or ID.".to_string()
64966619 })?;
6497- Some(RemoteId::new(workspace.to_string()))
6620+ let temp_mount = MountConfig {
6621+ mount_id: mount_id.clone(),
6622+ connector: connector.clone(),
6623+ root: root.clone(),
6624+ remote_root_id: None,
6625+ connection_id: connection_id.clone(),
6626+ read_only: request.read_only,
6627+ projection: projection.clone(),
6628+ };
6629+ let credentials = open_credential_store(&state_root);
6630+ let connector =
6631+ resolve_google_docs_connector_for_mount(&store, credentials.as_ref(), &temp_mount)
6632+ .map_err(|error| error.message())?;
6633+ let folder_id = connector
6634+ .resolve_workspace_folder(workspace)
6635+ .map_err(|error| {
6636+ format!("Failed to resolve Google Docs workspace folder `{workspace}`: {error}")
6637+ })?;
6638+ Some(folder_id)
64986639 }
6640+ "gmail" => None,
64996641 other => {
65006642 return Err(format!(
65016643 "Desktop mount creation does not support connector `{other}`."
@@ -8948,6 +9090,112 @@ fn connect_notion_with_broker(state_root: PathBuf, open_browser: bool) -> Result
89489090 Ok(format!("{connected_message} {refresh_message}"))
89499091}
89509092
9093+ fn connect_google_docs_with_broker(
9094+ state_root: PathBuf,
9095+ open_browser: bool,
9096+ ) -> Result<String, String> {
9097+ let mut store = SqliteStateStore::open(state_root.clone())
9098+ .map_err(|error| format!("Could not open Locality state: {error}"))?;
9099+ let credentials = open_credential_store(&state_root);
9100+ let broker_url = env_first(&[
9101+ "LOCALITY_GOOGLE_DOCS_OAUTH_BROKER_URL",
9102+ "LOCALITY_AUTH_BROKER_URL",
9103+ ])
9104+ .unwrap_or_else(|| DEFAULT_GOOGLE_DOCS_OAUTH_BROKER_URL.to_string());
9105+ let redirect_uri = env_first(&[
9106+ "LOCALITY_GOOGLE_DOCS_OAUTH_REDIRECT_URI",
9107+ "GOOGLE_DOCS_OAUTH_REDIRECT_URI",
9108+ ])
9109+ .unwrap_or_else(|| DEFAULT_GOOGLE_DOCS_OAUTH_REDIRECT_URI.to_string());
9110+ let broker = HttpGoogleDocsOAuthBrokerClient::new(broker_url.clone());
9111+ let start = broker
9112+ .start(&OAuthBrokerStart {
9113+ connector: GOOGLE_DOCS_CONNECTOR_ID.to_string(),
9114+ redirect_uri,
9115+ })
9116+ .map_err(|error| format!("Could not start Google Docs OAuth broker flow: {error}"))?;
9117+ let authorization = run_local_oauth_authorization(
9118+ "Google Docs",
9119+ &start.authorization_url,
9120+ &start.redirect_uri,
9121+ &start.state,
9122+ !open_browser,
9123+ true,
9124+ )
9125+ .map_err(|error| error.message)?;
9126+ let options = GoogleDocsBrokerOAuthConnectOptions {
9127+ connection_id: None,
9128+ broker_url,
9129+ client_id: start.client_id,
9130+ session: start.session,
9131+ state: start.state,
9132+ code: authorization.code,
9133+ redirect_uri: start.redirect_uri,
9134+ };
9135+
9136+ let report =
9137+ run_connect_google_docs_broker_oauth(&mut store, credentials.as_ref(), options, &broker)
9138+ .map_err(|error| error.message())?;
9139+ let connected_message = match report.workspace_name.or(report.account_label) {
9140+ Some(label) if !label.is_empty() => format!("Connected Google Docs account {label}."),
9141+ _ => "Connected Google Docs.".to_string(),
9142+ };
9143+ Ok(format!(
9144+ "{connected_message} Create a Google Docs source folder to mount a Drive workspace."
9145+ ))
9146+ }
9147+
9148+ fn connect_gmail_with_broker(state_root: PathBuf, open_browser: bool) -> Result<String, String> {
9149+ let mut store = SqliteStateStore::open(state_root.clone())
9150+ .map_err(|error| format!("Could not open Locality state: {error}"))?;
9151+ let credentials = open_credential_store(&state_root);
9152+ let broker_url = env_first(&[
9153+ "LOCALITY_GMAIL_OAUTH_BROKER_URL",
9154+ "LOCALITY_AUTH_BROKER_URL",
9155+ ])
9156+ .unwrap_or_else(|| DEFAULT_GMAIL_OAUTH_BROKER_URL.to_string());
9157+ let redirect_uri = env_first(&[
9158+ "LOCALITY_GMAIL_OAUTH_REDIRECT_URI",
9159+ "GMAIL_OAUTH_REDIRECT_URI",
9160+ ])
9161+ .unwrap_or_else(|| DEFAULT_GMAIL_OAUTH_REDIRECT_URI.to_string());
9162+ let broker = HttpGmailOAuthBrokerClient::new(broker_url.clone());
9163+ let start = broker
9164+ .start(&OAuthBrokerStart {
9165+ connector: GMAIL_CONNECTOR_ID.to_string(),
9166+ redirect_uri,
9167+ })
9168+ .map_err(|error| format!("Could not start Gmail OAuth broker flow: {error}"))?;
9169+ let authorization = run_local_oauth_authorization(
9170+ "Gmail",
9171+ &start.authorization_url,
9172+ &start.redirect_uri,
9173+ &start.state,
9174+ !open_browser,
9175+ true,
9176+ )
9177+ .map_err(|error| error.message)?;
9178+ let options = GmailBrokerOAuthConnectOptions {
9179+ connection_id: None,
9180+ broker_url,
9181+ client_id: start.client_id,
9182+ session: start.session,
9183+ state: start.state,
9184+ code: authorization.code,
9185+ redirect_uri: start.redirect_uri,
9186+ };
9187+
9188+ let report = run_connect_gmail_broker_oauth(&mut store, credentials.as_ref(), options, &broker)
9189+ .map_err(|error| error.message())?;
9190+ let connected_message = match report.workspace_name.or(report.account_label) {
9191+ Some(label) if !label.is_empty() => format!("Connected Gmail account {label}."),
9192+ _ => "Connected Gmail.".to_string(),
9193+ };
9194+ Ok(format!(
9195+ "{connected_message} Create a Gmail source folder to mount inbox, sent, and draft mailboxes."
9196+ ))
9197+ }
9198+
89519199fn notion_login_link_slot() -> &'static Mutex<Option<String>> {
89529200 NOTION_LOGIN_LINK.get_or_init(|| Mutex::new(None))
89539201}
@@ -15741,6 +15989,8 @@ fn main() {
1574115989 connect_notion,
1574215990 connect_notion_without_browser,
1574315991 change_notion_access,
15992+ connect_google_docs,
15993+ connect_gmail,
1574415994 notion_login_link,
1574515995 install_state_review,
1574615996 acknowledge_install_state,
0 commit comments