@agent-native/core 0.145.1 → 0.145.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/corpus/README.md +1 -1
- package/corpus/templates/brain/server/lib/capture-sanitization.ts +6 -2
- package/corpus/templates/clips/changelog/2026-08-07-full-screen-recordings-on-a-multi-monitor-mac-now-let-you-pi.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/styles.css +13 -4
- package/corpus/templates/clips/desktop/src/app.tsx +38 -1
- package/corpus/templates/clips/desktop/src/components/MediaDeviceRow.tsx +123 -104
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +165 -2
- package/corpus/templates/clips/desktop/src/main.tsx +3 -0
- package/corpus/templates/clips/desktop/src/overlays/monitor-picker.tsx +70 -0
- package/corpus/templates/clips/desktop/src/styles.css +70 -11
- package/corpus/templates/clips/desktop/src-tauri/capabilities/default.json +2 -1
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +163 -1
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +6 -1
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +141 -22
- package/corpus/templates/clips/desktop/src-tauri/src/state.rs +27 -1
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +12 -1
- package/dist/agent/engine/index.d.ts +1 -1
- package/dist/agent/engine/index.js +1 -1
- package/dist/agent/engine/registry.d.ts +15 -0
- package/dist/agent/engine/registry.js +39 -10
- package/dist/agent/production-agent.d.ts +40 -1
- package/dist/agent/production-agent.js +81 -40
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/integrations/webhook-handler.d.ts +3 -2
- package/dist/integrations/webhook-handler.js +8 -38
- package/dist/observability/routes.d.ts +3 -3
- package/dist/server/agent-chat-plugin.js +67 -33
- package/dist/server/complete-text.js +14 -7
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/poll.d.ts +11 -0
- package/dist/server/poll.js +7 -4
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/realtime-token.js +8 -0
- package/dist/server/request-context.d.ts +7 -1
- package/dist/server/short-lived-token.d.ts +8 -0
- package/dist/server/short-lived-token.js +12 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/docs/content/environment-variables.mdx +6 -0
- package/package.json +3 -3
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { emit } from "@tauri-apps/api/event";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
|
|
4
|
+
interface MonitorPickerParams {
|
|
5
|
+
index: number;
|
|
6
|
+
total: number;
|
|
7
|
+
displayId: number | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseParams(search: string): MonitorPickerParams {
|
|
11
|
+
const params = new URLSearchParams(search);
|
|
12
|
+
const index = Number.parseInt(params.get("index") ?? "", 10);
|
|
13
|
+
const total = Number.parseInt(params.get("total") ?? "", 10);
|
|
14
|
+
const displayIdRaw = Number.parseInt(params.get("displayId") ?? "", 10);
|
|
15
|
+
return {
|
|
16
|
+
index: Number.isFinite(index) && index > 0 ? index : 1,
|
|
17
|
+
total: Number.isFinite(total) && total > 0 ? total : 1,
|
|
18
|
+
displayId:
|
|
19
|
+
Number.isFinite(displayIdRaw) && displayIdRaw > 0 ? displayIdRaw : null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One of these opens centered on every connected monitor before a
|
|
25
|
+
* full-screen native recording starts with more than one monitor attached
|
|
26
|
+
* (see `show_monitor_picker` on the Rust side). The display id this specific
|
|
27
|
+
* monitor resolves to is baked into this window's own URL rather than looked
|
|
28
|
+
* up again here, so a click can report it without a round trip. The recorder
|
|
29
|
+
* driver — not this component — closes every sibling window once it hears
|
|
30
|
+
* either event, so a click never closes just itself.
|
|
31
|
+
*/
|
|
32
|
+
export function MonitorPicker() {
|
|
33
|
+
const { index, total, displayId } =
|
|
34
|
+
typeof window === "undefined"
|
|
35
|
+
? { index: 1, total: 1, displayId: null }
|
|
36
|
+
: parseParams(window.location.search);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
40
|
+
if (event.key !== "Escape") return;
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
emit("clips:monitor-picker-cancelled").catch(() => {});
|
|
43
|
+
};
|
|
44
|
+
window.addEventListener("keydown", onKeyDown);
|
|
45
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
function select() {
|
|
49
|
+
emit("clips:monitor-picker-selected", { displayId }).catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="monitor-picker-root">
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
className="monitor-picker-card"
|
|
57
|
+
onClick={select}
|
|
58
|
+
autoFocus
|
|
59
|
+
>
|
|
60
|
+
<span className="monitor-picker-title">Screen {index}</span>
|
|
61
|
+
<span className="monitor-picker-subtitle">
|
|
62
|
+
Click to record this display
|
|
63
|
+
</span>
|
|
64
|
+
<span className="monitor-picker-hint">
|
|
65
|
+
{total} displays connected · Esc to cancel
|
|
66
|
+
</span>
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -606,7 +606,7 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
606
606
|
}
|
|
607
607
|
|
|
608
608
|
/* Popover menu rendered below a device row. */
|
|
609
|
-
.media-device-
|
|
609
|
+
.media-device-picker {
|
|
610
610
|
position: relative;
|
|
611
611
|
}
|
|
612
612
|
|
|
@@ -665,19 +665,14 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
665
665
|
border-radius: 4px;
|
|
666
666
|
}
|
|
667
667
|
|
|
668
|
-
.
|
|
669
|
-
|
|
670
|
-
align-items: center;
|
|
671
|
-
justify-content: space-between;
|
|
672
|
-
gap: 8px;
|
|
673
|
-
margin-top: 4px;
|
|
674
|
-
padding: 10px 10px 8px 34px;
|
|
675
|
-
border-top: 1px solid var(--border, rgba(255, 255, 255, 0.08));
|
|
668
|
+
.system-audio-option {
|
|
669
|
+
margin-top: 8px;
|
|
676
670
|
}
|
|
677
671
|
|
|
678
|
-
.
|
|
679
|
-
font-size: 13px;
|
|
672
|
+
.system-audio-option-label {
|
|
680
673
|
color: var(--fg);
|
|
674
|
+
font-size: 13px;
|
|
675
|
+
font-weight: 500;
|
|
681
676
|
}
|
|
682
677
|
|
|
683
678
|
.row-menu-check {
|
|
@@ -6229,3 +6224,67 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
6229
6224
|
transform: rotate(360deg);
|
|
6230
6225
|
}
|
|
6231
6226
|
}
|
|
6227
|
+
|
|
6228
|
+
/*
|
|
6229
|
+
* One of these windows opens centered on every connected monitor when a
|
|
6230
|
+
* full-screen native recording is about to start with more than one monitor
|
|
6231
|
+
* attached (see `show_monitor_picker`). The window itself is already sized
|
|
6232
|
+
* and positioned by Rust, so the card just fills it.
|
|
6233
|
+
*/
|
|
6234
|
+
.monitor-picker-root {
|
|
6235
|
+
position: fixed;
|
|
6236
|
+
inset: 0;
|
|
6237
|
+
display: flex;
|
|
6238
|
+
align-items: center;
|
|
6239
|
+
justify-content: center;
|
|
6240
|
+
padding: 18px;
|
|
6241
|
+
background: transparent;
|
|
6242
|
+
}
|
|
6243
|
+
|
|
6244
|
+
.monitor-picker-card {
|
|
6245
|
+
display: flex;
|
|
6246
|
+
flex-direction: column;
|
|
6247
|
+
align-items: center;
|
|
6248
|
+
justify-content: center;
|
|
6249
|
+
gap: 6px;
|
|
6250
|
+
width: 100%;
|
|
6251
|
+
height: 100%;
|
|
6252
|
+
padding: 16px;
|
|
6253
|
+
border-radius: 20px;
|
|
6254
|
+
background: rgba(20, 22, 28, 0.94);
|
|
6255
|
+
backdrop-filter: blur(20px);
|
|
6256
|
+
-webkit-backdrop-filter: blur(20px);
|
|
6257
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
6258
|
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
|
6259
|
+
color: white;
|
|
6260
|
+
text-align: center;
|
|
6261
|
+
cursor: pointer;
|
|
6262
|
+
transition:
|
|
6263
|
+
background 120ms,
|
|
6264
|
+
border-color 120ms;
|
|
6265
|
+
}
|
|
6266
|
+
|
|
6267
|
+
.monitor-picker-card:hover,
|
|
6268
|
+
.monitor-picker-card:focus-visible {
|
|
6269
|
+
background: rgba(32, 35, 43, 0.96);
|
|
6270
|
+
border-color: rgba(255, 255, 255, 0.24);
|
|
6271
|
+
outline: none;
|
|
6272
|
+
}
|
|
6273
|
+
|
|
6274
|
+
.monitor-picker-title {
|
|
6275
|
+
font-size: 17px;
|
|
6276
|
+
font-weight: 700;
|
|
6277
|
+
}
|
|
6278
|
+
|
|
6279
|
+
.monitor-picker-subtitle {
|
|
6280
|
+
font-size: 13px;
|
|
6281
|
+
font-weight: 500;
|
|
6282
|
+
color: rgba(255, 255, 255, 0.72);
|
|
6283
|
+
}
|
|
6284
|
+
|
|
6285
|
+
.monitor-picker-hint {
|
|
6286
|
+
margin-top: 4px;
|
|
6287
|
+
font-size: 11px;
|
|
6288
|
+
font-weight: 500;
|
|
6289
|
+
color: rgba(255, 255, 255, 0.45);
|
|
6290
|
+
}
|
|
@@ -47,6 +47,7 @@ const FLOW_BAR_LABEL: &str = "flow-bar";
|
|
|
47
47
|
const REGION_GUIDES_LABEL: &str = "region-guides";
|
|
48
48
|
const REGION_GUIDE_EDITOR_LABEL: &str = "region-guide-editor";
|
|
49
49
|
const REGION_RECORD_BORDER_LABEL: &str = "region-record-border";
|
|
50
|
+
const MONITOR_PICKER_LABEL_PREFIX: &str = "monitor-picker-";
|
|
50
51
|
const ONBOARDING_LABEL: &str = "onboarding";
|
|
51
52
|
const OVERLAY_LABELS: &[&str] = &[
|
|
52
53
|
COUNTDOWN_LABEL,
|
|
@@ -859,6 +860,153 @@ pub async fn show_region_capture_selector(app: AppHandle) -> Result<(), String>
|
|
|
859
860
|
Ok(())
|
|
860
861
|
}
|
|
861
862
|
|
|
863
|
+
fn close_monitor_picker_windows(app: &AppHandle) {
|
|
864
|
+
for (label, window) in app.webview_windows() {
|
|
865
|
+
if label.starts_with(MONITOR_PICKER_LABEL_PREFIX) {
|
|
866
|
+
let _ = window.close();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/// Resolve the CGDirectDisplayID a monitor's own center point maps to, using
|
|
872
|
+
/// that monitor's own scale factor (mixed-DPI multi-monitor setups can differ
|
|
873
|
+
/// per screen, unlike the single popover-scale shortcut `tray_display_id`
|
|
874
|
+
/// uses elsewhere).
|
|
875
|
+
#[cfg(target_os = "macos")]
|
|
876
|
+
fn display_id_for_monitor_rect(x: i32, y: i32, width: u32, height: u32, scale: f64) -> Option<u32> {
|
|
877
|
+
let cx_phys = x as f64 + width as f64 / 2.0;
|
|
878
|
+
let cy_phys = y as f64 + height as f64 / 2.0;
|
|
879
|
+
crate::native_screen::cg_display_id_at_physical_point(cx_phys, cy_phys, scale)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/// One small "record this screen" popup centered on every connected monitor,
|
|
883
|
+
/// shown before a full-screen native recording starts when more than one
|
|
884
|
+
/// monitor is connected. The React view in each window emits
|
|
885
|
+
/// `clips:monitor-picker-selected` (with the display id baked into its own
|
|
886
|
+
/// URL) or `clips:monitor-picker-cancelled`; the recorder driver listens for
|
|
887
|
+
/// either and calls `close_monitor_picker` to tear all of them down. Returns
|
|
888
|
+
/// `false` without opening anything when there's only one monitor, so the
|
|
889
|
+
/// caller can skip straight to its existing single-monitor start path.
|
|
890
|
+
#[tauri::command]
|
|
891
|
+
pub async fn show_monitor_picker(app: AppHandle) -> Result<bool, String> {
|
|
892
|
+
#[cfg(not(target_os = "macos"))]
|
|
893
|
+
{
|
|
894
|
+
let _ = app;
|
|
895
|
+
Ok(false)
|
|
896
|
+
}
|
|
897
|
+
#[cfg(target_os = "macos")]
|
|
898
|
+
{
|
|
899
|
+
close_monitor_picker_windows(&app);
|
|
900
|
+
// Every fresh pick starts clean — a leftover pick from a previous
|
|
901
|
+
// recording must never apply here, including the single-monitor
|
|
902
|
+
// early-return below.
|
|
903
|
+
crate::state::SelectedRecordingDisplay::set(&app, None);
|
|
904
|
+
let monitors = app
|
|
905
|
+
.get_webview_window("popover")
|
|
906
|
+
.and_then(|w| w.available_monitors().ok())
|
|
907
|
+
.unwrap_or_default();
|
|
908
|
+
if monitors.len() <= 1 {
|
|
909
|
+
return Ok(false);
|
|
910
|
+
}
|
|
911
|
+
let total = monitors.len();
|
|
912
|
+
// Only the LAST window gets the full activate/makeKey/focus dance —
|
|
913
|
+
// `present_interactive_window` steals key-window status from
|
|
914
|
+
// whichever window had it, so calling it once per monitor just
|
|
915
|
+
// re-steals focus from the previous iteration's window for no
|
|
916
|
+
// lasting effect. A plain `show()` is enough for the rest.
|
|
917
|
+
let mut last_window: Option<WebviewWindow> = None;
|
|
918
|
+
for (index, monitor) in monitors.iter().enumerate() {
|
|
919
|
+
let pos = monitor.position();
|
|
920
|
+
let size = monitor.size();
|
|
921
|
+
let scale = monitor.scale_factor().max(1.0);
|
|
922
|
+
// Fail closed: a `0` placeholder here would round-trip through
|
|
923
|
+
// the URL and back as a `null` pick, which `set_recording_display_override`
|
|
924
|
+
// accepts as "no override" — clicking this exact card would then
|
|
925
|
+
// silently record whatever the tray-anchor heuristic picks
|
|
926
|
+
// instead of the screen the user is looking at right now.
|
|
927
|
+
let Some(display_id) =
|
|
928
|
+
display_id_for_monitor_rect(pos.x, pos.y, size.width, size.height, scale)
|
|
929
|
+
else {
|
|
930
|
+
eprintln!(
|
|
931
|
+
"[clips-tray] monitor picker: could not resolve a display id for monitor {index}"
|
|
932
|
+
);
|
|
933
|
+
close_monitor_picker_windows(&app);
|
|
934
|
+
return Err("Could not identify one of the connected displays.".to_string());
|
|
935
|
+
};
|
|
936
|
+
let gutter = overlay_shadow_gutter_physical(&app);
|
|
937
|
+
let content_w: u32 = (240.0 * scale).round() as u32;
|
|
938
|
+
let content_h: u32 = (150.0 * scale).round() as u32;
|
|
939
|
+
let w = content_w + gutter * 2;
|
|
940
|
+
let h = content_h + gutter * 2;
|
|
941
|
+
let x = pos.x + (size.width as i32 - w as i32) / 2;
|
|
942
|
+
let y = pos.y + (size.height as i32 - h as i32) / 2;
|
|
943
|
+
let label = format!("{MONITOR_PICKER_LABEL_PREFIX}{index}");
|
|
944
|
+
let url = WebviewUrl::App(
|
|
945
|
+
format!(
|
|
946
|
+
"index.html?index={}&total={}&displayId={}#monitor-picker",
|
|
947
|
+
index + 1,
|
|
948
|
+
total,
|
|
949
|
+
display_id
|
|
950
|
+
)
|
|
951
|
+
.into(),
|
|
952
|
+
);
|
|
953
|
+
let win = match WebviewWindowBuilder::new(&app, &label, url)
|
|
954
|
+
.title("Choose a screen to record")
|
|
955
|
+
.decorations(false)
|
|
956
|
+
.transparent(true)
|
|
957
|
+
.always_on_top(true)
|
|
958
|
+
.skip_taskbar(true)
|
|
959
|
+
.resizable(false)
|
|
960
|
+
.shadow(false)
|
|
961
|
+
.visible(false)
|
|
962
|
+
.focused(true)
|
|
963
|
+
.accept_first_mouse(true)
|
|
964
|
+
.build()
|
|
965
|
+
{
|
|
966
|
+
Ok(win) => win,
|
|
967
|
+
Err(e) => {
|
|
968
|
+
eprintln!("[clips-tray] monitor picker build failed: {}", e);
|
|
969
|
+
// Earlier iterations already built and showed windows for
|
|
970
|
+
// other monitors — without this they'd stay open and
|
|
971
|
+
// always-on-top indefinitely since nothing else tears
|
|
972
|
+
// them down after this function errors out.
|
|
973
|
+
close_monitor_picker_windows(&app);
|
|
974
|
+
return Err(e.to_string());
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
let _ = win.set_size(tauri::Size::Physical(PhysicalSize::new(w, h)));
|
|
978
|
+
let _ = win.set_position(PhysicalPosition::new(x, y));
|
|
979
|
+
let _ = win.set_ignore_cursor_events(false);
|
|
980
|
+
set_capture_excluded_always(&win);
|
|
981
|
+
configure_overlay_behavior(&win);
|
|
982
|
+
let _ = win.show();
|
|
983
|
+
last_window = Some(win);
|
|
984
|
+
}
|
|
985
|
+
if let Some(win) = last_window {
|
|
986
|
+
present_interactive_window(&win);
|
|
987
|
+
}
|
|
988
|
+
Ok(true)
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
#[tauri::command]
|
|
993
|
+
pub async fn close_monitor_picker(app: AppHandle) -> Result<(), String> {
|
|
994
|
+
close_monitor_picker_windows(&app);
|
|
995
|
+
Ok(())
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/// Stash the user's monitor-picker pick for `tray_display_id` to consume the
|
|
999
|
+
/// next time it resolves a target display — i.e. for the native full-screen
|
|
1000
|
+
/// recording about to start.
|
|
1001
|
+
#[tauri::command]
|
|
1002
|
+
pub async fn set_recording_display_override(
|
|
1003
|
+
app: AppHandle,
|
|
1004
|
+
display_id: Option<u32>,
|
|
1005
|
+
) -> Result<(), String> {
|
|
1006
|
+
crate::state::SelectedRecordingDisplay::set(&app, display_id);
|
|
1007
|
+
Ok(())
|
|
1008
|
+
}
|
|
1009
|
+
|
|
862
1010
|
/// Vertical recording pill anchored to the left edge. Stop + timer + pause,
|
|
863
1011
|
/// with hover-revealed restart/cancel controls matching Loom's left-rail
|
|
864
1012
|
/// placement. Draggable, always on top.
|
|
@@ -1066,6 +1214,7 @@ pub async fn hide_overlays(
|
|
|
1066
1214
|
preserve_finalizing: Option<bool>,
|
|
1067
1215
|
) -> Result<(), String> {
|
|
1068
1216
|
stop_countdown_control_tracking();
|
|
1217
|
+
close_monitor_picker_windows(&app);
|
|
1069
1218
|
for label in overlay_labels_to_hide(preserve_finalizing.unwrap_or(false)) {
|
|
1070
1219
|
if let Some(w) = app.get_webview_window(label) {
|
|
1071
1220
|
let _ = w.close();
|
|
@@ -1086,7 +1235,10 @@ pub async fn hide_overlays(
|
|
|
1086
1235
|
/// by the popover's session effect (show on popover-open, hide on
|
|
1087
1236
|
/// popover-close).
|
|
1088
1237
|
#[tauri::command]
|
|
1089
|
-
pub async fn hide_recording_chrome(
|
|
1238
|
+
pub async fn hide_recording_chrome(
|
|
1239
|
+
app: AppHandle,
|
|
1240
|
+
preserve_display_override: Option<bool>,
|
|
1241
|
+
) -> Result<(), String> {
|
|
1090
1242
|
stop_countdown_control_tracking();
|
|
1091
1243
|
// The countdown + toolbar always tear down on recording stop. The region
|
|
1092
1244
|
// guides only tear down when they aren't pinned on-screen via the always-on
|
|
@@ -1104,6 +1256,16 @@ pub async fn hide_recording_chrome(app: AppHandle) -> Result<(), String> {
|
|
|
1104
1256
|
let _ = w.close();
|
|
1105
1257
|
}
|
|
1106
1258
|
}
|
|
1259
|
+
// A recording that used the multi-monitor picker is over — clear its
|
|
1260
|
+
// display override so the NEXT recording (which might skip the picker
|
|
1261
|
+
// entirely on a single monitor) doesn't inherit a stale target screen.
|
|
1262
|
+
// A native restart is the one exception: it discards this take and
|
|
1263
|
+
// immediately starts a replacement on the SAME screen without re-running
|
|
1264
|
+
// the picker (see `pickFullscreenRecordingDisplay`'s skip condition in
|
|
1265
|
+
// recorder.ts), so the caller passes `preserve_display_override: true`.
|
|
1266
|
+
if !preserve_display_override.unwrap_or(false) {
|
|
1267
|
+
crate::state::SelectedRecordingDisplay::set(&app, None);
|
|
1268
|
+
}
|
|
1107
1269
|
// If meeting or voice flows showed a recording pill, auto-hide it after
|
|
1108
1270
|
// recording stops. Bail early if a new recording came up in the meantime.
|
|
1109
1271
|
let app_for_pill = app.clone();
|
|
@@ -46,7 +46,8 @@ use tauri::{Emitter, Manager};
|
|
|
46
46
|
use clips::{position_popover, toggle_popover};
|
|
47
47
|
use state::{
|
|
48
48
|
ActiveMeetingId, DictationActive, DictationEnabled, LastTranscript, MeetingActive,
|
|
49
|
-
PopoverShownAt, RecordingActive, TrayAnchor, TrayMeetings,
|
|
49
|
+
PopoverShownAt, RecordingActive, SelectedRecordingDisplay, TrayAnchor, TrayMeetings,
|
|
50
|
+
VoiceTargetBundle, VoiceWakePopover,
|
|
50
51
|
};
|
|
51
52
|
use util::{
|
|
52
53
|
configure_overlay_behavior, is_recording_active, present_interactive_window,
|
|
@@ -98,6 +99,9 @@ pub fn run() {
|
|
|
98
99
|
clips::hide_region_record_border,
|
|
99
100
|
clips::show_region_guide_editor,
|
|
100
101
|
clips::show_region_capture_selector,
|
|
102
|
+
clips::show_monitor_picker,
|
|
103
|
+
clips::close_monitor_picker,
|
|
104
|
+
clips::set_recording_display_override,
|
|
101
105
|
clips::close_bubble,
|
|
102
106
|
clips::show_popover,
|
|
103
107
|
clips::park_popover_offscreen,
|
|
@@ -270,6 +274,7 @@ pub fn run() {
|
|
|
270
274
|
.manage(VoiceWakePopover::default())
|
|
271
275
|
.manage(VoiceTargetBundle::default())
|
|
272
276
|
.manage(LastTranscript::default())
|
|
277
|
+
.manage(SelectedRecordingDisplay::default())
|
|
273
278
|
.manage(native_screen::NativeFullscreenRecordingState::default())
|
|
274
279
|
.manage(screen_memory::ScreenMemoryState::default())
|
|
275
280
|
.manage(capture_graph::CaptureGraphState::default())
|
|
@@ -270,6 +270,14 @@ mod native_upload_mode_tests {
|
|
|
270
270
|
#[derive(Default)]
|
|
271
271
|
pub struct NativeFullscreenRecordingState {
|
|
272
272
|
inner: Mutex<Option<NativeFullscreenSession>>,
|
|
273
|
+
/// Bumped by `native_fullscreen_recording_cancel`. The warm task runs on
|
|
274
|
+
/// a blocking-pool thread and can still be mid-setup when a cancel
|
|
275
|
+
/// arrives — at that point `inner` is still empty, so cancel's own
|
|
276
|
+
/// `take()` finds nothing to discard. The warm task re-checks this
|
|
277
|
+
/// generation right after it installs its session; if it moved on, a
|
|
278
|
+
/// cancel arrived while it was working and it must undo the install
|
|
279
|
+
/// instead of leaving an orphaned, still-capturing stream behind.
|
|
280
|
+
warm_generation: AtomicU64,
|
|
273
281
|
}
|
|
274
282
|
|
|
275
283
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
|
@@ -1273,6 +1281,7 @@ fn start_native_session_locked(
|
|
|
1273
1281
|
mic_device_label: Option<String>,
|
|
1274
1282
|
capture_region: Option<NativeCaptureRegion>,
|
|
1275
1283
|
defer_recording_output: bool,
|
|
1284
|
+
expected_generation: Option<u64>,
|
|
1276
1285
|
) -> Result<NativeFullscreenStartInfo, String> {
|
|
1277
1286
|
let safe_id = sanitize_recording_id(recording_id);
|
|
1278
1287
|
reset_native_upload_completion_state();
|
|
@@ -1282,7 +1291,7 @@ fn start_native_session_locked(
|
|
|
1282
1291
|
|| mic_device_label
|
|
1283
1292
|
.as_deref()
|
|
1284
1293
|
.is_some_and(|v| !v.trim().is_empty());
|
|
1285
|
-
let session = match start_screencapturekit_recording(
|
|
1294
|
+
let mut session = match start_screencapturekit_recording(
|
|
1286
1295
|
app,
|
|
1287
1296
|
&safe_id,
|
|
1288
1297
|
include_audio,
|
|
@@ -1329,17 +1338,21 @@ fn start_native_session_locked(
|
|
|
1329
1338
|
|
|
1330
1339
|
let previous = {
|
|
1331
1340
|
let mut guard = state.inner.lock().map_err(|e| e.to_string())?;
|
|
1332
|
-
|
|
1341
|
+
if let Some(expected) = expected_generation {
|
|
1342
|
+
if state.warm_generation.load(Ordering::SeqCst) != expected {
|
|
1343
|
+
drop(guard);
|
|
1344
|
+
discard_session(&mut session);
|
|
1345
|
+
return Err("Recording cancelled while warming up.".to_string());
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
let previous = guard.take();
|
|
1349
|
+
*guard = Some(session);
|
|
1350
|
+
previous
|
|
1333
1351
|
};
|
|
1334
1352
|
if let Some(mut previous) = previous {
|
|
1335
1353
|
discard_session(&mut previous);
|
|
1336
1354
|
}
|
|
1337
1355
|
|
|
1338
|
-
{
|
|
1339
|
-
let mut guard = state.inner.lock().map_err(|e| e.to_string())?;
|
|
1340
|
-
*guard = Some(session);
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
1356
|
Ok(NativeFullscreenStartInfo {
|
|
1344
1357
|
recording_id: recording_id.to_string(),
|
|
1345
1358
|
width,
|
|
@@ -1358,7 +1371,6 @@ fn start_native_session_locked(
|
|
|
1358
1371
|
#[tauri::command]
|
|
1359
1372
|
pub async fn native_fullscreen_recording_warm(
|
|
1360
1373
|
app: AppHandle,
|
|
1361
|
-
state: State<'_, NativeFullscreenRecordingState>,
|
|
1362
1374
|
recording_id: String,
|
|
1363
1375
|
include_audio: bool,
|
|
1364
1376
|
capture_system_audio: bool,
|
|
@@ -1370,7 +1382,6 @@ pub async fn native_fullscreen_recording_warm(
|
|
|
1370
1382
|
{
|
|
1371
1383
|
let _ = (
|
|
1372
1384
|
app,
|
|
1373
|
-
state,
|
|
1374
1385
|
recording_id,
|
|
1375
1386
|
include_audio,
|
|
1376
1387
|
capture_system_audio,
|
|
@@ -1383,26 +1394,55 @@ pub async fn native_fullscreen_recording_warm(
|
|
|
1383
1394
|
|
|
1384
1395
|
#[cfg(target_os = "macos")]
|
|
1385
1396
|
{
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1388
|
-
//
|
|
1389
|
-
//
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1397
|
+
// Warm regardless of mic: the real cost here is SCShareableContent
|
|
1398
|
+
// lookup + SCStream/output construction + `start_capture`, not just
|
|
1399
|
+
// the microphone. Gating this on `include_audio` left every mic-off
|
|
1400
|
+
// recording to do that full setup synchronously inside `begin` —
|
|
1401
|
+
// i.e. after the countdown — which is exactly the multi-second gap
|
|
1402
|
+
// between "countdown hits zero" and "capture actually starts" that
|
|
1403
|
+
// mic-on recordings never had. SCK failures here are swallowed —
|
|
1404
|
+
// `begin` detects the absent warm session and falls back to an
|
|
1405
|
+
// immediate start, so warming is always best-effort.
|
|
1406
|
+
//
|
|
1407
|
+
// `SCShareableContent::get()` blocks its calling thread on a real
|
|
1408
|
+
// condvar wait (not polling) that this file's own comments elsewhere
|
|
1409
|
+
// clock at multi-second — that's the whole reason this exists instead
|
|
1410
|
+
// of running at `begin`. Run it via `spawn_blocking` so that wait
|
|
1411
|
+
// parks a blocking-pool thread instead of a tokio worker, which the
|
|
1412
|
+
// countdown's `Promise.all` overlap is also relying on to stay
|
|
1413
|
+
// responsive.
|
|
1414
|
+
let app_for_warm = app.clone();
|
|
1415
|
+
let recording_id_for_warm = recording_id.clone();
|
|
1416
|
+
let generation_before_warm = app
|
|
1417
|
+
.state::<NativeFullscreenRecordingState>()
|
|
1418
|
+
.warm_generation
|
|
1419
|
+
.load(Ordering::SeqCst);
|
|
1420
|
+
let warm_result = tauri::async_runtime::spawn_blocking(move || {
|
|
1421
|
+
let state = app_for_warm.state::<NativeFullscreenRecordingState>();
|
|
1422
|
+
start_native_session_locked(
|
|
1423
|
+
&app_for_warm,
|
|
1393
1424
|
&state,
|
|
1394
|
-
&
|
|
1425
|
+
&recording_id_for_warm,
|
|
1395
1426
|
include_audio,
|
|
1396
1427
|
capture_system_audio,
|
|
1397
1428
|
mic_device_id,
|
|
1398
1429
|
mic_device_label,
|
|
1399
1430
|
capture_region,
|
|
1400
1431
|
true,
|
|
1401
|
-
|
|
1432
|
+
Some(generation_before_warm),
|
|
1433
|
+
)
|
|
1434
|
+
})
|
|
1435
|
+
.await;
|
|
1436
|
+
match warm_result {
|
|
1437
|
+
Ok(Ok(_)) => {}
|
|
1438
|
+
Ok(Err(err)) => {
|
|
1402
1439
|
eprintln!(
|
|
1403
|
-
"[clips-tray]
|
|
1440
|
+
"[clips-tray] recording pre-warm unavailable; will start normally at begin: {err}"
|
|
1404
1441
|
);
|
|
1405
1442
|
}
|
|
1443
|
+
Err(join_err) => {
|
|
1444
|
+
eprintln!("[clips-tray] recording pre-warm task panicked: {join_err}");
|
|
1445
|
+
}
|
|
1406
1446
|
}
|
|
1407
1447
|
Ok(())
|
|
1408
1448
|
}
|
|
@@ -1480,6 +1520,15 @@ pub async fn native_fullscreen_recording_begin(
|
|
|
1480
1520
|
.unwrap_or(false)
|
|
1481
1521
|
};
|
|
1482
1522
|
if !is_warmed {
|
|
1523
|
+
// A warm task can still be mid-`SCShareableContent` lookup on a
|
|
1524
|
+
// blocking-pool thread right now — that's exactly why nothing is
|
|
1525
|
+
// installed yet. Bump the generation before building our own
|
|
1526
|
+
// session below: without this, when that warm task eventually
|
|
1527
|
+
// finishes, it sees no cancel occurred, assumes its generation is
|
|
1528
|
+
// still current, and silently replaces the live session we're
|
|
1529
|
+
// about to install with its own (never actually attached)
|
|
1530
|
+
// deferred-output one.
|
|
1531
|
+
state.warm_generation.fetch_add(1, Ordering::SeqCst);
|
|
1483
1532
|
let info = start_native_session_locked(
|
|
1484
1533
|
&app,
|
|
1485
1534
|
&state,
|
|
@@ -1490,6 +1539,7 @@ pub async fn native_fullscreen_recording_begin(
|
|
|
1490
1539
|
mic_device_label,
|
|
1491
1540
|
capture_region,
|
|
1492
1541
|
false,
|
|
1542
|
+
None,
|
|
1493
1543
|
)?;
|
|
1494
1544
|
{
|
|
1495
1545
|
let mut guard = state.inner.lock().map_err(|e| e.to_string())?;
|
|
@@ -2094,8 +2144,15 @@ pub(crate) fn kill_active_screencapture_child(state: &NativeFullscreenRecordingS
|
|
|
2094
2144
|
|
|
2095
2145
|
#[tauri::command]
|
|
2096
2146
|
pub async fn native_fullscreen_recording_cancel(
|
|
2147
|
+
app: AppHandle,
|
|
2097
2148
|
state: State<'_, NativeFullscreenRecordingState>,
|
|
2149
|
+
preserve_display_override: Option<bool>,
|
|
2098
2150
|
) -> Result<(), String> {
|
|
2151
|
+
// Bump BEFORE taking the session: a warm task on a blocking-pool thread
|
|
2152
|
+
// may still be mid-setup right now, with nothing installed yet for this
|
|
2153
|
+
// `take()` to find. Bumping first guarantees that when it later checks
|
|
2154
|
+
// the generation after installing its session, it sees this cancel.
|
|
2155
|
+
state.warm_generation.fetch_add(1, Ordering::SeqCst);
|
|
2099
2156
|
let session = {
|
|
2100
2157
|
let mut guard = state.inner.lock().map_err(|e| e.to_string())?;
|
|
2101
2158
|
guard.take()
|
|
@@ -2103,6 +2160,14 @@ pub async fn native_fullscreen_recording_cancel(
|
|
|
2103
2160
|
if let Some(mut session) = session {
|
|
2104
2161
|
discard_session(&mut session);
|
|
2105
2162
|
}
|
|
2163
|
+
// An aborted start (countdown/warm cancelled before `begin`) never reaches
|
|
2164
|
+
// `hide_recording_chrome`, so the picker's monitor override must also be
|
|
2165
|
+
// cleared here or it would wrongly apply to the next recording attempt.
|
|
2166
|
+
// A native restart is the exception — see `hide_recording_chrome`'s
|
|
2167
|
+
// matching `preserve_display_override` doc comment.
|
|
2168
|
+
if !preserve_display_override.unwrap_or(false) {
|
|
2169
|
+
crate::state::SelectedRecordingDisplay::set(&app, None);
|
|
2170
|
+
}
|
|
2106
2171
|
Ok(())
|
|
2107
2172
|
}
|
|
2108
2173
|
|
|
@@ -3895,7 +3960,21 @@ pub(crate) fn clear_shared_clip_recording(app: &AppHandle, recording_id: &str, p
|
|
|
3895
3960
|
let _ = app.emit("clips:pending-uploads-changed", ());
|
|
3896
3961
|
}
|
|
3897
3962
|
|
|
3963
|
+
/// Convert a physical-pixel point to the logical-point coordinate space
|
|
3964
|
+
/// `CGDisplay::displays_with_point` requires (dividing by the monitor's own
|
|
3965
|
+
/// scale factor) and resolve which display owns it. Shared by every place in
|
|
3966
|
+
/// this file that maps a screen point to a `CGDirectDisplayID`.
|
|
3898
3967
|
#[cfg(target_os = "macos")]
|
|
3968
|
+
pub(crate) fn cg_display_id_at_physical_point(
|
|
3969
|
+
cx_phys: f64,
|
|
3970
|
+
cy_phys: f64,
|
|
3971
|
+
scale: f64,
|
|
3972
|
+
) -> Option<u32> {
|
|
3973
|
+
let point = CGPoint::new(cx_phys / scale, cy_phys / scale);
|
|
3974
|
+
let (ids, _) = CGDisplay::displays_with_point(point, 4).ok()?;
|
|
3975
|
+
ids.into_iter().next()
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3899
3978
|
/// Return the `CGDirectDisplayID` of the display containing the centre of
|
|
3900
3979
|
/// the last-clicked tray icon. Uses the Tauri monitor list to locate the
|
|
3901
3980
|
/// right monitor and converts from physical pixels to the logical-point
|
|
@@ -3904,6 +3983,12 @@ pub(crate) fn clear_shared_clip_recording(app: &AppHandle, recording_id: &str, p
|
|
|
3904
3983
|
/// fails — callers fall back to the first available display.
|
|
3905
3984
|
#[cfg(target_os = "macos")]
|
|
3906
3985
|
pub(crate) fn tray_display_id(app: &AppHandle) -> Option<u32> {
|
|
3986
|
+
// A multi-monitor picker pick always wins over the tray-anchor heuristic
|
|
3987
|
+
// below — see `SelectedRecordingDisplay`'s doc comment for its lifecycle.
|
|
3988
|
+
if let Some(id) = crate::state::SelectedRecordingDisplay::get(app) {
|
|
3989
|
+
return Some(id);
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3907
3992
|
let tray_rect = app
|
|
3908
3993
|
.try_state::<crate::state::TrayAnchor>()
|
|
3909
3994
|
.and_then(|a| a.0.lock().ok().and_then(|g| *g))?;
|
|
@@ -3946,9 +4031,43 @@ pub(crate) fn tray_display_id(app: &AppHandle) -> Option<u32> {
|
|
|
3946
4031
|
.map(|m| m.scale_factor())
|
|
3947
4032
|
.unwrap_or(2.0);
|
|
3948
4033
|
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
4034
|
+
cg_display_id_at_physical_point(cx_phys, cy_phys, scale)
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
/// Reverse of the monitor-center-to-CGDisplayID resolution above: given a
|
|
4038
|
+
/// CGDirectDisplayID (as picked in the multi-monitor screen picker), find the
|
|
4039
|
+
/// Tauri monitor whose center resolves to it and return its physical rect.
|
|
4040
|
+
/// Used so overlay windows (countdown, toolbar, finalizing, ...) shown during
|
|
4041
|
+
/// a recording that used the picker land on the same screen the user picked,
|
|
4042
|
+
/// not the tray icon's screen.
|
|
4043
|
+
#[cfg(target_os = "macos")]
|
|
4044
|
+
pub(crate) fn monitor_rect_for_display_id(
|
|
4045
|
+
app: &AppHandle,
|
|
4046
|
+
display_id: u32,
|
|
4047
|
+
) -> Option<(i32, i32, u32, u32)> {
|
|
4048
|
+
let monitors = app
|
|
4049
|
+
.get_webview_window("popover")?
|
|
4050
|
+
.available_monitors()
|
|
4051
|
+
.ok()?;
|
|
4052
|
+
for monitor in monitors {
|
|
4053
|
+
let pos = monitor.position();
|
|
4054
|
+
let size = monitor.size();
|
|
4055
|
+
let scale = monitor.scale_factor().max(1.0);
|
|
4056
|
+
let cx_phys = pos.x as f64 + size.width as f64 / 2.0;
|
|
4057
|
+
let cy_phys = pos.y as f64 + size.height as f64 / 2.0;
|
|
4058
|
+
if cg_display_id_at_physical_point(cx_phys, cy_phys, scale) == Some(display_id) {
|
|
4059
|
+
return Some((pos.x, pos.y, size.width, size.height));
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
None
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
#[cfg(not(target_os = "macos"))]
|
|
4066
|
+
pub(crate) fn monitor_rect_for_display_id(
|
|
4067
|
+
_app: &AppHandle,
|
|
4068
|
+
_display_id: u32,
|
|
4069
|
+
) -> Option<(i32, i32, u32, u32)> {
|
|
4070
|
+
None
|
|
3952
4071
|
}
|
|
3953
4072
|
|
|
3954
4073
|
#[cfg(target_os = "macos")]
|