@agent-native/core 0.114.13 → 0.114.16

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.
Files changed (163) hide show
  1. package/bin/agent-native.js +35 -14
  2. package/bin/launcher.js +34 -0
  3. package/corpus/README.md +2 -2
  4. package/corpus/core/CHANGELOG.md +21 -0
  5. package/corpus/core/bin/agent-native.js +35 -14
  6. package/corpus/core/bin/launcher.js +34 -0
  7. package/corpus/core/docs/content/deployment.mdx +1 -1
  8. package/corpus/core/docs/content/frames.mdx +1 -1
  9. package/corpus/core/docs/content/integrations.mdx +27 -27
  10. package/corpus/core/docs/content/pr-visual-recap.mdx +17 -13
  11. package/corpus/core/docs/content/workspace-management.mdx +1 -1
  12. package/corpus/core/package.json +2 -1
  13. package/corpus/core/src/agent/engine/builder-engine.ts +4 -0
  14. package/corpus/core/src/client/AgentPanel.tsx +10 -4
  15. package/corpus/core/src/client/ConnectBuilderCard.tsx +5 -1
  16. package/corpus/core/src/client/chat/markdown-renderer.tsx +3 -5
  17. package/corpus/core/src/client/error-format.ts +2 -1
  18. package/corpus/core/src/client/index.ts +1 -0
  19. package/corpus/core/src/client/integrations/IntegrationsPanel.tsx +2 -18
  20. package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +43 -32
  21. package/corpus/core/src/client/resources/mcp-integration-catalog.ts +4 -2
  22. package/corpus/core/src/client/settings/BackgroundAgentSection.tsx +5 -1
  23. package/corpus/core/src/client/settings/SettingsPanel.tsx +4 -4
  24. package/corpus/core/src/client/settings/useBuilderStatus.ts +3 -0
  25. package/corpus/core/src/localization/default-messages.ts +1 -7
  26. package/corpus/core/src/server/builder-browser.ts +4 -0
  27. package/corpus/core/src/server/builder-design-systems.ts +6 -1
  28. package/corpus/core/src/server/fusion-app.ts +5 -1
  29. package/corpus/core/src/shared/builder-link-tracking.ts +41 -0
  30. package/corpus/core/src/shared/index.ts +1 -0
  31. package/corpus/templates/analytics/AGENTS.md +10 -1
  32. package/corpus/templates/analytics/actions/save-dashboard-report-subscription.ts +10 -2
  33. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +5 -4
  34. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +20 -32
  35. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +62 -0
  36. package/corpus/templates/analytics/changelog/2026-07-21-chart-series-controls-stay-open-while-moving-from-the-legend.md +6 -0
  37. package/corpus/templates/analytics/changelog/2026-07-21-dashboard-email-captures-complete-large-dashboards-in-chunks.md +6 -0
  38. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +4 -4
  39. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +1 -8
  40. package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +39 -7
  41. package/corpus/templates/analytics/server/lib/dashboard-report.ts +341 -251
  42. package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +114 -0
  43. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +84 -6
  44. package/corpus/templates/analytics/server/plugins/db.ts +13 -0
  45. package/corpus/templates/clips/app/components/library/bulk-action-toolbar.tsx +25 -8
  46. package/corpus/templates/clips/app/components/library/library-grid.tsx +118 -9
  47. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  48. package/corpus/templates/clips/app/components/library/recording-card.tsx +10 -8
  49. package/corpus/templates/clips/app/components/player/video-player.tsx +10 -4
  50. package/corpus/templates/clips/app/i18n/en-US.ts +6 -0
  51. package/corpus/templates/clips/app/routes/_app.trash.tsx +16 -1
  52. package/corpus/templates/clips/changelog/2026-07-21-choose-which-whisper-model-to-use-in-settings.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-07-21-fixed-organization-recording-visibility-default.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-21-meeting-notes-stop-when-calls-end.md +6 -0
  55. package/corpus/templates/clips/desktop/src/app.tsx +90 -78
  56. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +2 -0
  57. package/corpus/templates/clips/desktop/src/hooks/useWhisperSettings.ts +172 -0
  58. package/corpus/templates/clips/desktop/src/lib/meeting-call-app.ts +41 -0
  59. package/corpus/templates/clips/desktop/src/lib/silence-events.ts +1 -0
  60. package/corpus/templates/clips/desktop/src/shared/config.ts +1 -0
  61. package/corpus/templates/clips/desktop/src/styles.css +80 -7
  62. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +32 -0
  63. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  64. package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +268 -20
  65. package/corpus/templates/clips/desktop/src-tauri/src/whisper_model.rs +285 -164
  66. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +18 -7
  67. package/corpus/templates/clips/learnings.defaults.md +1 -1
  68. package/corpus/templates/clips/server/plugins/db.ts +11 -0
  69. package/corpus/templates/clips/shared/builder-credits.ts +1 -1
  70. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +20 -38
  71. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +40 -1
  72. package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +9 -1
  73. package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +5 -1
  74. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +9 -1
  75. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +9 -1
  76. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  77. package/dist/agent/engine/builder-engine.js +4 -0
  78. package/dist/agent/engine/builder-engine.js.map +1 -1
  79. package/dist/client/AgentPanel.d.ts.map +1 -1
  80. package/dist/client/AgentPanel.js +10 -3
  81. package/dist/client/AgentPanel.js.map +1 -1
  82. package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
  83. package/dist/client/ConnectBuilderCard.js +5 -1
  84. package/dist/client/ConnectBuilderCard.js.map +1 -1
  85. package/dist/client/chat/markdown-renderer.d.ts.map +1 -1
  86. package/dist/client/chat/markdown-renderer.js +3 -2
  87. package/dist/client/chat/markdown-renderer.js.map +1 -1
  88. package/dist/client/error-format.d.ts +1 -1
  89. package/dist/client/error-format.d.ts.map +1 -1
  90. package/dist/client/error-format.js +1 -1
  91. package/dist/client/error-format.js.map +1 -1
  92. package/dist/client/index.d.ts +1 -0
  93. package/dist/client/index.d.ts.map +1 -1
  94. package/dist/client/index.js +1 -0
  95. package/dist/client/index.js.map +1 -1
  96. package/dist/client/integrations/IntegrationsPanel.d.ts.map +1 -1
  97. package/dist/client/integrations/IntegrationsPanel.js +3 -4
  98. package/dist/client/integrations/IntegrationsPanel.js.map +1 -1
  99. package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
  100. package/dist/client/resources/McpIntegrationDialog.js +21 -7
  101. package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
  102. package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
  103. package/dist/client/resources/mcp-integration-catalog.js +2 -2
  104. package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
  105. package/dist/client/settings/BackgroundAgentSection.d.ts.map +1 -1
  106. package/dist/client/settings/BackgroundAgentSection.js +5 -1
  107. package/dist/client/settings/BackgroundAgentSection.js.map +1 -1
  108. package/dist/client/settings/SettingsPanel.js +1 -1
  109. package/dist/client/settings/SettingsPanel.js.map +1 -1
  110. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  111. package/dist/client/settings/useBuilderStatus.js +2 -0
  112. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  113. package/dist/collab/struct-routes.d.ts +1 -1
  114. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  115. package/dist/localization/default-messages.d.ts +0 -6
  116. package/dist/localization/default-messages.d.ts.map +1 -1
  117. package/dist/localization/default-messages.js +1 -7
  118. package/dist/localization/default-messages.js.map +1 -1
  119. package/dist/observability/routes.d.ts +5 -5
  120. package/dist/resources/handlers.d.ts +1 -1
  121. package/dist/secrets/routes.d.ts +9 -9
  122. package/dist/server/builder-browser.d.ts.map +1 -1
  123. package/dist/server/builder-browser.js +4 -0
  124. package/dist/server/builder-browser.js.map +1 -1
  125. package/dist/server/builder-design-systems.d.ts.map +1 -1
  126. package/dist/server/builder-design-systems.js +6 -1
  127. package/dist/server/builder-design-systems.js.map +1 -1
  128. package/dist/server/fusion-app.d.ts.map +1 -1
  129. package/dist/server/fusion-app.js +2 -1
  130. package/dist/server/fusion-app.js.map +1 -1
  131. package/dist/server/transcribe-voice.d.ts +1 -1
  132. package/dist/shared/builder-link-tracking.d.ts +14 -0
  133. package/dist/shared/builder-link-tracking.d.ts.map +1 -0
  134. package/dist/shared/builder-link-tracking.js +26 -0
  135. package/dist/shared/builder-link-tracking.js.map +1 -0
  136. package/dist/shared/index.d.ts +1 -0
  137. package/dist/shared/index.d.ts.map +1 -1
  138. package/dist/shared/index.js +1 -0
  139. package/dist/shared/index.js.map +1 -1
  140. package/docs/content/deployment.mdx +1 -1
  141. package/docs/content/frames.mdx +1 -1
  142. package/docs/content/integrations.mdx +27 -27
  143. package/docs/content/pr-visual-recap.mdx +17 -13
  144. package/docs/content/workspace-management.mdx +1 -1
  145. package/package.json +3 -2
  146. package/src/agent/engine/builder-engine.ts +4 -0
  147. package/src/client/AgentPanel.tsx +10 -4
  148. package/src/client/ConnectBuilderCard.tsx +5 -1
  149. package/src/client/chat/markdown-renderer.tsx +3 -5
  150. package/src/client/error-format.ts +2 -1
  151. package/src/client/index.ts +1 -0
  152. package/src/client/integrations/IntegrationsPanel.tsx +2 -18
  153. package/src/client/resources/McpIntegrationDialog.tsx +43 -32
  154. package/src/client/resources/mcp-integration-catalog.ts +4 -2
  155. package/src/client/settings/BackgroundAgentSection.tsx +5 -1
  156. package/src/client/settings/SettingsPanel.tsx +4 -4
  157. package/src/client/settings/useBuilderStatus.ts +3 -0
  158. package/src/localization/default-messages.ts +1 -7
  159. package/src/server/builder-browser.ts +4 -0
  160. package/src/server/builder-design-systems.ts +6 -1
  161. package/src/server/fusion-app.ts +5 -1
  162. package/src/shared/builder-link-tracking.ts +41 -0
  163. package/src/shared/index.ts +1 -0
@@ -135,6 +135,8 @@ pub struct FeatureConfig {
135
135
  pub onboarding_complete: bool,
136
136
  #[serde(default = "default_whisper_model_enabled")]
137
137
  pub whisper_model_enabled: bool,
138
+ #[serde(default = "default_whisper_model_id")]
139
+ pub whisper_model_id: String,
138
140
  }
139
141
 
140
142
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -170,6 +172,10 @@ fn default_whisper_model_enabled() -> bool {
170
172
  true
171
173
  }
172
174
 
175
+ fn default_whisper_model_id() -> String {
176
+ "base".to_string()
177
+ }
178
+
173
179
  fn default_screen_memory_retention_hours() -> u32 {
174
180
  8
175
181
  }
@@ -227,6 +233,7 @@ impl Default for FeatureConfig {
227
233
  screen_memory: ScreenMemoryConfig::default(),
228
234
  onboarding_complete: false,
229
235
  whisper_model_enabled: default_whisper_model_enabled(),
236
+ whisper_model_id: default_whisper_model_id(),
230
237
  }
231
238
  }
232
239
  }
@@ -333,6 +340,12 @@ pub async fn get_feature_config(app: AppHandle) -> Result<FeatureConfig, String>
333
340
  /// Save feature config to disk and emit a change event.
334
341
  #[tauri::command]
335
342
  pub async fn set_feature_config(app: AppHandle, config: FeatureConfig) -> Result<(), String> {
343
+ if !crate::whisper_model::is_supported_model_id(&config.whisper_model_id) {
344
+ return Err(format!(
345
+ "unsupported Whisper model: {}",
346
+ config.whisper_model_id
347
+ ));
348
+ }
336
349
  let previous = load_config(&app);
337
350
  if (crate::rewind_clip::is_active(&app) || crate::util::is_recording_active(&app))
338
351
  && rewind_capture_contract_changed(&previous.screen_memory, &config.screen_memory)
@@ -366,6 +379,12 @@ pub async fn set_feature_config(app: AppHandle, config: FeatureConfig) -> Result
366
379
  serde_json::json!({ "enabled": config.whisper_model_enabled }),
367
380
  );
368
381
  }
382
+ if previous.whisper_model_id != config.whisper_model_id {
383
+ let _ = app.emit(
384
+ "whisper:model-selection-changed",
385
+ serde_json::json!({ "modelId": config.whisper_model_id }),
386
+ );
387
+ }
369
388
  crate::screen_memory::sync_from_config(&app, &config);
370
389
  let _ = app.emit("app:feature-config-changed", config);
371
390
  Ok(())
@@ -406,6 +425,19 @@ mod tests {
406
425
  .contains(&"com.1password.1password".to_string()));
407
426
  }
408
427
 
428
+ #[test]
429
+ fn feature_config_defaults_to_base_whisper_model_for_legacy_json() {
430
+ let config: FeatureConfig = serde_json::from_value(serde_json::json!({
431
+ "clipsEnabled": true,
432
+ "meetingsEnabled": true,
433
+ "voiceEnabled": true,
434
+ "onboardingComplete": true
435
+ }))
436
+ .unwrap();
437
+
438
+ assert_eq!(config.whisper_model_id, "base");
439
+ }
440
+
409
441
  #[test]
410
442
  fn rewind_enums_use_stable_kebab_case_values() {
411
443
  assert_eq!(
@@ -231,8 +231,11 @@ pub fn run() {
231
231
  shortcuts::set_fn_shortcut_enabled,
232
232
  shortcuts::set_dictation_escape_active,
233
233
  // whisper model management
234
+ whisper_model::whisper_models,
234
235
  whisper_model::whisper_model_status,
235
236
  whisper_model::whisper_model_download,
237
+ whisper_model::whisper_downloaded_models,
238
+ whisper_model::whisper_model_delete,
236
239
  // permission status (silent checks for all TCC permissions)
237
240
  permission_status::check_permission_statuses,
238
241
  // persistent log file (production debugging)
@@ -11,9 +11,11 @@
11
11
  //!
12
12
  //! * **System sleep** — `NSWorkspaceWillSleepNotification` via objc2.
13
13
  //! Emits `meetings:sleep-stop`.
14
- //! * **Call-end heuristic** — best-effort: if a known video-conferencing
15
- //! bundle id (Zoom, Google Meet wrapper, MS Teams) goes from frontmost
16
- //! to background for >2 minutes, emit `meetings:call-ended`.
14
+ //! * **Call-end heuristic** — best-effort: when a known conferencing app
15
+ //! releases its microphone after using it for the active meeting, emit
16
+ //! `meetings:call-ended`. Falling back to a foreground-to-background
17
+ //! transition keeps the detector useful on macOS versions that do not
18
+ //! expose per-process input activity.
17
19
  //! * **Calendar end** — when the scheduled meeting end has passed and both
18
20
  //! audio sources have been quiet for the call-end window, emit the same
19
21
  //! event even if the conferencing app remains frontmost.
@@ -69,6 +71,11 @@ pub struct SilenceConfig {
69
71
  /// Whether to enable the call-ended heuristic.
70
72
  #[serde(default = "default_true")]
71
73
  pub watch_call_ended: bool,
74
+ /// Bundle IDs allowed to corroborate a call ending by releasing their
75
+ /// microphone input. Restricting this to the meeting provider prevents an
76
+ /// unrelated browser tab from affecting a live meeting session.
77
+ #[serde(default)]
78
+ pub call_app_bundle_ids: Option<Vec<String>>,
72
79
  /// Unix epoch milliseconds for the calendar event's scheduled end.
73
80
  /// Calendar-end stopping still requires quiet audio as confirmation.
74
81
  #[serde(default)]
@@ -117,6 +124,9 @@ struct DetectorInner {
117
124
  silence_fired: bool,
118
125
  /// Calendar event end for the active session, if one is known.
119
126
  scheduled_end_ms: Option<u64>,
127
+ /// Apps allowed to corroborate a call ending by releasing their microphone
128
+ /// input. This varies by the calendar join URL for each session.
129
+ call_app_bundle_ids: Vec<String>,
120
130
  }
121
131
 
122
132
  pub struct DetectorState {
@@ -151,6 +161,7 @@ pub fn silence_detector_start(app: AppHandle, config: Option<SilenceConfig>) ->
151
161
  call_ended_ms: default_call_ended_ms(),
152
162
  watch_sleep: true,
153
163
  watch_call_ended: true,
164
+ call_app_bundle_ids: None,
154
165
  scheduled_end_ms: None,
155
166
  });
156
167
 
@@ -196,6 +207,13 @@ pub fn silence_detector_start(app: AppHandle, config: Option<SilenceConfig>) ->
196
207
  g.silence_fired = false;
197
208
  g.config = Some(cfg.clone());
198
209
  g.scheduled_end_ms = cfg.scheduled_end_ms;
210
+ g.call_app_bundle_ids = cfg
211
+ .call_app_bundle_ids
212
+ .clone()
213
+ .unwrap_or_default()
214
+ .into_iter()
215
+ .map(|bundle_id| bundle_id.to_lowercase())
216
+ .collect();
199
217
  // Seed both buckets with `now()` so we don't insta-fire on start
200
218
  // before any audio has streamed in yet.
201
219
  g.mic = Some(SourceState::fresh());
@@ -293,6 +311,7 @@ pub fn silence_detector_stop(app: AppHandle) -> Result<(), String> {
293
311
  g.mic = None;
294
312
  g.system = None;
295
313
  g.scheduled_end_ms = None;
314
+ g.call_app_bundle_ids.clear();
296
315
  Ok(())
297
316
  }
298
317
 
@@ -334,6 +353,20 @@ fn install_sleep_watcher(_app: &AppHandle) {}
334
353
 
335
354
  // --- call-ended heuristic --------------------------------------------------
336
355
 
356
+ const GENERIC_BROWSER_BUNDLE_IDS: &[&str] = &[
357
+ "com.google.chrome",
358
+ "company.thebrowser.browser",
359
+ "com.apple.safari",
360
+ "org.mozilla.firefox",
361
+ ];
362
+
363
+ fn is_configured_generic_browser(bundle_id: &str, call_app_bundle_ids: &[String]) -> bool {
364
+ GENERIC_BROWSER_BUNDLE_IDS.contains(&bundle_id)
365
+ && call_app_bundle_ids
366
+ .iter()
367
+ .any(|candidate| GENERIC_BROWSER_BUNDLE_IDS.contains(&candidate.as_str()))
368
+ }
369
+
337
370
  #[cfg(target_os = "macos")]
338
371
  fn install_call_ended_watcher(app: &AppHandle, threshold_ms: u64) {
339
372
  static INSTALLED: OnceLock<()> = OnceLock::new();
@@ -356,39 +389,63 @@ fn install_call_ended_watcher(app: &AppHandle, threshold_ms: u64) {
356
389
  // (DetectorInner.mic/system), never on the frontmost-app poll
357
390
  // alone — matching the granola-ux.md "transcript length +
358
391
  // calendar times" model instead of raw frontmost tracking.
359
- let strong_vc_bundles: &[&str] = &[
360
- "us.zoom.xos",
361
- "us.zoom.ZoomClips",
362
- "com.microsoft.teams2",
363
- "com.microsoft.teams",
364
- ];
365
- let generic_browser_bundles: &[&str] = &[
366
- "com.google.Chrome",
367
- "company.thebrowser.Browser", // Arc
368
- ];
369
392
  let mut ever_seen_front = false;
370
393
  let mut last_front_at: Option<Instant> = None;
371
394
  let mut fired = false;
372
395
  let mut last_front_was_generic_browser = false;
396
+ let mut call_app_used_microphone = false;
397
+ let mut microphone_released_at: Option<Instant> = None;
398
+ let mut generation: Option<u64> = None;
373
399
  loop {
374
400
  std::thread::sleep(Duration::from_secs(10));
375
401
  let state = app.state::<DetectorState>();
376
- let active = state.inner.lock().map(|g| g.active).unwrap_or(false);
402
+ let (active, active_generation, configured_bundle_ids) = state
403
+ .inner
404
+ .lock()
405
+ .map(|g| (g.active, g.generation, g.call_app_bundle_ids.clone()))
406
+ .unwrap_or((false, 0, Vec::new()));
377
407
  if !active {
378
408
  ever_seen_front = false;
379
409
  last_front_at = None;
380
410
  fired = false;
381
411
  last_front_was_generic_browser = false;
412
+ call_app_used_microphone = false;
413
+ microphone_released_at = None;
382
414
  continue;
383
415
  }
416
+ if generation != Some(active_generation) {
417
+ ever_seen_front = false;
418
+ last_front_at = None;
419
+ fired = false;
420
+ last_front_was_generic_browser = false;
421
+ call_app_used_microphone = false;
422
+ microphone_released_at = None;
423
+ generation = Some(active_generation);
424
+ }
425
+ let call_app_bundle_ids = if configured_bundle_ids.is_empty() {
426
+ default_call_app_bundle_ids()
427
+ } else {
428
+ configured_bundle_ids
429
+ };
384
430
  let front = crate::util::frontmost_bundle_id();
385
- let is_strong_vc = front
431
+ let is_generic_browser = front
386
432
  .as_ref()
387
- .map(|b| strong_vc_bundles.iter().any(|k| k == b))
433
+ .map(|bundle_id| {
434
+ is_configured_generic_browser(
435
+ &bundle_id.to_lowercase(),
436
+ &call_app_bundle_ids,
437
+ )
438
+ })
388
439
  .unwrap_or(false);
389
- let is_generic_browser = front
440
+ let is_strong_vc = front
390
441
  .as_ref()
391
- .map(|b| generic_browser_bundles.iter().any(|k| k == b))
442
+ .map(|bundle_id| {
443
+ let bundle_id = bundle_id.to_lowercase();
444
+ call_app_bundle_ids
445
+ .iter()
446
+ .any(|candidate| candidate == &bundle_id)
447
+ && !is_generic_browser
448
+ })
392
449
  .unwrap_or(false);
393
450
  if is_strong_vc || is_generic_browser {
394
451
  ever_seen_front = true;
@@ -399,6 +456,29 @@ fn install_call_ended_watcher(app: &AppHandle, threshold_ms: u64) {
399
456
  continue;
400
457
  }
401
458
 
459
+ // A native call application remains frontmost on its post-call
460
+ // screen, so foreground tracking alone cannot tell that the
461
+ // meeting ended. CoreAudio reports whether the provider still
462
+ // has an active microphone stream. Only accept a true -> false
463
+ // transition that stays stable for 30 seconds; this tolerates
464
+ // a device handoff while avoiding a stop before the call has
465
+ // actually acquired its microphone.
466
+ match call_app_uses_microphone(&call_app_bundle_ids) {
467
+ Some(true) => {
468
+ call_app_used_microphone = true;
469
+ microphone_released_at = None;
470
+ }
471
+ Some(false) if call_app_used_microphone => {
472
+ microphone_released_at.get_or_insert_with(Instant::now);
473
+ }
474
+ _ => {}
475
+ }
476
+
477
+ let microphone_released = microphone_release_stop_ready(
478
+ call_app_used_microphone,
479
+ microphone_released_at.map(|at| Instant::now().duration_since(at)),
480
+ );
481
+
402
482
  let frontmost_call_ended = ever_seen_front
403
483
  && last_front_at
404
484
  .map(|t| {
@@ -412,7 +492,7 @@ fn install_call_ended_watcher(app: &AppHandle, threshold_ms: u64) {
412
492
  && (!last_front_was_generic_browser
413
493
  || audio_recently_silent(&state, threshold_ms));
414
494
 
415
- if frontmost_call_ended {
495
+ if microphone_released || frontmost_call_ended {
416
496
  let _ = app.emit("meetings:call-ended", ());
417
497
  fired = true;
418
498
  }
@@ -424,6 +504,136 @@ fn install_call_ended_watcher(app: &AppHandle, threshold_ms: u64) {
424
504
  #[cfg(not(target_os = "macos"))]
425
505
  fn install_call_ended_watcher(_app: &AppHandle, _threshold_ms: u64) {}
426
506
 
507
+ #[cfg(target_os = "macos")]
508
+ fn default_call_app_bundle_ids() -> Vec<String> {
509
+ [
510
+ "us.zoom.xos",
511
+ "us.zoom.ZoomClips",
512
+ "com.microsoft.teams2",
513
+ "com.microsoft.teams",
514
+ ]
515
+ .into_iter()
516
+ .map(|bundle_id| bundle_id.to_lowercase())
517
+ .collect()
518
+ }
519
+
520
+ fn microphone_release_stop_ready(
521
+ app_used_microphone: bool,
522
+ released_for: Option<Duration>,
523
+ ) -> bool {
524
+ app_used_microphone
525
+ && released_for
526
+ .map(|elapsed| elapsed >= Duration::from_secs(30))
527
+ .unwrap_or(false)
528
+ }
529
+
530
+ /// Returns whether one of the target conferencing apps currently has a live
531
+ /// CoreAudio input stream. `None` means the OS could not provide a reliable
532
+ /// answer, so callers must keep the existing conservative fallbacks.
533
+ #[cfg(target_os = "macos")]
534
+ fn call_app_uses_microphone(bundle_ids: &[String]) -> Option<bool> {
535
+ use core_foundation::base::TCFType;
536
+ use core_foundation::string::CFString;
537
+ use objc2_core_audio::{
538
+ kAudioHardwareNoError, kAudioHardwarePropertyProcessObjectList,
539
+ kAudioObjectPropertyElementMain, kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject,
540
+ kAudioProcessPropertyBundleID, kAudioProcessPropertyIsRunningInput,
541
+ AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize, AudioObjectID,
542
+ AudioObjectPropertyAddress,
543
+ };
544
+ use std::ffi::c_void;
545
+ use std::mem::size_of;
546
+ use std::ptr::NonNull;
547
+
548
+ let mut list_address = AudioObjectPropertyAddress {
549
+ mSelector: kAudioHardwarePropertyProcessObjectList,
550
+ mScope: kAudioObjectPropertyScopeGlobal,
551
+ mElement: kAudioObjectPropertyElementMain,
552
+ };
553
+ let mut data_size = 0;
554
+ let list_status = unsafe {
555
+ AudioObjectGetPropertyDataSize(
556
+ kAudioObjectSystemObject as AudioObjectID,
557
+ NonNull::from(&mut list_address),
558
+ 0,
559
+ std::ptr::null(),
560
+ NonNull::from(&mut data_size),
561
+ )
562
+ };
563
+ if list_status != kAudioHardwareNoError || data_size == 0 {
564
+ return None;
565
+ }
566
+
567
+ let mut processes = vec![0 as AudioObjectID; data_size as usize / size_of::<AudioObjectID>()];
568
+ let list_status = unsafe {
569
+ AudioObjectGetPropertyData(
570
+ kAudioObjectSystemObject as AudioObjectID,
571
+ NonNull::from(&mut list_address),
572
+ 0,
573
+ std::ptr::null(),
574
+ NonNull::from(&mut data_size),
575
+ NonNull::new(processes.as_mut_ptr().cast::<c_void>())?,
576
+ )
577
+ };
578
+ if list_status != kAudioHardwareNoError {
579
+ return None;
580
+ }
581
+
582
+ for process in processes {
583
+ let mut bundle_address = AudioObjectPropertyAddress {
584
+ mSelector: kAudioProcessPropertyBundleID,
585
+ mScope: kAudioObjectPropertyScopeGlobal,
586
+ mElement: kAudioObjectPropertyElementMain,
587
+ };
588
+ let mut bundle_ref: *const c_void = std::ptr::null();
589
+ let mut bundle_size = size_of::<*const c_void>() as u32;
590
+ let bundle_status = unsafe {
591
+ AudioObjectGetPropertyData(
592
+ process,
593
+ NonNull::from(&mut bundle_address),
594
+ 0,
595
+ std::ptr::null(),
596
+ NonNull::from(&mut bundle_size),
597
+ NonNull::new((&mut bundle_ref as *mut *const c_void).cast::<c_void>())?,
598
+ )
599
+ };
600
+ if bundle_status != kAudioHardwareNoError || bundle_ref.is_null() {
601
+ continue;
602
+ }
603
+ let bundle_id = unsafe {
604
+ CFString::wrap_under_get_rule(bundle_ref as core_foundation::string::CFStringRef)
605
+ }
606
+ .to_string()
607
+ .to_lowercase();
608
+ if !bundle_ids.iter().any(|candidate| candidate == &bundle_id) {
609
+ continue;
610
+ }
611
+
612
+ let mut input_address = AudioObjectPropertyAddress {
613
+ mSelector: kAudioProcessPropertyIsRunningInput,
614
+ mScope: kAudioObjectPropertyScopeGlobal,
615
+ mElement: kAudioObjectPropertyElementMain,
616
+ };
617
+ let mut input_running: u32 = 0;
618
+ let mut input_size = size_of::<u32>() as u32;
619
+ let input_status = unsafe {
620
+ AudioObjectGetPropertyData(
621
+ process,
622
+ NonNull::from(&mut input_address),
623
+ 0,
624
+ std::ptr::null(),
625
+ NonNull::from(&mut input_size),
626
+ NonNull::new((&mut input_running as *mut u32).cast::<c_void>())?,
627
+ )
628
+ };
629
+ if input_status == kAudioHardwareNoError && input_running != 0 {
630
+ return Some(true);
631
+ }
632
+ }
633
+
634
+ Some(false)
635
+ }
636
+
427
637
  fn scheduled_end_reached(scheduled_end_ms: Option<u64>, now_ms: u64) -> bool {
428
638
  scheduled_end_ms
429
639
  .map(|end_ms| now_ms >= end_ms)
@@ -470,7 +680,12 @@ fn audio_recently_silent(state: &tauri::State<'_, DetectorState>, threshold_ms:
470
680
 
471
681
  #[cfg(test)]
472
682
  mod tests {
473
- use super::{calendar_end_stop_ready, scheduled_end_reached};
683
+ use std::time::Duration;
684
+
685
+ use super::{
686
+ calendar_end_stop_ready, is_configured_generic_browser, microphone_release_stop_ready,
687
+ scheduled_end_reached,
688
+ };
474
689
 
475
690
  #[test]
476
691
  fn calendar_end_requires_a_known_end_and_allows_the_exact_boundary() {
@@ -486,4 +701,37 @@ mod tests {
486
701
  assert!(calendar_end_stop_ready(Some(9_999), 10_000, true));
487
702
  assert!(!calendar_end_stop_ready(Some(10_001), 10_000, true));
488
703
  }
704
+
705
+ #[test]
706
+ fn microphone_release_only_stops_after_an_observed_call_input_ends() {
707
+ assert!(!microphone_release_stop_ready(
708
+ false,
709
+ Some(Duration::from_secs(60))
710
+ ));
711
+ assert!(!microphone_release_stop_ready(true, None));
712
+ assert!(!microphone_release_stop_ready(
713
+ true,
714
+ Some(Duration::from_secs(29))
715
+ ));
716
+ assert!(microphone_release_stop_ready(
717
+ true,
718
+ Some(Duration::from_secs(30))
719
+ ));
720
+ }
721
+
722
+ #[test]
723
+ fn browser_calls_require_a_configured_browser_bundle() {
724
+ let browser_call = vec!["com.google.chrome".to_owned()];
725
+ let native_call = vec!["us.zoom.xos".to_owned()];
726
+
727
+ for browser in [
728
+ "com.google.chrome",
729
+ "company.thebrowser.browser",
730
+ "com.apple.safari",
731
+ "org.mozilla.firefox",
732
+ ] {
733
+ assert!(is_configured_generic_browser(browser, &browser_call));
734
+ assert!(!is_configured_generic_browser(browser, &native_call));
735
+ }
736
+ }
489
737
  }