Skip to content

Commit 4574c56

Browse files
committed
Wire desktop source connectors
1 parent b55b21c commit 4574c56

10 files changed

Lines changed: 560 additions & 92 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ tauri-build = { version = "2", features = [] }
1111

1212
[dependencies]
1313
loc-cli = { path = "../../../crates/loc-cli" }
14+
locality-connector.workspace = true
1415
locality-core.workspace = true
16+
locality-gmail.workspace = true
17+
locality-google-docs.workspace = true
1518
locality-notion.workspace = true
1619
locality-platform.workspace = true
1720
locality-store.workspace = true
@@ -24,9 +27,6 @@ tauri-plugin-dialog = "2"
2427
tauri-plugin-process = "2"
2528
tauri-plugin-updater = "2"
2629

27-
[dev-dependencies]
28-
locality-connector.workspace = true
29-
3030
[target.'cfg(windows)'.dependencies]
3131
windows-sys = { version = "0.61", features = [
3232
"Win32_Foundation",

apps/desktop/src-tauri/src/agent_guidance.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,10 +422,10 @@ Locality projects connected company sources, including Notion, into the local fi
422422
1. If the user gives a Notion URL, run `loc locate <url>` and edit the printed local Markdown path.
423423
2. Edit the local Markdown file directly.
424424
3. Do not edit Locality identity frontmatter, block IDs, `::loc{{...}}` directives, `_schema.yaml`, `AGENTS.md`, or `CLAUDE.md` unless explicitly asked.
425-
4. Leave edits pending for Locality review and tell the user what changed.
425+
4. Unless the user asked you to sync back to Notion, leave edits pending for Locality review and tell the user what changed.
426426
5. Use `loc status` only when you need to inspect pending changes; regular clean files hydrate automatically on open.
427427
6. If desktop Live Mode is on, safe local edits may sync automatically. Do not run routine `loc pull` or `loc push` after every edit.
428-
7. Only push when the user explicitly asks or when Live Mode pauses for review. Run `loc diff <file>` first, then `loc push <file> -y` for safe plans.
428+
7. If the user asks you to sync back to Notion, update Notion, publish, or apply the edit remotely, do not stop after local edits. Run `loc diff <file>` first, then `loc push <file> -y` for safe plans.
429429
8. If push says the remote changed since last sync, run `loc pull <file>`, resolve any inline conflict markers in the Markdown, rerun `loc diff <file>`, then push again.
430430
431431
## Creating Notion Content
@@ -470,7 +470,7 @@ fn managed_instruction_block(mount_path: &str) -> String {
470470

471471
fn suggested_agent_prompt(mount_path: &str) -> String {
472472
format!(
473-
"Use Locality to edit my Notion workspace. Open the Notion files under {mount_path}, make the requested edits directly in Markdown, and leave the changes pending for Locality review."
473+
"Use Locality to edit my Notion workspace. Open the Notion files under {mount_path}, make the requested edits directly in Markdown, and leave the changes pending for Locality review unless I ask you to sync back to Notion. When I do, run `loc diff <file>` and then `loc push <file> -y` for safe plans."
474474
)
475475
}
476476

@@ -1037,6 +1037,7 @@ mod tests {
10371037
assert!(skill.contains("name: locality"));
10381038
assert!(skill.contains("~/Library/CloudStorage/Locality/notion"));
10391039
assert!(skill.contains("pending for Locality review"));
1040+
assert!(skill.contains("sync back to Notion"));
10401041
assert!(skill.contains("If desktop Live Mode is on"));
10411042
assert!(skill.contains("Do not run routine `loc pull` or `loc push`"));
10421043
assert!(skill.contains("loc diff <file>"));

apps/desktop/src-tauri/src/main.rs

Lines changed: 253 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
2020

2121
#[cfg(test)]
2222
use 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+
};
2428
use loc_cli::daemon::{DaemonRunState, run_daemon_control};
2529
use loc_cli::diff::{DiffReport, run_diff};
2630
#[cfg(target_os = "windows")]
@@ -47,6 +51,7 @@ use loc_cli::search::{
4751
use loc_cli::status::{StatusOptions, StatusState, StatusSyncState, run_status};
4852
#[cfg(test)]
4953
use locality_connector::ConnectorCapabilities;
54+
use locality_connector::oauth_broker::OAuthBrokerStart;
5055
use locality_core::canonical::parse_canonical_markdown;
5156
use locality_core::conflict::{
5257
has_unresolved_conflict_markers, render_inline_conflict_markdown_with_base,
@@ -55,6 +60,14 @@ use locality_core::freshness::RemoteVersion;
5560
use locality_core::hydration::{HydrationReason, HydrationRequest};
5661
use locality_core::journal::{JournalEntry, JournalStatus};
5762
use 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)]
5972
use locality_notion::NotionConfig;
6073
#[cfg(test)]
@@ -88,6 +101,7 @@ use locality_store::{
88101
use locality_store::{ConnectorProfileId, ConnectorProfileRecord, ConnectorProfileRepository};
89102
use localityd::autosave::auto_save_timestamp;
90103
use localityd::file_provider::{self as daemon_file_provider, ROOT_CONTAINER_IDENTIFIER};
104+
use localityd::google_docs::resolve_google_docs_connector_for_mount;
91105
use localityd::hydration::HydrationSource;
92106
use localityd::ipc::{
93107
DaemonBuildInfo, DaemonDebugQueueStatus, DaemonRequest, DaemonStatusReport, send_request,
@@ -493,6 +507,8 @@ struct MountLiveModeChange {
493507
}
494508

495509
static 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);
496512
static DAEMON_LIFECYCLE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
497513
static NOTION_LOGIN_LINK: OnceLock<Mutex<Option<String>>> = OnceLock::new();
498514
static 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)]
920946
enum 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]
10041127
fn 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+
89519199
fn 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

Comments
 (0)