Skip to content

Commit 8bf5cdb

Browse files
Tessa Heidkampjo
andcommitted
Bug 2049263 - Add LoginsBridgedEngine to expose logins sync to Desktop via UniFFI
Co-Authored-By: Johannes Salas Schmidt <joschmidt@mozilla.com>
1 parent ad7e69c commit 8bf5cdb

7 files changed

Lines changed: 356 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
[Full Changelog](In progress)
44

5+
## ✨ What's New ✨
6+
7+
### Logins
8+
9+
- Add `LoginStore.bridgedEngine()`, which exposes the logins sync engine to Desktop's Sync. ([bug 2049263](https://bugzilla.mozilla.org/show_bug.cgi?id=2049263))
10+
511
# v153.0 (_2026-06-15_)
612

713
## ⚠️ Breaking Changes ⚠️

components/logins/src/error.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ impl GetErrorHandling for Error {
195195
}
196196
}
197197

198+
// The bridged sync engine (`sync::bridge`) deals in `anyhow::Result`, as that's
199+
// what the `sync15` BridgedEngine traits use. This lets UniFFI map those errors
200+
// onto our public error type when the bridge methods are exposed via the UDL.
201+
impl From<anyhow::Error> for LoginsApiError {
202+
fn from(value: anyhow::Error) -> Self {
203+
LoginsApiError::UnexpectedLoginsApiError {
204+
reason: value.to_string(),
205+
}
206+
}
207+
}
208+
198209
impl From<uniffi::UnexpectedUniFFICallbackError> for LoginsApiError {
199210
fn from(error: uniffi::UnexpectedUniFFICallbackError) -> Self {
200211
LoginsApiError::UnexpectedLoginsApiError {

components/logins/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use crate::encryption::{check_canary, create_canary, create_key};
2929
pub use crate::error::*;
3030
pub use crate::login::*;
3131
pub use crate::store::*;
32-
pub use crate::sync::LoginsSyncEngine;
32+
pub use crate::sync::{LoginsBridgedEngine, LoginsSyncEngine};
3333
use std::sync::Arc;
3434

3535
// Utility function to create a StaticKeyManager to be used for the time being until support lands

components/logins/src/logins.udl

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,58 @@ interface LoginStore {
301301
[Self=ByArc]
302302
void register_with_sync_manager();
303303

304+
/// Returns a bridged sync engine for Desktop's Sync framework.
305+
/// Without this UDL entry the engine is invisible to JS: UniFFI generates
306+
/// the XPCOM glue that lets JS call `rustStore.bridgedEngine()`.
307+
[Throws=LoginsApiError, Self=ByArc]
308+
LoginsBridgedEngine bridged_engine();
309+
304310
[Self=ByArc]
305311
void shutdown();
306312
};
307313

314+
/// The Desktop-facing bridged sync engine. The canonical docs are in
315+
/// https://searchfox.org/mozilla-central/source/services/interfaces/mozIBridgedSyncEngine.idl
316+
/// It's only actually used on Desktop, but it's fine to expose this everywhere.
317+
/// NOTE: all timestamps here are milliseconds.
318+
interface LoginsBridgedEngine {
319+
[Throws=LoginsApiError]
320+
i64 last_sync();
321+
322+
[Throws=LoginsApiError]
323+
void set_last_sync(i64 last_sync);
324+
325+
[Throws=LoginsApiError]
326+
string? sync_id();
327+
328+
[Throws=LoginsApiError]
329+
string reset_sync_id();
330+
331+
[Throws=LoginsApiError]
332+
string ensure_current_sync_id([ByRef]string new_sync_id);
333+
334+
[Throws=LoginsApiError]
335+
void sync_started();
336+
337+
[Throws=LoginsApiError]
338+
void store_incoming(sequence<string> incoming_envelopes_as_json);
339+
340+
[Throws=LoginsApiError]
341+
sequence<string> apply();
342+
343+
[Throws=LoginsApiError]
344+
void set_uploaded(i64 new_timestamp, sequence<string> uploaded_ids);
345+
346+
[Throws=LoginsApiError]
347+
void sync_finished();
348+
349+
[Throws=LoginsApiError]
350+
void reset();
351+
352+
[Throws=LoginsApiError]
353+
void wipe();
354+
};
355+
308356
dictionary RunMaintenanceOptions {
309357
// Wipe un-decryptable logins. These will hopefully come back on the next sync.
310358
boolean delete_undecryptable_records_for_remote_replacement=true;
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
use crate::sync::engine::LoginsSyncEngine;
6+
use crate::LoginStore;
7+
use anyhow::Result;
8+
use std::sync::Arc;
9+
use sync15::bso::{IncomingBso, OutgoingBso};
10+
use sync15::engine::{BridgedEngine, BridgedEngineAdaptor};
11+
use sync15::ServerTimestamp;
12+
use sync_guid::Guid as SyncGuid;
13+
14+
impl LoginStore {
15+
/// Returns a bridged sync engine for Desktop for this store.
16+
///
17+
/// Unlike Tabs, constructing a `LoginsSyncEngine` locks the DB and can
18+
/// fail, so this is fallible (and exposed as `[Throws]` in the UDL). The
19+
/// internal error is surfaced via `anyhow`, which UniFFI maps onto
20+
/// `LoginsApiError` through `From<anyhow::Error>`.
21+
pub fn bridged_engine(self: Arc<Self>) -> Result<Arc<LoginsBridgedEngine>> {
22+
let engine = LoginsSyncEngine::new(self)?;
23+
let bridged_engine = LoginsBridgedEngineAdaptor { engine };
24+
Ok(Arc::new(LoginsBridgedEngine::new(Box::new(bridged_engine))))
25+
}
26+
}
27+
28+
/// `LoginsSyncEngine` only implements the internal `sync15::SyncEngine` trait,
29+
/// which is what the mobile (Android/iOS) sync manager drives. Desktop's Sync
30+
/// framework instead speaks the `mozIBridgedSyncEngine` interface, whose Rust
31+
/// shape is `sync15::BridgedEngine`. This adaptor wraps our `SyncEngine` and,
32+
/// via the blanket `impl<A: BridgedEngineAdaptor> BridgedEngine for A`, gives
33+
/// us a `BridgedEngine` for free. The adaptor exists only because these two
34+
/// sync-engine traits still live side by side; it can go away if they're ever
35+
/// unified.
36+
struct LoginsBridgedEngineAdaptor {
37+
engine: LoginsSyncEngine,
38+
}
39+
40+
/// see sync15/src/engine/bridged_engine.rs for required functions for the trait
41+
impl BridgedEngineAdaptor for LoginsBridgedEngineAdaptor {
42+
fn last_sync(&self) -> Result<i64> {
43+
// `get_last_sync` takes the `&LoginDb` to avoid deadlocking when called
44+
// mid-sync (while the lock is already held). The bridge methods are
45+
// always called outside a sync transaction, so we can lock here.
46+
let db = self.engine.store.lock_db()?;
47+
Ok(self
48+
.engine
49+
.get_last_sync(&db)?
50+
.unwrap_or_default()
51+
.as_millis())
52+
}
53+
54+
fn set_last_sync(&self, last_sync_millis: i64) -> Result<()> {
55+
let db = self.engine.store.lock_db()?;
56+
self.engine
57+
.set_last_sync(&db, ServerTimestamp::from_millis(last_sync_millis))?;
58+
Ok(())
59+
}
60+
61+
fn engine(&self) -> &dyn sync15::engine::SyncEngine {
62+
&self.engine
63+
}
64+
}
65+
66+
// This is what UniFFI exposes; it does nothing other than delegate back to the
67+
// `BridgedEngine` trait object (and handle the JSON (de)serialization of BSOs
68+
// that crosses the FFI boundary).
69+
/// see services/interfaces/mozIBridgedSyncEngine.idl for contract
70+
pub struct LoginsBridgedEngine {
71+
bridge_impl: Box<dyn BridgedEngine>,
72+
}
73+
74+
impl LoginsBridgedEngine {
75+
pub fn new(bridge_impl: Box<dyn BridgedEngine>) -> Self {
76+
Self { bridge_impl }
77+
}
78+
79+
pub fn last_sync(&self) -> Result<i64> {
80+
self.bridge_impl.last_sync()
81+
}
82+
83+
pub fn set_last_sync(&self, last_sync: i64) -> Result<()> {
84+
self.bridge_impl.set_last_sync(last_sync)
85+
}
86+
87+
pub fn sync_id(&self) -> Result<Option<String>> {
88+
self.bridge_impl.sync_id()
89+
}
90+
91+
pub fn reset_sync_id(&self) -> Result<String> {
92+
self.bridge_impl.reset_sync_id()
93+
}
94+
95+
pub fn ensure_current_sync_id(&self, sync_id: &str) -> Result<String> {
96+
self.bridge_impl.ensure_current_sync_id(sync_id)
97+
}
98+
99+
pub fn sync_started(&self) -> Result<()> {
100+
self.bridge_impl.sync_started()
101+
}
102+
103+
// Decode the JSON-encoded IncomingBso's that UniFFI passes to us
104+
fn convert_incoming_bsos(&self, incoming: Vec<String>) -> Result<Vec<IncomingBso>> {
105+
let mut bsos = Vec::with_capacity(incoming.len());
106+
for inc in incoming {
107+
bsos.push(serde_json::from_str::<IncomingBso>(&inc)?);
108+
}
109+
Ok(bsos)
110+
}
111+
112+
// Encode OutgoingBso's into JSON for UniFFI
113+
fn convert_outgoing_bsos(&self, outgoing: Vec<OutgoingBso>) -> Result<Vec<String>> {
114+
let mut bsos = Vec::with_capacity(outgoing.len());
115+
for e in outgoing {
116+
bsos.push(serde_json::to_string(&e)?);
117+
}
118+
Ok(bsos)
119+
}
120+
121+
pub fn store_incoming(&self, incoming: Vec<String>) -> Result<()> {
122+
self.bridge_impl
123+
.store_incoming(self.convert_incoming_bsos(incoming)?)
124+
}
125+
126+
pub fn apply(&self) -> Result<Vec<String>> {
127+
let apply_results = self.bridge_impl.apply()?;
128+
self.convert_outgoing_bsos(apply_results.records)
129+
}
130+
131+
pub fn set_uploaded(&self, server_modified_millis: i64, guids: Vec<String>) -> Result<()> {
132+
// UniFFI hands us plain strings; the bridge works in terms of `Guid`.
133+
let guids: Vec<SyncGuid> = guids.into_iter().map(SyncGuid::from).collect();
134+
self.bridge_impl
135+
.set_uploaded(server_modified_millis, &guids)
136+
}
137+
138+
pub fn sync_finished(&self) -> Result<()> {
139+
self.bridge_impl.sync_finished()
140+
}
141+
142+
pub fn reset(&self) -> Result<()> {
143+
self.bridge_impl.reset()
144+
}
145+
146+
pub fn wipe(&self) -> Result<()> {
147+
self.bridge_impl.wipe()
148+
}
149+
}
150+
151+
#[cfg(test)]
152+
mod tests {
153+
use super::*;
154+
use crate::db::test_utils::insert_login;
155+
use nss_as::ensure_initialized;
156+
use std::collections::HashMap;
157+
158+
// Exercises the sync-metadata plumbing (last_sync / sync_id / reset) that
159+
// Desktop's Sync framework drives through the bridge, mirroring the Tabs
160+
// `test_sync_meta` test.
161+
#[test]
162+
fn test_sync_meta() {
163+
ensure_initialized();
164+
error_support::init_for_tests();
165+
166+
let store = Arc::new(LoginStore::new_in_memory());
167+
let bridge = store.bridged_engine().expect("should create bridge");
168+
169+
// Fresh DB: never synced.
170+
assert_eq!(bridge.last_sync().unwrap(), 0);
171+
bridge.set_last_sync(3).unwrap();
172+
assert_eq!(bridge.last_sync().unwrap(), 3);
173+
174+
assert!(bridge.sync_id().unwrap().is_none());
175+
176+
bridge.ensure_current_sync_id("some_guid").unwrap();
177+
assert_eq!(bridge.sync_id().unwrap(), Some("some_guid".to_string()));
178+
// changing the sync ID should reset the timestamp
179+
assert_eq!(bridge.last_sync().unwrap(), 0);
180+
bridge.set_last_sync(3).unwrap();
181+
182+
bridge.reset_sync_id().unwrap();
183+
// should now be a random guid.
184+
assert_ne!(bridge.sync_id().unwrap(), Some("some_guid".to_string()));
185+
// should have reset the last sync timestamp.
186+
assert_eq!(bridge.last_sync().unwrap(), 0);
187+
bridge.set_last_sync(3).unwrap();
188+
189+
// `reset` clears the guid and the timestamp
190+
bridge.reset().unwrap();
191+
assert_eq!(bridge.last_sync().unwrap(), 0);
192+
assert!(bridge.sync_id().unwrap().is_none());
193+
}
194+
195+
// A roundtrip through the bridge's data path: stage an incoming remote
196+
// login, apply it, and confirm the local-only login comes back out for
197+
// upload. Unlike `test_sync_meta`, this exercises the JSON (de)serialization
198+
// of BSOs and the staged-incoming `Mutex`. Mirrors the Tabs
199+
// `test_sync_via_bridge` test.
200+
#[test]
201+
fn test_sync_via_bridge() {
202+
ensure_initialized();
203+
error_support::init_for_tests();
204+
205+
let store = Arc::new(LoginStore::new_in_memory());
206+
207+
// A local-only login: nothing on the server knows about it yet, so it
208+
// should be uploaded.
209+
insert_login(
210+
&store.lock_db().unwrap(),
211+
"local-only-aaaa",
212+
Some("local-password"),
213+
None,
214+
);
215+
216+
let bridge = store
217+
.clone()
218+
.bridged_engine()
219+
.expect("should create bridge");
220+
221+
bridge.sync_started().unwrap();
222+
223+
// An incoming remote login that isn't known locally. We build the
224+
// envelope as raw JSON, exactly as the JS bridge hands it to us.
225+
let incoming = vec![serde_json::json!({
226+
"id": "remote-only-bbbb",
227+
"modified": 0,
228+
"payload": serde_json::json!({
229+
"id": "remote-only-bbbb",
230+
"hostname": "https://remote.example.com",
231+
"formSubmitURL": "https://remote.example.com",
232+
"username": "remote-user",
233+
"password": "remote-password",
234+
})
235+
.to_string(),
236+
})
237+
.to_string()];
238+
bridge
239+
.store_incoming(incoming)
240+
.expect("should store incoming");
241+
242+
// Applying stores the remote record locally and returns the local-only
243+
// login for upload.
244+
let outgoing = bridge.apply().expect("should apply");
245+
let changes: HashMap<String, serde_json::Value> = outgoing
246+
.into_iter()
247+
.map(|s| {
248+
let bso: serde_json::Value = serde_json::from_str(&s).unwrap();
249+
let payload: serde_json::Value =
250+
serde_json::from_str(bso["payload"].as_str().unwrap()).unwrap();
251+
(payload["id"].as_str().unwrap().to_string(), payload)
252+
})
253+
.collect();
254+
255+
// Only the local login is outgoing; the just-applied remote one is not
256+
// re-uploaded.
257+
assert_eq!(changes.len(), 1);
258+
assert_eq!(changes["local-only-aaaa"]["password"], "local-password");
259+
260+
// The incoming remote login was actually persisted.
261+
let stored = store
262+
.get("remote-only-bbbb")
263+
.unwrap()
264+
.expect("remote login should have been stored");
265+
assert_eq!(stored.password, "remote-password");
266+
267+
// Acknowledging the upload advances last_sync.
268+
bridge
269+
.set_uploaded(1234, vec!["local-only-aaaa".to_string()])
270+
.unwrap();
271+
bridge.sync_finished().unwrap();
272+
assert_eq!(bridge.last_sync().unwrap(), 1234);
273+
}
274+
}

0 commit comments

Comments
 (0)