clawdex-mobile 5.2.0 → 5.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/services/rust-bridge/Cargo.lock +1 -1
- package/services/rust-bridge/Cargo.toml +1 -1
- package/services/rust-bridge/src/main.rs +733 -0
- package/vendor/bridge-binaries/darwin-arm64/codex-rust-bridge +0 -0
- package/vendor/bridge-binaries/darwin-x64/codex-rust-bridge +0 -0
- package/vendor/bridge-binaries/linux-arm64/codex-rust-bridge +0 -0
- package/vendor/bridge-binaries/linux-armv7l/codex-rust-bridge +0 -0
- package/vendor/bridge-binaries/linux-x64/codex-rust-bridge +0 -0
- package/vendor/bridge-binaries/win32-x64/codex-rust-bridge.exe +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 5.2.3 - 2026-05-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Push notifications for agent turn completion and approval requests. Because the app's WebSocket closes when backgrounded, the always-on bridge is the sender: devices register an Expo push token (`bridge/push/register`/`unregister`/`list`) and the bridge POSTs a minimal payload to the Expo push service, which relays via APNs (iOS) and FCM (Android). Auto-enabled on first bridge connect; Settings → Notifications is the override (opt out + per-event toggles). Foreground banners are suppressed; tapping a notification deep-links to the thread.
|
|
9
|
+
- Reply preview on turn-completed notifications: the agent's last reply line, whitespace-collapsed and capped at 140 characters. A reply snippet therefore transits Expo/Apple/Google when notifications are enabled; disclosed in the privacy policy and store data-safety answers.
|
|
10
|
+
- Actionable Approve/Deny buttons on approval notifications, resolved over the authenticated bridge WebSocket without opening the conversation.
|
|
11
|
+
- Android push support (Firebase project + FCM v1 credentials).
|
|
12
|
+
- Prompt library in the composer: save and one-tap-insert reusable prompts, with search and inline add/edit/delete.
|
|
13
|
+
|
|
14
|
+
### Improved
|
|
15
|
+
- Push delivery hardened for scale: retry with exponential backoff on Expo 429/5xx and transport errors, plus delayed receipt polling that prunes unregistered device tokens.
|
|
16
|
+
- Version synced to 5.2.3 across the monorepo (CLI, mobile app, and Rust bridge).
|
|
17
|
+
|
|
5
18
|
## 5.2.0 - 2026-05-18
|
|
6
19
|
|
|
7
20
|
### Added
|
package/package.json
CHANGED
|
@@ -663,6 +663,552 @@ async fn configure_git_credential_store(
|
|
|
663
663
|
Ok(())
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
+
// ---- Push notifications ----------------------------------------------------
|
|
667
|
+
//
|
|
668
|
+
// The mobile app can only run JavaScript (and therefore keep its WebSocket
|
|
669
|
+
// open) while it is foregrounded. The moment it is backgrounded or killed the
|
|
670
|
+
// socket closes, so the *phone* can never observe a turn completing. The bridge
|
|
671
|
+
// is the only component reliably alive at that moment, so it is the sender:
|
|
672
|
+
// devices register an Expo push token, and the bridge POSTs a minimal,
|
|
673
|
+
// content-free payload to the Expo push service when a turn completes or an
|
|
674
|
+
// approval is requested. Expo relays to APNs/FCM, which wakes the app.
|
|
675
|
+
|
|
676
|
+
const PUSH_REGISTRY_FILE_NAME: &str = ".clawdex-push-registry.json";
|
|
677
|
+
const EXPO_PUSH_SEND_ENDPOINT: &str = "https://exp.host/--/api/v2/push/send";
|
|
678
|
+
const EXPO_PUSH_RECEIPTS_ENDPOINT: &str = "https://exp.host/--/api/v2/push/getReceipts";
|
|
679
|
+
const EXPO_PUSH_BATCH_SIZE: usize = 100;
|
|
680
|
+
// Reply-preview tuning: cap how much streamed text we buffer per thread, and how
|
|
681
|
+
// many characters of the first line we surface in the notification body.
|
|
682
|
+
const PUSH_PREVIEW_ACCUMULATE_CAP: usize = 8000;
|
|
683
|
+
const PUSH_PREVIEW_MAX_CHARS: usize = 140;
|
|
684
|
+
const EXPO_RECEIPT_BATCH_SIZE: usize = 1000;
|
|
685
|
+
// Expo asks senders to wait at least ~15 minutes before fetching delivery receipts.
|
|
686
|
+
const RECEIPT_CHECK_DELAY_SECS: u64 = 900;
|
|
687
|
+
const PUSH_SEND_MAX_ATTEMPTS: u32 = 4;
|
|
688
|
+
|
|
689
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
690
|
+
#[serde(rename_all = "camelCase")]
|
|
691
|
+
struct PushEventPreferences {
|
|
692
|
+
#[serde(default = "default_true")]
|
|
693
|
+
turn_completed: bool,
|
|
694
|
+
#[serde(default = "default_true")]
|
|
695
|
+
approval_requested: bool,
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
fn default_true() -> bool {
|
|
699
|
+
true
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
impl Default for PushEventPreferences {
|
|
703
|
+
fn default() -> Self {
|
|
704
|
+
Self {
|
|
705
|
+
turn_completed: true,
|
|
706
|
+
approval_requested: true,
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
712
|
+
#[serde(rename_all = "camelCase")]
|
|
713
|
+
struct PushDeviceRegistration {
|
|
714
|
+
token: String,
|
|
715
|
+
#[serde(default)]
|
|
716
|
+
platform: String,
|
|
717
|
+
#[serde(default)]
|
|
718
|
+
device_name: String,
|
|
719
|
+
#[serde(default)]
|
|
720
|
+
events: PushEventPreferences,
|
|
721
|
+
created_at: String,
|
|
722
|
+
updated_at: String,
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
726
|
+
#[serde(rename_all = "camelCase")]
|
|
727
|
+
struct PushRegistry {
|
|
728
|
+
#[serde(default)]
|
|
729
|
+
devices: Vec<PushDeviceRegistration>,
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
struct PushService {
|
|
733
|
+
registry: RwLock<PushRegistry>,
|
|
734
|
+
registry_path: PathBuf,
|
|
735
|
+
project_label: String,
|
|
736
|
+
http: reqwest::Client,
|
|
737
|
+
access_token: Option<String>,
|
|
738
|
+
// Accumulates the in-flight agent reply text per thread (keyed by threadId),
|
|
739
|
+
// so a turn/completed push can include a short preview of what the agent said.
|
|
740
|
+
recent_replies: RwLock<HashMap<String, String>>,
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
impl PushService {
|
|
744
|
+
async fn load(workdir: &Path, project_label: String) -> Arc<Self> {
|
|
745
|
+
let registry_path = workdir.join(PUSH_REGISTRY_FILE_NAME);
|
|
746
|
+
let registry = match tokio::fs::read_to_string(®istry_path).await {
|
|
747
|
+
Ok(contents) => serde_json::from_str::<PushRegistry>(&contents).unwrap_or_default(),
|
|
748
|
+
Err(_) => PushRegistry::default(),
|
|
749
|
+
};
|
|
750
|
+
let access_token = env::var("EXPO_ACCESS_TOKEN")
|
|
751
|
+
.ok()
|
|
752
|
+
.map(|value| value.trim().to_string())
|
|
753
|
+
.filter(|value| !value.is_empty());
|
|
754
|
+
Arc::new(Self {
|
|
755
|
+
registry: RwLock::new(registry),
|
|
756
|
+
registry_path,
|
|
757
|
+
project_label,
|
|
758
|
+
http: reqwest::Client::new(),
|
|
759
|
+
access_token,
|
|
760
|
+
recent_replies: RwLock::new(HashMap::new()),
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
fn spawn_event_loop(self: &Arc<Self>, hub: &Arc<ClientHub>) {
|
|
765
|
+
let this = Arc::clone(self);
|
|
766
|
+
let mut receiver = hub.subscribe_notifications();
|
|
767
|
+
tokio::spawn(async move {
|
|
768
|
+
loop {
|
|
769
|
+
match receiver.recv().await {
|
|
770
|
+
Ok(notification) => {
|
|
771
|
+
this.handle_notification(¬ification.method, ¬ification.params)
|
|
772
|
+
.await;
|
|
773
|
+
}
|
|
774
|
+
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
|
775
|
+
Err(broadcast::error::RecvError::Closed) => break,
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async fn persist(&self) {
|
|
782
|
+
let snapshot = { self.registry.read().await.clone() };
|
|
783
|
+
match serde_json::to_string_pretty(&snapshot) {
|
|
784
|
+
Ok(contents) => {
|
|
785
|
+
if let Err(error) = tokio::fs::write(&self.registry_path, contents).await {
|
|
786
|
+
eprintln!("failed to persist push registry: {error}");
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
Err(error) => eprintln!("failed to serialize push registry: {error}"),
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async fn register(
|
|
794
|
+
&self,
|
|
795
|
+
token: String,
|
|
796
|
+
platform: String,
|
|
797
|
+
device_name: String,
|
|
798
|
+
events: PushEventPreferences,
|
|
799
|
+
) -> usize {
|
|
800
|
+
let now = now_iso();
|
|
801
|
+
let count = {
|
|
802
|
+
let mut registry = self.registry.write().await;
|
|
803
|
+
if let Some(existing) = registry
|
|
804
|
+
.devices
|
|
805
|
+
.iter_mut()
|
|
806
|
+
.find(|device| device.token == token)
|
|
807
|
+
{
|
|
808
|
+
existing.platform = platform;
|
|
809
|
+
existing.device_name = device_name;
|
|
810
|
+
existing.events = events;
|
|
811
|
+
existing.updated_at = now;
|
|
812
|
+
} else {
|
|
813
|
+
registry.devices.push(PushDeviceRegistration {
|
|
814
|
+
token,
|
|
815
|
+
platform,
|
|
816
|
+
device_name,
|
|
817
|
+
events,
|
|
818
|
+
created_at: now.clone(),
|
|
819
|
+
updated_at: now,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
registry.devices.len()
|
|
823
|
+
};
|
|
824
|
+
self.persist().await;
|
|
825
|
+
count
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async fn unregister(&self, token: &str) -> bool {
|
|
829
|
+
let removed = {
|
|
830
|
+
let mut registry = self.registry.write().await;
|
|
831
|
+
let before = registry.devices.len();
|
|
832
|
+
registry.devices.retain(|device| device.token != token);
|
|
833
|
+
registry.devices.len() != before
|
|
834
|
+
};
|
|
835
|
+
if removed {
|
|
836
|
+
self.persist().await;
|
|
837
|
+
}
|
|
838
|
+
removed
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async fn list(&self) -> Vec<Value> {
|
|
842
|
+
let registry = self.registry.read().await;
|
|
843
|
+
registry
|
|
844
|
+
.devices
|
|
845
|
+
.iter()
|
|
846
|
+
.map(|device| {
|
|
847
|
+
json!({
|
|
848
|
+
"platform": device.platform,
|
|
849
|
+
"deviceName": device.device_name,
|
|
850
|
+
"events": device.events,
|
|
851
|
+
"createdAt": device.created_at,
|
|
852
|
+
"updatedAt": device.updated_at,
|
|
853
|
+
// Never echo full tokens back to clients; expose only a short suffix.
|
|
854
|
+
"tokenSuffix": token_suffix(&device.token),
|
|
855
|
+
})
|
|
856
|
+
})
|
|
857
|
+
.collect()
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/// Pull params.threadId (or thread_id), trimmed and non-empty.
|
|
861
|
+
fn read_thread_id(params: &Value) -> Option<String> {
|
|
862
|
+
read_string(params.get("threadId"))
|
|
863
|
+
.or_else(|| read_string(params.get("thread_id")))
|
|
864
|
+
.map(|value| value.trim().to_string())
|
|
865
|
+
.filter(|value| !value.is_empty())
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/// Accumulate streamed agent reply text per thread so a completed turn can
|
|
869
|
+
/// include a short preview. Handles the app-server delta method and the
|
|
870
|
+
/// codex-event variant; only text deltas are captured. Returns true if the
|
|
871
|
+
/// notification was a reply delta (and thus fully handled here).
|
|
872
|
+
async fn accumulate_reply(&self, method: &str, params: &Value) -> bool {
|
|
873
|
+
let is_delta = matches!(
|
|
874
|
+
method,
|
|
875
|
+
"item/agentMessage/delta" | "codex/event/agent_message_delta"
|
|
876
|
+
);
|
|
877
|
+
if !is_delta {
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
let field_is_text = read_string(params.get("field"))
|
|
881
|
+
.map(|value| value == "text")
|
|
882
|
+
.unwrap_or(true);
|
|
883
|
+
let delta = read_string(params.get("delta"))
|
|
884
|
+
.or_else(|| read_string(params.get("text")))
|
|
885
|
+
.unwrap_or_default();
|
|
886
|
+
if !field_is_text || delta.is_empty() {
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
if let Some(thread_id) = Self::read_thread_id(params) {
|
|
890
|
+
let mut replies = self.recent_replies.write().await;
|
|
891
|
+
let entry = replies.entry(thread_id).or_default();
|
|
892
|
+
// Cap accumulation so a long turn cannot grow this unbounded.
|
|
893
|
+
if entry.len() < PUSH_PREVIEW_ACCUMULATE_CAP {
|
|
894
|
+
entry.push_str(&delta);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
true
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/// Remove and format the accumulated reply for a thread into a one-line
|
|
901
|
+
/// preview: last non-empty line (agents usually end with the conclusion),
|
|
902
|
+
/// whitespace-collapsed, length-capped.
|
|
903
|
+
async fn take_reply_preview(&self, thread_id: &str) -> Option<String> {
|
|
904
|
+
let raw = {
|
|
905
|
+
let mut replies = self.recent_replies.write().await;
|
|
906
|
+
replies.remove(thread_id)?
|
|
907
|
+
};
|
|
908
|
+
let last_line = raw
|
|
909
|
+
.lines()
|
|
910
|
+
.map(str::trim)
|
|
911
|
+
.filter(|line| !line.is_empty())
|
|
912
|
+
.next_back()?;
|
|
913
|
+
let collapsed = last_line.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
914
|
+
if collapsed.is_empty() {
|
|
915
|
+
return None;
|
|
916
|
+
}
|
|
917
|
+
Some(truncate_chars(&collapsed, PUSH_PREVIEW_MAX_CHARS))
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async fn handle_notification(self: &Arc<Self>, method: &str, params: &Value) {
|
|
921
|
+
if self.accumulate_reply(method, params).await {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
let event = match method {
|
|
925
|
+
"turn/completed" => PushEvent::TurnCompleted,
|
|
926
|
+
"bridge/approval.requested" => PushEvent::ApprovalRequested,
|
|
927
|
+
_ => return,
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
let thread_id = read_string(params.get("threadId"))
|
|
931
|
+
.or_else(|| read_string(params.get("thread_id")))
|
|
932
|
+
.map(|value| value.trim().to_string())
|
|
933
|
+
.filter(|value| !value.is_empty());
|
|
934
|
+
|
|
935
|
+
// For approval events, carry the approval id so a notification action can
|
|
936
|
+
// resolve exactly this approval without opening the conversation first.
|
|
937
|
+
let approval_id = match event {
|
|
938
|
+
PushEvent::ApprovalRequested => read_string(params.get("id"))
|
|
939
|
+
.map(|value| value.trim().to_string())
|
|
940
|
+
.filter(|value| !value.is_empty()),
|
|
941
|
+
PushEvent::TurnCompleted => None,
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// Drain the accumulated reply buffer on completion regardless of whether
|
|
945
|
+
// any device is registered, otherwise threads streamed while no device
|
|
946
|
+
// is subscribed would leak their buffers indefinitely.
|
|
947
|
+
let reply_preview = match event {
|
|
948
|
+
PushEvent::TurnCompleted => match thread_id.as_deref() {
|
|
949
|
+
Some(tid) => self.take_reply_preview(tid).await,
|
|
950
|
+
None => None,
|
|
951
|
+
},
|
|
952
|
+
PushEvent::ApprovalRequested => None,
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
let targets: Vec<String> = {
|
|
956
|
+
let registry = self.registry.read().await;
|
|
957
|
+
registry
|
|
958
|
+
.devices
|
|
959
|
+
.iter()
|
|
960
|
+
.filter(|device| match event {
|
|
961
|
+
PushEvent::TurnCompleted => device.events.turn_completed,
|
|
962
|
+
PushEvent::ApprovalRequested => device.events.approval_requested,
|
|
963
|
+
})
|
|
964
|
+
.map(|device| device.token.clone())
|
|
965
|
+
.collect()
|
|
966
|
+
};
|
|
967
|
+
if targets.is_empty() {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
let (title, body) = match event {
|
|
971
|
+
PushEvent::TurnCompleted => (
|
|
972
|
+
"Turn finished".to_string(),
|
|
973
|
+
reply_preview
|
|
974
|
+
.unwrap_or_else(|| format!("Codex finished working in {}", self.project_label)),
|
|
975
|
+
),
|
|
976
|
+
PushEvent::ApprovalRequested => (
|
|
977
|
+
"Approval needed".to_string(),
|
|
978
|
+
format!(
|
|
979
|
+
"Codex is waiting for your approval in {}",
|
|
980
|
+
self.project_label
|
|
981
|
+
),
|
|
982
|
+
),
|
|
983
|
+
};
|
|
984
|
+
let data = json!({
|
|
985
|
+
"type": event.as_str(),
|
|
986
|
+
"threadId": thread_id,
|
|
987
|
+
"approvalId": approval_id,
|
|
988
|
+
});
|
|
989
|
+
// Only approval pushes get the actionable category; turn-complete pushes
|
|
990
|
+
// have nothing to act on.
|
|
991
|
+
let category_id = match event {
|
|
992
|
+
PushEvent::ApprovalRequested if approval_id.is_some() => Some("approval"),
|
|
993
|
+
_ => None,
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
self.send(&title, &body, &data, category_id, targets).await;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async fn send(
|
|
1000
|
+
self: &Arc<Self>,
|
|
1001
|
+
title: &str,
|
|
1002
|
+
body: &str,
|
|
1003
|
+
data: &Value,
|
|
1004
|
+
category_id: Option<&str>,
|
|
1005
|
+
tokens: Vec<String>,
|
|
1006
|
+
) {
|
|
1007
|
+
for chunk in tokens.chunks(EXPO_PUSH_BATCH_SIZE) {
|
|
1008
|
+
let messages: Vec<Value> = chunk
|
|
1009
|
+
.iter()
|
|
1010
|
+
.map(|token| {
|
|
1011
|
+
let mut message = json!({
|
|
1012
|
+
"to": token,
|
|
1013
|
+
"title": title,
|
|
1014
|
+
"body": body,
|
|
1015
|
+
"data": data,
|
|
1016
|
+
"sound": "default",
|
|
1017
|
+
"priority": "high",
|
|
1018
|
+
});
|
|
1019
|
+
// iOS action buttons are driven by a registered category; the
|
|
1020
|
+
// app maps this id to its Approve/Deny actions.
|
|
1021
|
+
if let Some(category) = category_id {
|
|
1022
|
+
message["categoryId"] = json!(category);
|
|
1023
|
+
}
|
|
1024
|
+
message
|
|
1025
|
+
})
|
|
1026
|
+
.collect();
|
|
1027
|
+
|
|
1028
|
+
let Some(payload) = self
|
|
1029
|
+
.post_with_retry(EXPO_PUSH_SEND_ENDPOINT, &Value::Array(messages))
|
|
1030
|
+
.await
|
|
1031
|
+
else {
|
|
1032
|
+
continue;
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
// Expo returns one ticket per message, in request order. status="error"
|
|
1036
|
+
// is an immediate failure; status="ok" carries a receipt id that we
|
|
1037
|
+
// re-check later, because DeviceNotRegistered (and APNs/FCM delivery
|
|
1038
|
+
// failures) frequently only surface in the receipt, not the ticket.
|
|
1039
|
+
let Some(tickets) = payload.get("data").and_then(Value::as_array) else {
|
|
1040
|
+
continue;
|
|
1041
|
+
};
|
|
1042
|
+
let mut stale: Vec<String> = Vec::new();
|
|
1043
|
+
let mut pending_receipts: Vec<(String, String)> = Vec::new();
|
|
1044
|
+
for (index, ticket) in tickets.iter().enumerate() {
|
|
1045
|
+
let Some(token) = chunk.get(index).cloned() else {
|
|
1046
|
+
continue;
|
|
1047
|
+
};
|
|
1048
|
+
match read_string(ticket.get("status")).as_deref() {
|
|
1049
|
+
Some("ok") => {
|
|
1050
|
+
if let Some(receipt_id) = read_string(ticket.get("id")) {
|
|
1051
|
+
pending_receipts.push((receipt_id, token));
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
Some("error") => {
|
|
1055
|
+
let error_kind = ticket
|
|
1056
|
+
.get("details")
|
|
1057
|
+
.and_then(|details| read_string(details.get("error")));
|
|
1058
|
+
if error_kind.as_deref() == Some("DeviceNotRegistered") {
|
|
1059
|
+
stale.push(token);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
_ => {}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
for token in stale {
|
|
1066
|
+
self.unregister(&token).await;
|
|
1067
|
+
}
|
|
1068
|
+
if !pending_receipts.is_empty() {
|
|
1069
|
+
self.spawn_receipt_check(pending_receipts);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/// POST JSON to Expo, retrying on 429 / 5xx / transport errors with
|
|
1075
|
+
/// exponential backoff (honoring Retry-After). Returns the parsed body, or
|
|
1076
|
+
/// None once attempts are exhausted.
|
|
1077
|
+
async fn post_with_retry(&self, url: &str, body: &Value) -> Option<Value> {
|
|
1078
|
+
let mut delay_ms: u64 = 500;
|
|
1079
|
+
for attempt in 1..=PUSH_SEND_MAX_ATTEMPTS {
|
|
1080
|
+
let mut request = self.http.post(url).json(body);
|
|
1081
|
+
if let Some(token) = &self.access_token {
|
|
1082
|
+
request = request.bearer_auth(token);
|
|
1083
|
+
}
|
|
1084
|
+
match request.send().await {
|
|
1085
|
+
Ok(response) => {
|
|
1086
|
+
let status = response.status();
|
|
1087
|
+
if status.as_u16() == 429 || status.is_server_error() {
|
|
1088
|
+
if attempt >= PUSH_SEND_MAX_ATTEMPTS {
|
|
1089
|
+
eprintln!(
|
|
1090
|
+
"push request to {url} gave up after {attempt} attempts (status {status})"
|
|
1091
|
+
);
|
|
1092
|
+
return None;
|
|
1093
|
+
}
|
|
1094
|
+
let wait_ms = response
|
|
1095
|
+
.headers()
|
|
1096
|
+
.get("retry-after")
|
|
1097
|
+
.and_then(|value| value.to_str().ok())
|
|
1098
|
+
.and_then(|value| value.parse::<u64>().ok())
|
|
1099
|
+
.map(|secs| secs.saturating_mul(1000))
|
|
1100
|
+
.unwrap_or(delay_ms);
|
|
1101
|
+
tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
|
|
1102
|
+
delay_ms = (delay_ms * 2).min(8000);
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
match response.json::<Value>().await {
|
|
1106
|
+
Ok(value) => return Some(value),
|
|
1107
|
+
Err(error) => {
|
|
1108
|
+
eprintln!("push response parse failed: {error}");
|
|
1109
|
+
return None;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
Err(error) => {
|
|
1114
|
+
if attempt >= PUSH_SEND_MAX_ATTEMPTS {
|
|
1115
|
+
eprintln!("push request to {url} failed after {attempt} attempts: {error}");
|
|
1116
|
+
return None;
|
|
1117
|
+
}
|
|
1118
|
+
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
|
1119
|
+
delay_ms = (delay_ms * 2).min(8000);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
None
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/// After Expo's recommended delay, fetch delivery receipts for the given
|
|
1127
|
+
/// (receiptId, token) pairs and prune tokens reported DeviceNotRegistered.
|
|
1128
|
+
fn spawn_receipt_check(self: &Arc<Self>, receipts: Vec<(String, String)>) {
|
|
1129
|
+
let this = Arc::clone(self);
|
|
1130
|
+
tokio::spawn(async move {
|
|
1131
|
+
tokio::time::sleep(std::time::Duration::from_secs(RECEIPT_CHECK_DELAY_SECS)).await;
|
|
1132
|
+
this.check_receipts(receipts).await;
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async fn check_receipts(&self, receipts: Vec<(String, String)>) {
|
|
1137
|
+
for chunk in receipts.chunks(EXPO_RECEIPT_BATCH_SIZE) {
|
|
1138
|
+
let ids: Vec<&str> = chunk.iter().map(|(id, _)| id.as_str()).collect();
|
|
1139
|
+
let Some(payload) = self
|
|
1140
|
+
.post_with_retry(EXPO_PUSH_RECEIPTS_ENDPOINT, &json!({ "ids": ids }))
|
|
1141
|
+
.await
|
|
1142
|
+
else {
|
|
1143
|
+
continue;
|
|
1144
|
+
};
|
|
1145
|
+
let Some(map) = payload.get("data").and_then(Value::as_object) else {
|
|
1146
|
+
continue;
|
|
1147
|
+
};
|
|
1148
|
+
let mut stale: Vec<String> = Vec::new();
|
|
1149
|
+
for (receipt_id, receipt) in map {
|
|
1150
|
+
if read_string(receipt.get("status")).as_deref() != Some("error") {
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
let error_kind = receipt
|
|
1154
|
+
.get("details")
|
|
1155
|
+
.and_then(|details| read_string(details.get("error")));
|
|
1156
|
+
if error_kind.as_deref() == Some("DeviceNotRegistered") {
|
|
1157
|
+
if let Some((_, token)) = chunk.iter().find(|(id, _)| id == receipt_id) {
|
|
1158
|
+
stale.push(token.clone());
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
for token in stale {
|
|
1163
|
+
self.unregister(&token).await;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
#[derive(Clone, Copy)]
|
|
1170
|
+
enum PushEvent {
|
|
1171
|
+
TurnCompleted,
|
|
1172
|
+
ApprovalRequested,
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
impl PushEvent {
|
|
1176
|
+
fn as_str(self) -> &'static str {
|
|
1177
|
+
match self {
|
|
1178
|
+
PushEvent::TurnCompleted => "turn_completed",
|
|
1179
|
+
PushEvent::ApprovalRequested => "approval_requested",
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/// Truncate to at most `max_chars` characters (char-safe), appending an ellipsis
|
|
1185
|
+
/// when content was dropped.
|
|
1186
|
+
fn truncate_chars(text: &str, max_chars: usize) -> String {
|
|
1187
|
+
if text.chars().count() <= max_chars {
|
|
1188
|
+
return text.to_string();
|
|
1189
|
+
}
|
|
1190
|
+
let truncated: String = text.chars().take(max_chars.saturating_sub(1)).collect();
|
|
1191
|
+
format!("{}…", truncated.trim_end())
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
fn token_suffix(token: &str) -> String {
|
|
1195
|
+
let visible: String = token.chars().rev().take(6).collect::<String>();
|
|
1196
|
+
visible.chars().rev().collect()
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
fn parse_push_event_preferences(value: Option<&Value>) -> PushEventPreferences {
|
|
1200
|
+
let defaults = PushEventPreferences::default();
|
|
1201
|
+
match value {
|
|
1202
|
+
Some(object) => PushEventPreferences {
|
|
1203
|
+
turn_completed: read_bool(object.get("turnCompleted"))
|
|
1204
|
+
.unwrap_or(defaults.turn_completed),
|
|
1205
|
+
approval_requested: read_bool(object.get("approvalRequested"))
|
|
1206
|
+
.unwrap_or(defaults.approval_requested),
|
|
1207
|
+
},
|
|
1208
|
+
None => defaults,
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
666
1212
|
#[derive(Clone)]
|
|
667
1213
|
struct AppState {
|
|
668
1214
|
config: Arc<BridgeConfig>,
|
|
@@ -675,6 +1221,7 @@ struct AppState {
|
|
|
675
1221
|
git: Arc<GitService>,
|
|
676
1222
|
updater: Arc<UpdateService>,
|
|
677
1223
|
preview: Arc<BrowserPreviewService>,
|
|
1224
|
+
push: Arc<PushService>,
|
|
678
1225
|
}
|
|
679
1226
|
|
|
680
1227
|
#[allow(dead_code)]
|
|
@@ -6880,6 +7427,15 @@ async fn main() {
|
|
|
6880
7427
|
));
|
|
6881
7428
|
let queue = BridgeQueueService::new(backend.clone(), hub.clone());
|
|
6882
7429
|
|
|
7430
|
+
let project_label = config
|
|
7431
|
+
.workdir
|
|
7432
|
+
.file_name()
|
|
7433
|
+
.map(|name| name.to_string_lossy().to_string())
|
|
7434
|
+
.filter(|name| !name.is_empty())
|
|
7435
|
+
.unwrap_or_else(|| "Clawdex".to_string());
|
|
7436
|
+
let push = PushService::load(&config.workdir, project_label).await;
|
|
7437
|
+
push.spawn_event_loop(&hub);
|
|
7438
|
+
|
|
6883
7439
|
let state = Arc::new(AppState {
|
|
6884
7440
|
config: config.clone(),
|
|
6885
7441
|
started_at: Instant::now(),
|
|
@@ -6891,6 +7447,7 @@ async fn main() {
|
|
|
6891
7447
|
git,
|
|
6892
7448
|
updater,
|
|
6893
7449
|
preview,
|
|
7450
|
+
push,
|
|
6894
7451
|
});
|
|
6895
7452
|
|
|
6896
7453
|
let app = Router::new()
|
|
@@ -7776,6 +8333,36 @@ async fn handle_bridge_method(
|
|
|
7776
8333
|
.map_err(|error| BridgeError::server(&error.to_string())),
|
|
7777
8334
|
"bridge/runtime/read" => serde_json::to_value(state.updater.runtime_info().await)
|
|
7778
8335
|
.map_err(|error| BridgeError::server(&error.to_string())),
|
|
8336
|
+
"bridge/push/register" => {
|
|
8337
|
+
let params = params.unwrap_or_else(|| json!({}));
|
|
8338
|
+
let token = read_string(params.get("token"))
|
|
8339
|
+
.map(|value| value.trim().to_string())
|
|
8340
|
+
.filter(|value| !value.is_empty())
|
|
8341
|
+
.ok_or_else(|| BridgeError::invalid_params("push token is required"))?;
|
|
8342
|
+
let platform = read_string(params.get("platform"))
|
|
8343
|
+
.map(|value| value.trim().to_lowercase())
|
|
8344
|
+
.unwrap_or_else(|| "unknown".to_string());
|
|
8345
|
+
let device_name = read_string(params.get("deviceName"))
|
|
8346
|
+
.map(|value| value.trim().to_string())
|
|
8347
|
+
.filter(|value| !value.is_empty())
|
|
8348
|
+
.unwrap_or_else(|| "Unknown device".to_string());
|
|
8349
|
+
let events = parse_push_event_preferences(params.get("events"));
|
|
8350
|
+
let count = state
|
|
8351
|
+
.push
|
|
8352
|
+
.register(token, platform, device_name, events)
|
|
8353
|
+
.await;
|
|
8354
|
+
Ok(json!({ "ok": true, "deviceCount": count }))
|
|
8355
|
+
}
|
|
8356
|
+
"bridge/push/unregister" => {
|
|
8357
|
+
let params = params.unwrap_or_else(|| json!({}));
|
|
8358
|
+
let token = read_string(params.get("token"))
|
|
8359
|
+
.map(|value| value.trim().to_string())
|
|
8360
|
+
.filter(|value| !value.is_empty())
|
|
8361
|
+
.ok_or_else(|| BridgeError::invalid_params("push token is required"))?;
|
|
8362
|
+
let removed = state.push.unregister(&token).await;
|
|
8363
|
+
Ok(json!({ "ok": true, "removed": removed }))
|
|
8364
|
+
}
|
|
8365
|
+
"bridge/push/list" => Ok(json!({ "devices": state.push.list().await })),
|
|
7779
8366
|
"bridge/cursor/credentials/read" => {
|
|
7780
8367
|
let status = read_cursor_credential_status(state).await?;
|
|
7781
8368
|
serde_json::to_value(status).map_err(|error| BridgeError::server(&error.to_string()))
|
|
@@ -14012,6 +14599,150 @@ fn normalize_path(path: &Path) -> PathBuf {
|
|
|
14012
14599
|
mod tests {
|
|
14013
14600
|
use super::*;
|
|
14014
14601
|
|
|
14602
|
+
#[test]
|
|
14603
|
+
fn token_suffix_masks_all_but_last_six_chars() {
|
|
14604
|
+
assert_eq!(token_suffix("ExponentPushToken[abcdef123456]"), "23456]");
|
|
14605
|
+
assert_eq!(token_suffix("abc"), "abc");
|
|
14606
|
+
assert_eq!(token_suffix(""), "");
|
|
14607
|
+
}
|
|
14608
|
+
|
|
14609
|
+
#[test]
|
|
14610
|
+
fn truncate_chars_caps_and_ellipsizes() {
|
|
14611
|
+
assert_eq!(truncate_chars("short", 140), "short");
|
|
14612
|
+
let long = "a".repeat(200);
|
|
14613
|
+
let out = truncate_chars(&long, 140);
|
|
14614
|
+
assert_eq!(out.chars().count(), 140); // 139 chars + ellipsis
|
|
14615
|
+
assert!(out.ends_with('…'));
|
|
14616
|
+
// Char-safe: must not split a multi-byte char mid-way.
|
|
14617
|
+
let emoji = "🚀".repeat(10);
|
|
14618
|
+
let out = truncate_chars(&emoji, 4);
|
|
14619
|
+
assert_eq!(out.chars().count(), 4);
|
|
14620
|
+
}
|
|
14621
|
+
|
|
14622
|
+
#[tokio::test]
|
|
14623
|
+
async fn take_reply_preview_uses_last_nonempty_line() {
|
|
14624
|
+
let dir = std::env::temp_dir().join(format!("clawdex-preview-{}", std::process::id()));
|
|
14625
|
+
let _ = tokio::fs::create_dir_all(&dir).await;
|
|
14626
|
+
let service = PushService::load(&dir, "demo".to_string()).await;
|
|
14627
|
+
service
|
|
14628
|
+
.accumulate_reply(
|
|
14629
|
+
"item/agentMessage/delta",
|
|
14630
|
+
&json!({ "threadId": "t1", "field": "text", "delta": "Working on it\n Done: fixed the bug \n\n" }),
|
|
14631
|
+
)
|
|
14632
|
+
.await;
|
|
14633
|
+
let preview = service.take_reply_preview("t1").await;
|
|
14634
|
+
assert_eq!(preview.as_deref(), Some("Done: fixed the bug"));
|
|
14635
|
+
// Buffer is consumed.
|
|
14636
|
+
assert!(service.take_reply_preview("t1").await.is_none());
|
|
14637
|
+
let _ = tokio::fs::remove_dir_all(&dir).await;
|
|
14638
|
+
}
|
|
14639
|
+
|
|
14640
|
+
#[tokio::test]
|
|
14641
|
+
async fn turn_completed_drains_reply_buffer_with_no_devices() {
|
|
14642
|
+
let dir = std::env::temp_dir().join(format!("clawdex-drain-{}", std::process::id()));
|
|
14643
|
+
let _ = tokio::fs::create_dir_all(&dir).await;
|
|
14644
|
+
let service = PushService::load(&dir, "demo".to_string()).await;
|
|
14645
|
+
// Stream a reply with no devices registered.
|
|
14646
|
+
service
|
|
14647
|
+
.accumulate_reply(
|
|
14648
|
+
"item/agentMessage/delta",
|
|
14649
|
+
&json!({ "threadId": "t1", "field": "text", "delta": "All done" }),
|
|
14650
|
+
)
|
|
14651
|
+
.await;
|
|
14652
|
+
// Completion with an empty registry must still drain the buffer, not leak it.
|
|
14653
|
+
service
|
|
14654
|
+
.handle_notification("turn/completed", &json!({ "threadId": "t1" }))
|
|
14655
|
+
.await;
|
|
14656
|
+
assert!(service.take_reply_preview("t1").await.is_none());
|
|
14657
|
+
let _ = tokio::fs::remove_dir_all(&dir).await;
|
|
14658
|
+
}
|
|
14659
|
+
|
|
14660
|
+
#[test]
|
|
14661
|
+
fn parse_push_event_preferences_defaults_to_enabled() {
|
|
14662
|
+
let defaults = parse_push_event_preferences(None);
|
|
14663
|
+
assert!(defaults.turn_completed);
|
|
14664
|
+
assert!(defaults.approval_requested);
|
|
14665
|
+
|
|
14666
|
+
let partial = parse_push_event_preferences(Some(&json!({ "approvalRequested": false })));
|
|
14667
|
+
assert!(partial.turn_completed);
|
|
14668
|
+
assert!(!partial.approval_requested);
|
|
14669
|
+
}
|
|
14670
|
+
|
|
14671
|
+
#[test]
|
|
14672
|
+
fn push_registry_round_trips_and_tolerates_missing_fields() {
|
|
14673
|
+
let raw = json!({
|
|
14674
|
+
"devices": [
|
|
14675
|
+
{
|
|
14676
|
+
"token": "ExponentPushToken[one]",
|
|
14677
|
+
"platform": "ios",
|
|
14678
|
+
"deviceName": "iPhone",
|
|
14679
|
+
"events": { "turnCompleted": true, "approvalRequested": false },
|
|
14680
|
+
"createdAt": "2026-05-29T00:00:00Z",
|
|
14681
|
+
"updatedAt": "2026-05-29T00:00:00Z"
|
|
14682
|
+
},
|
|
14683
|
+
{
|
|
14684
|
+
"token": "ExponentPushToken[two]",
|
|
14685
|
+
"createdAt": "2026-05-29T00:00:00Z",
|
|
14686
|
+
"updatedAt": "2026-05-29T00:00:00Z"
|
|
14687
|
+
}
|
|
14688
|
+
]
|
|
14689
|
+
});
|
|
14690
|
+
let registry: PushRegistry = serde_json::from_value(raw).expect("parse registry");
|
|
14691
|
+
assert_eq!(registry.devices.len(), 2);
|
|
14692
|
+
// Missing event prefs fall back to enabled.
|
|
14693
|
+
assert!(registry.devices[1].events.turn_completed);
|
|
14694
|
+
assert!(registry.devices[1].events.approval_requested);
|
|
14695
|
+
|
|
14696
|
+
let serialized = serde_json::to_string(®istry).expect("serialize");
|
|
14697
|
+
let reparsed: PushRegistry = serde_json::from_str(&serialized).expect("reparse");
|
|
14698
|
+
assert_eq!(reparsed.devices[0].token, "ExponentPushToken[one]");
|
|
14699
|
+
assert!(!reparsed.devices[0].events.approval_requested);
|
|
14700
|
+
}
|
|
14701
|
+
|
|
14702
|
+
#[tokio::test]
|
|
14703
|
+
async fn push_service_registers_dedupes_and_unregisters() {
|
|
14704
|
+
let dir = std::env::temp_dir().join(format!("clawdex-push-test-{}", std::process::id()));
|
|
14705
|
+
let _ = tokio::fs::create_dir_all(&dir).await;
|
|
14706
|
+
let service = PushService::load(&dir, "demo".to_string()).await;
|
|
14707
|
+
|
|
14708
|
+
let prefs = PushEventPreferences::default();
|
|
14709
|
+
let count = service
|
|
14710
|
+
.register(
|
|
14711
|
+
"ExponentPushToken[a]".to_string(),
|
|
14712
|
+
"ios".to_string(),
|
|
14713
|
+
"Phone".to_string(),
|
|
14714
|
+
prefs.clone(),
|
|
14715
|
+
)
|
|
14716
|
+
.await;
|
|
14717
|
+
assert_eq!(count, 1);
|
|
14718
|
+
|
|
14719
|
+
// Re-registering the same token updates in place rather than duplicating.
|
|
14720
|
+
let count = service
|
|
14721
|
+
.register(
|
|
14722
|
+
"ExponentPushToken[a]".to_string(),
|
|
14723
|
+
"ios".to_string(),
|
|
14724
|
+
"Phone Renamed".to_string(),
|
|
14725
|
+
prefs,
|
|
14726
|
+
)
|
|
14727
|
+
.await;
|
|
14728
|
+
assert_eq!(count, 1);
|
|
14729
|
+
|
|
14730
|
+
let listed = service.list().await;
|
|
14731
|
+
assert_eq!(listed.len(), 1);
|
|
14732
|
+
assert_eq!(
|
|
14733
|
+
listed[0].get("deviceName").and_then(Value::as_str),
|
|
14734
|
+
Some("Phone Renamed")
|
|
14735
|
+
);
|
|
14736
|
+
// Full tokens are never echoed back.
|
|
14737
|
+
assert!(listed[0].get("token").is_none());
|
|
14738
|
+
|
|
14739
|
+
assert!(service.unregister("ExponentPushToken[a]").await);
|
|
14740
|
+
assert!(!service.unregister("ExponentPushToken[a]").await);
|
|
14741
|
+
assert!(service.list().await.is_empty());
|
|
14742
|
+
|
|
14743
|
+
let _ = tokio::fs::remove_dir_all(&dir).await;
|
|
14744
|
+
}
|
|
14745
|
+
|
|
14015
14746
|
fn bridge_chatgpt_auth_test_lock() -> &'static std::sync::Mutex<()> {
|
|
14016
14747
|
static LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
|
|
14017
14748
|
LOCK.get_or_init(|| std::sync::Mutex::new(()))
|
|
@@ -14208,6 +14939,7 @@ mod tests {
|
|
|
14208
14939
|
config.preview_connect_url.clone(),
|
|
14209
14940
|
));
|
|
14210
14941
|
let queue = BridgeQueueService::new(backend.clone(), hub.clone());
|
|
14942
|
+
let push = PushService::load(&config.workdir, "Clawdex".to_string()).await;
|
|
14211
14943
|
|
|
14212
14944
|
Arc::new(AppState {
|
|
14213
14945
|
config,
|
|
@@ -14220,6 +14952,7 @@ mod tests {
|
|
|
14220
14952
|
git,
|
|
14221
14953
|
updater,
|
|
14222
14954
|
preview,
|
|
14955
|
+
push,
|
|
14223
14956
|
})
|
|
14224
14957
|
}
|
|
14225
14958
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|