@agent-native/core 0.175.2-nightly-20260826150235 → 0.175.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/clips/app/components/player/comments-panel.tsx +1 -1
- package/corpus/templates/clips/app/components/player/video-player.tsx +5 -3
- package/corpus/templates/clips/desktop/src/app.tsx +171 -51
- package/corpus/templates/clips/desktop/src/lib/pill-completion.ts +111 -0
- package/corpus/templates/clips/desktop/src/lib/pill-session.ts +34 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +181 -79
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +28 -4
- package/corpus/templates/clips/desktop/src/main.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/record-pill.tsx +1320 -0
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +1 -1
- package/corpus/templates/clips/desktop/src/styles.css +20 -308
- package/corpus/templates/clips/desktop/src/tailwind.css +85 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +153 -70
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +15 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +25 -7
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +3 -3
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +375 -24
- package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +22 -30
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +5 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +134 -14
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +233 -4
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +8 -5
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -1
- package/corpus/templates/design/.generated/bridge/embedded-wheel.generated.ts +3 -2
- package/corpus/templates/design/.generated/bridge/zoom.generated.ts +8 -2
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +46 -10
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -63
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +3 -1
- package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +8 -2
- package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +11 -2
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -4
- package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +19 -63
- package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +2 -1
- package/corpus/templates/design/shared/canvas-math.ts +11 -1
- package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +44 -18
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +88 -23
- package/corpus/templates/slides/app/context/DeckContext.tsx +155 -86
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +7 -5
- package/corpus/templates/slides/changelog/2026-08-26-thumbnail-arrow-keys-now-navigate-slides-after-selection.md +6 -0
- package/dist/client/hooks/index.d.ts +1 -0
- package/dist/client/hooks/index.js +1 -0
- package/dist/client/use-pinch-zoom.js +17 -4
- package/dist/client/zoom-gesture.d.ts +74 -0
- package/dist/client/zoom-gesture.js +100 -0
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +13 -13
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/builder-browser.d.ts +13 -13
- package/dist/server/builder-browser.js +46 -15
- package/dist/server/builder-oauth.d.ts +12 -0
- package/dist/server/builder-oauth.js +13 -2
- package/dist/server/core-routes-plugin.js +54 -19
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/vite/client.js +2 -0
- package/docs/content/template-chat-developers.mdx +100 -13
- package/docs/content/template-chat-first-edits.mdx +5 -5
- package/docs/content/template-chat.mdx +84 -36
- package/package.json +4 -3
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +0 -458
|
@@ -76,8 +76,10 @@ const PILL_W_EXPANDED_MEETING_LOGICAL: u32 = 480;
|
|
|
76
76
|
/// cursor is still nowhere near it.
|
|
77
77
|
const PILL_H_LOGICAL: u32 = 60;
|
|
78
78
|
const PILL_H_EXPANDED_LOGICAL: u32 = 340;
|
|
79
|
-
/// Bottom margin from the screen edge, logical px.
|
|
80
|
-
|
|
79
|
+
/// Bottom margin from the screen edge, logical px. 42 = the old 24px window
|
|
80
|
+
/// margin + the removed 18px shadow gutter, so the default capsule keeps the
|
|
81
|
+
/// exact on-screen spot it had before the native-shadow conversion.
|
|
82
|
+
const PILL_BOTTOM_MARGIN_LOGICAL: u32 = 42;
|
|
81
83
|
|
|
82
84
|
/// Detached / "floating" mode dimensions — anchored top-right of the primary
|
|
83
85
|
/// monitor when the user focuses another app. Smaller footprint so it
|
|
@@ -88,7 +90,6 @@ const PILL_DETACHED_TOP_MARGIN_LOGICAL: u32 = 24;
|
|
|
88
90
|
const PILL_DETACHED_RIGHT_MARGIN_LOGICAL: u32 = 24;
|
|
89
91
|
/// Gap between the visible capsule and the right screen edge, logical px.
|
|
90
92
|
const PILL_RIGHT_MARGIN_LOGICAL: u32 = 28;
|
|
91
|
-
const OVERLAY_SHADOW_GUTTER_LOGICAL: f64 = 18.0;
|
|
92
93
|
|
|
93
94
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
|
94
95
|
#[serde(rename_all = "lowercase")]
|
|
@@ -104,17 +105,11 @@ fn scale_factor(app: &AppHandle) -> f64 {
|
|
|
104
105
|
.unwrap_or(2.0)
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
/// Screen-edge margin for the *visible* capsule. The window is padded out with
|
|
112
|
-
/// a transparent shadow gutter, so that gutter comes off the window margin —
|
|
113
|
-
/// measured from the window frame the pill floats a whole gutter further from
|
|
114
|
-
/// the edge than the constant says.
|
|
108
|
+
/// Screen-edge margin for the visible capsule, physical px. The window is
|
|
109
|
+
/// sized to the capsule exactly (native shadow, no transparent gutter), so
|
|
110
|
+
/// the margin applies straight to the window frame.
|
|
115
111
|
fn edge_margin_physical(app: &AppHandle, logical: u32) -> i32 {
|
|
116
|
-
|
|
117
|
-
(margin - overlay_shadow_gutter_physical(app) as i32).max(0)
|
|
112
|
+
(logical as f64 * scale_factor(app)) as i32
|
|
118
113
|
}
|
|
119
114
|
|
|
120
115
|
/// Persist the last-known pill position so the next `show` re-opens at the
|
|
@@ -259,7 +254,10 @@ fn default_center_right(app: &AppHandle, w: u32, h: u32) -> (i32, i32) {
|
|
|
259
254
|
(x, y)
|
|
260
255
|
}
|
|
261
256
|
|
|
262
|
-
|
|
257
|
+
/// Window size (physical px). The window is sized to the visible capsule
|
|
258
|
+
/// exactly — elevation is the native NSWindow shadow (see the builder's
|
|
259
|
+
/// `shadow(true)`), so there is no transparent shadow gutter to add here.
|
|
260
|
+
fn pill_size_physical(app: &AppHandle, expanded: bool) -> (u32, u32) {
|
|
263
261
|
let scale = scale_factor(app);
|
|
264
262
|
let detached = PILL_DETACHED.load(Ordering::Relaxed);
|
|
265
263
|
// Detached mode ignores the `expanded` flag — the floating pill is a
|
|
@@ -284,12 +282,6 @@ fn pill_content_size_physical(app: &AppHandle, expanded: bool) -> (u32, u32) {
|
|
|
284
282
|
(w, h)
|
|
285
283
|
}
|
|
286
284
|
|
|
287
|
-
fn pill_size_physical(app: &AppHandle, expanded: bool) -> (u32, u32) {
|
|
288
|
-
let (content_w, content_h) = pill_content_size_physical(app, expanded);
|
|
289
|
-
let gutter = overlay_shadow_gutter_physical(app);
|
|
290
|
-
(content_w + gutter * 2, content_h + gutter * 2)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
285
|
/// Default top-right anchor (physical px) for detached mode.
|
|
294
286
|
fn default_top_right(app: &AppHandle, w: u32, _h: u32) -> (i32, i32) {
|
|
295
287
|
let top_margin = edge_margin_physical(app, PILL_DETACHED_TOP_MARGIN_LOGICAL);
|
|
@@ -441,7 +433,11 @@ pub async fn recording_pill_show(
|
|
|
441
433
|
.always_on_top(true)
|
|
442
434
|
.skip_taskbar(true)
|
|
443
435
|
.resizable(false)
|
|
444
|
-
|
|
436
|
+
// Native elevation: on a transparent window macOS computes the
|
|
437
|
+
// shadow from the drawn content's alpha, so the capsule gets a
|
|
438
|
+
// correctly rounded OS shadow with the window sized to the capsule
|
|
439
|
+
// exactly — no transparent CSS-shadow apron eating clicks.
|
|
440
|
+
.shadow(true)
|
|
445
441
|
.visible(false)
|
|
446
442
|
.focused(false)
|
|
447
443
|
.accept_first_mouse(true)
|
|
@@ -472,9 +468,8 @@ pub async fn recording_pill_show(
|
|
|
472
468
|
|
|
473
469
|
/// True when the global cursor sits inside the pill window's frame. Cursor and
|
|
474
470
|
/// frame both come from Tauri (physical px, desktop top-left origin), so the
|
|
475
|
-
/// test is a plain point-in-rect with no AppKit hop. The
|
|
476
|
-
///
|
|
477
|
-
/// matches the capsule the user actually sees.
|
|
471
|
+
/// test is a plain point-in-rect with no AppKit hop. The window is sized to
|
|
472
|
+
/// the capsule exactly, so the frame IS the capsule the user actually sees.
|
|
478
473
|
fn cursor_inside_pill_frame(window: &WebviewWindow) -> bool {
|
|
479
474
|
let (Ok(c), Ok(p), Ok(s)) = (
|
|
480
475
|
window.cursor_position(),
|
|
@@ -483,12 +478,9 @@ fn cursor_inside_pill_frame(window: &WebviewWindow) -> bool {
|
|
|
483
478
|
) else {
|
|
484
479
|
return false;
|
|
485
480
|
};
|
|
486
|
-
let
|
|
487
|
-
let
|
|
488
|
-
|
|
489
|
-
let right = p.x + s.width as i32 - gutter;
|
|
490
|
-
let bottom = p.y + s.height as i32 - gutter;
|
|
491
|
-
c.x >= left as f64 && c.x <= right as f64 && c.y >= top as f64 && c.y <= bottom as f64
|
|
481
|
+
let right = p.x + s.width as i32;
|
|
482
|
+
let bottom = p.y + s.height as i32;
|
|
483
|
+
c.x >= p.x as f64 && c.x <= right as f64 && c.y >= p.y as f64 && c.y <= bottom as f64
|
|
492
484
|
}
|
|
493
485
|
|
|
494
486
|
/// Start polling the cursor against the pill frame and emitting
|
|
@@ -119,6 +119,11 @@ pub(crate) fn rewind_clip_prepare(
|
|
|
119
119
|
if state.0.lock().map_err(|error| error.to_string())?.is_some() {
|
|
120
120
|
return Err("a Rewind-derived clip is already prepared or active".into());
|
|
121
121
|
}
|
|
122
|
+
// The completion slot is take-once but never expires, and the recording
|
|
123
|
+
// pill drains it whenever a completion card opens. The native start path
|
|
124
|
+
// clears it per session; this one has to as well, or a Rewind take's card
|
|
125
|
+
// can consume the previous recording's result and show its URL.
|
|
126
|
+
native_screen::reset_native_upload_completion_state();
|
|
122
127
|
let temporary_audio = if include_mic || include_system_audio {
|
|
123
128
|
screen_memory::acquire_temporary_audio_consumer(
|
|
124
129
|
&app,
|
|
@@ -1130,24 +1130,83 @@ pub(crate) fn release_temporary_audio_consumer(
|
|
|
1130
1130
|
};
|
|
1131
1131
|
end_graph_lease(app, &consumer.graph_lease_id);
|
|
1132
1132
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
.
|
|
1141
|
-
.map_err(|error| error.to_string())?
|
|
1142
|
-
.active
|
|
1143
|
-
.as_ref()
|
|
1144
|
-
.is_some_and(|active| active.capture_mode != desired);
|
|
1145
|
-
if needs_restore {
|
|
1146
|
-
rotate_segment_inner(app, &config)?;
|
|
1133
|
+
// Restoring the configured capture mode is a full producer stop/start
|
|
1134
|
+
// (the SCK source contract changed) that measures ~2s. Never charge that
|
|
1135
|
+
// to the releasing caller — a clip cancel must return immediately — and
|
|
1136
|
+
// give re-demand a grace window: a cancel-then-retake re-acquires the
|
|
1137
|
+
// same audio sources within a couple of seconds, which turns the pending
|
|
1138
|
+
// restore into a no-op instead of two stop/start cycles.
|
|
1139
|
+
if capture_mode_restore_needed(app) {
|
|
1140
|
+
schedule_capture_mode_restore(app.clone());
|
|
1147
1141
|
}
|
|
1148
1142
|
Ok(())
|
|
1149
1143
|
}
|
|
1150
1144
|
|
|
1145
|
+
/// How long released audio demand may keep the producer in its upgraded mode
|
|
1146
|
+
/// before the configured capture mode is restored. Long enough that a rapid
|
|
1147
|
+
/// cancel→retake reattaches to the still-running audio producer for free;
|
|
1148
|
+
/// short enough that the microphone stops being captured promptly after the
|
|
1149
|
+
/// last consumer lets go.
|
|
1150
|
+
const CAPTURE_MODE_RESTORE_GRACE: Duration = Duration::from_secs(5);
|
|
1151
|
+
|
|
1152
|
+
fn schedule_capture_mode_restore(app: AppHandle) {
|
|
1153
|
+
std::thread::spawn(move || {
|
|
1154
|
+
std::thread::sleep(CAPTURE_MODE_RESTORE_GRACE);
|
|
1155
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1156
|
+
let _transition = match state.transition.lock() {
|
|
1157
|
+
Ok(guard) => guard,
|
|
1158
|
+
Err(error) => {
|
|
1159
|
+
record_error(&app, error.to_string());
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
// Re-evaluate under the transition lock: new audio demand, a config
|
|
1164
|
+
// change, or a producer stop during the grace all make this a no-op,
|
|
1165
|
+
// so overlapping schedules can never double-reconfigure the producer.
|
|
1166
|
+
if !capture_mode_restore_needed(&app) {
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
let config =
|
|
1170
|
+
normalize_screen_memory_config(crate::config::feature_config(&app).screen_memory);
|
|
1171
|
+
if let Err(error) = rotate_segment_inner(&app, &config) {
|
|
1172
|
+
record_error(&app, error);
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
fn capture_mode_restore_needed(app: &AppHandle) -> bool {
|
|
1178
|
+
let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
|
|
1179
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1180
|
+
let Ok(runtime) = state.inner.lock() else {
|
|
1181
|
+
return false;
|
|
1182
|
+
};
|
|
1183
|
+
capture_mode_restore_decision(
|
|
1184
|
+
config.enabled,
|
|
1185
|
+
config.paused,
|
|
1186
|
+
runtime.active.as_ref().map(|active| active.capture_mode),
|
|
1187
|
+
config.capture_mode,
|
|
1188
|
+
runtime.temporary_audio_consumers.len(),
|
|
1189
|
+
)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/// True only while the producer is running in a capture mode the current
|
|
1193
|
+
/// demand (persisted config plus live temporary-audio consumers) no longer
|
|
1194
|
+
/// asks for. Disabled/paused configs are owned by `sync_from_config`'s stop
|
|
1195
|
+
/// path, and a stopped producer has nothing to restore.
|
|
1196
|
+
fn capture_mode_restore_decision(
|
|
1197
|
+
enabled: bool,
|
|
1198
|
+
paused: bool,
|
|
1199
|
+
active_mode: Option<RewindCaptureMode>,
|
|
1200
|
+
base_mode: RewindCaptureMode,
|
|
1201
|
+
temporary_audio_consumers: usize,
|
|
1202
|
+
) -> bool {
|
|
1203
|
+
if !enabled || paused {
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
let desired = effective_mode(base_mode, temporary_audio_consumers);
|
|
1207
|
+
active_mode.is_some_and(|mode| mode != desired)
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1151
1210
|
fn rollback_temporary_audio_consumer(app: &AppHandle, owner_id: &str) {
|
|
1152
1211
|
let consumer = app
|
|
1153
1212
|
.state::<ScreenMemoryState>()
|
|
@@ -3782,6 +3841,67 @@ command = "keep"
|
|
|
3782
3841
|
));
|
|
3783
3842
|
}
|
|
3784
3843
|
|
|
3844
|
+
#[test]
|
|
3845
|
+
fn deferred_capture_mode_restore_fires_only_for_a_stale_upgraded_producer() {
|
|
3846
|
+
// The clip's audio demand is gone and the producer still runs
|
|
3847
|
+
// upgraded: restore.
|
|
3848
|
+
assert!(capture_mode_restore_decision(
|
|
3849
|
+
true,
|
|
3850
|
+
false,
|
|
3851
|
+
Some(RewindCaptureMode::VisualsAudio),
|
|
3852
|
+
RewindCaptureMode::Visuals,
|
|
3853
|
+
0
|
|
3854
|
+
));
|
|
3855
|
+
// A retake (or a meeting) re-demanded audio during the grace window:
|
|
3856
|
+
// the pending restore must become a no-op, not a downgrade under a
|
|
3857
|
+
// live consumer.
|
|
3858
|
+
assert!(!capture_mode_restore_decision(
|
|
3859
|
+
true,
|
|
3860
|
+
false,
|
|
3861
|
+
Some(RewindCaptureMode::VisualsAudio),
|
|
3862
|
+
RewindCaptureMode::Visuals,
|
|
3863
|
+
1
|
|
3864
|
+
));
|
|
3865
|
+
// Producer already back at (or configured for) the desired mode.
|
|
3866
|
+
assert!(!capture_mode_restore_decision(
|
|
3867
|
+
true,
|
|
3868
|
+
false,
|
|
3869
|
+
Some(RewindCaptureMode::Visuals),
|
|
3870
|
+
RewindCaptureMode::Visuals,
|
|
3871
|
+
0
|
|
3872
|
+
));
|
|
3873
|
+
assert!(!capture_mode_restore_decision(
|
|
3874
|
+
true,
|
|
3875
|
+
false,
|
|
3876
|
+
Some(RewindCaptureMode::VisualsAudio),
|
|
3877
|
+
RewindCaptureMode::VisualsAudio,
|
|
3878
|
+
0
|
|
3879
|
+
));
|
|
3880
|
+
// Producer stopped during the grace window: nothing to restore.
|
|
3881
|
+
assert!(!capture_mode_restore_decision(
|
|
3882
|
+
true,
|
|
3883
|
+
false,
|
|
3884
|
+
None,
|
|
3885
|
+
RewindCaptureMode::Visuals,
|
|
3886
|
+
0
|
|
3887
|
+
));
|
|
3888
|
+
// Disabled/paused teardown is owned by sync_from_config's stop path.
|
|
3889
|
+
assert!(!capture_mode_restore_decision(
|
|
3890
|
+
false,
|
|
3891
|
+
false,
|
|
3892
|
+
Some(RewindCaptureMode::VisualsAudio),
|
|
3893
|
+
RewindCaptureMode::Visuals,
|
|
3894
|
+
0
|
|
3895
|
+
));
|
|
3896
|
+
assert!(!capture_mode_restore_decision(
|
|
3897
|
+
true,
|
|
3898
|
+
true,
|
|
3899
|
+
Some(RewindCaptureMode::VisualsAudio),
|
|
3900
|
+
RewindCaptureMode::Visuals,
|
|
3901
|
+
0
|
|
3902
|
+
));
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3785
3905
|
#[test]
|
|
3786
3906
|
fn terminal_handoffs_do_not_retain_agent_reason_text() {
|
|
3787
3907
|
let mut object = serde_json::json!({
|
|
@@ -11,6 +11,162 @@ use crate::tray_meetings::{build_meetings_section, handle_meeting_menu_click, Me
|
|
|
11
11
|
use crate::util::{is_meeting_active, is_recording_active};
|
|
12
12
|
use crate::TRAY_PNG;
|
|
13
13
|
|
|
14
|
+
/// A rounded stop square rendered at the same pixel size as the normal tray
|
|
15
|
+
/// icon, used as the menu-bar icon while a recording is live (CleanShot's
|
|
16
|
+
/// pattern: the status item becomes stop + elapsed time, and clicking it
|
|
17
|
+
/// stops the recording). Template-style: black with alpha, so macOS tints it
|
|
18
|
+
/// for menu-bar appearance.
|
|
19
|
+
fn stop_square_icon(w: u32, h: u32) -> tauri::image::Image<'static> {
|
|
20
|
+
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
|
21
|
+
let side = ((w.min(h) as f32) * 0.62).round() as i32;
|
|
22
|
+
let x0 = (w as i32 - side) / 2;
|
|
23
|
+
let y0 = (h as i32 - side) / 2;
|
|
24
|
+
let radius = (side as f32 * 0.28).max(1.0);
|
|
25
|
+
let inside_rounded = |x: i32, y: i32| -> bool {
|
|
26
|
+
if x < x0 || y < y0 || x >= x0 + side || y >= y0 + side {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
let rx = radius as i32;
|
|
30
|
+
let corners = [
|
|
31
|
+
(x0 + rx, y0 + rx),
|
|
32
|
+
(x0 + side - 1 - rx, y0 + rx),
|
|
33
|
+
(x0 + rx, y0 + side - 1 - rx),
|
|
34
|
+
(x0 + side - 1 - rx, y0 + side - 1 - rx),
|
|
35
|
+
];
|
|
36
|
+
for (cx, cy) in corners {
|
|
37
|
+
let in_corner_box =
|
|
38
|
+
(x < x0 + rx || x > x0 + side - 1 - rx) && (y < y0 + rx || y > y0 + side - 1 - rx);
|
|
39
|
+
if in_corner_box {
|
|
40
|
+
let dx = (x - cx) as f32;
|
|
41
|
+
let dy = (y - cy) as f32;
|
|
42
|
+
let near = (x - cx).abs() <= rx && (y - cy).abs() <= rx;
|
|
43
|
+
if near {
|
|
44
|
+
return dx * dx + dy * dy <= radius * radius;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
true
|
|
49
|
+
};
|
|
50
|
+
for y in 0..h as i32 {
|
|
51
|
+
for x in 0..w as i32 {
|
|
52
|
+
if inside_rounded(x, y) {
|
|
53
|
+
let idx = ((y as u32 * w + x as u32) * 4) as usize;
|
|
54
|
+
rgba[idx + 3] = 255;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
tauri::image::Image::new_owned(rgba, w, h)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Whether the status item is in recording mode (stop square + timer).
|
|
62
|
+
/// Written ONLY by `tray_recording_status` — the pill is the single owner of
|
|
63
|
+
/// this state — plus the window-destroyed backstop in lib.rs.
|
|
64
|
+
static TRAY_RECORDING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
65
|
+
/// Millis timestamp of the last recording-mode write. The pill refreshes the
|
|
66
|
+
/// title every 500ms while live, so 2.5s of silence means the writer is gone
|
|
67
|
+
/// and the dead-man loop below clears the status item — a stale menu-bar
|
|
68
|
+
/// timer is structurally impossible, not just handled per code path.
|
|
69
|
+
static TRAY_LAST_WRITE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
70
|
+
/// Icon image last applied to the status item: unset, app logo, or stop
|
|
71
|
+
/// square. Tracked separately from `TRAY_RECORDING` because it records what
|
|
72
|
+
/// the status item actually accepted, not what was requested.
|
|
73
|
+
const TRAY_ICON_MODE_UNSET: i8 = -1;
|
|
74
|
+
static TRAY_ICON_MODE: std::sync::atomic::AtomicI8 =
|
|
75
|
+
std::sync::atomic::AtomicI8::new(TRAY_ICON_MODE_UNSET);
|
|
76
|
+
|
|
77
|
+
fn tray_icon_mode(stored: i8) -> Option<bool> {
|
|
78
|
+
if stored == TRAY_ICON_MODE_UNSET {
|
|
79
|
+
None
|
|
80
|
+
} else {
|
|
81
|
+
Some(stored != 0)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Whether the status item's icon image has to be re-uploaded.
|
|
86
|
+
///
|
|
87
|
+
/// `set_icon` replaces the NSStatusItem's image, and the pill refreshes the
|
|
88
|
+
/// title four times a second to tick the timer. Re-uploading the same image at
|
|
89
|
+
/// that cadence makes the menu-bar icon visibly flash, so the image is written
|
|
90
|
+
/// only on the first write and on an actual mode flip. The title still goes
|
|
91
|
+
/// out every call — it is the part that genuinely changes.
|
|
92
|
+
fn tray_icon_needs_write(previous: Option<bool>, next: bool) -> bool {
|
|
93
|
+
previous != Some(next)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fn now_ms() -> u64 {
|
|
97
|
+
std::time::SystemTime::now()
|
|
98
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
99
|
+
.map(|d| d.as_millis() as u64)
|
|
100
|
+
.unwrap_or(0)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pub fn tray_recording_active() -> bool {
|
|
104
|
+
TRAY_RECORDING.load(std::sync::atomic::Ordering::SeqCst)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn apply_tray_mode(app: &tauri::AppHandle, active: bool, title: Option<String>) {
|
|
108
|
+
TRAY_RECORDING.store(active, std::sync::atomic::Ordering::SeqCst);
|
|
109
|
+
TRAY_LAST_WRITE_MS.store(now_ms(), std::sync::atomic::Ordering::SeqCst);
|
|
110
|
+
let Some(tray) = app.tray_by_id("main") else {
|
|
111
|
+
return;
|
|
112
|
+
};
|
|
113
|
+
let applied_icon_mode =
|
|
114
|
+
tray_icon_mode(TRAY_ICON_MODE.load(std::sync::atomic::Ordering::SeqCst));
|
|
115
|
+
if tray_icon_needs_write(applied_icon_mode, active) {
|
|
116
|
+
if let Ok(base) = tauri::image::Image::from_bytes(TRAY_PNG) {
|
|
117
|
+
let icon = if active {
|
|
118
|
+
stop_square_icon(base.width(), base.height())
|
|
119
|
+
} else {
|
|
120
|
+
base
|
|
121
|
+
};
|
|
122
|
+
// Recorded only once the status item accepts the image: a failed
|
|
123
|
+
// write must stay pending so the next call retries it, not look
|
|
124
|
+
// like the mode is already on screen.
|
|
125
|
+
match tray.set_icon(Some(icon)) {
|
|
126
|
+
Ok(()) => {
|
|
127
|
+
let _ = tray.set_icon_as_template(true);
|
|
128
|
+
TRAY_ICON_MODE.store(i8::from(active), std::sync::atomic::Ordering::SeqCst);
|
|
129
|
+
}
|
|
130
|
+
Err(err) => eprintln!("[clips-tray] status item icon update failed: {err}"),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// ALWAYS Some(...): tray-icon's macOS set_title is a silent no-op for
|
|
135
|
+
// None (its Some-only branch never clears the NSStatusItem text), so a
|
|
136
|
+
// reset must pass an empty string or the stale timer sticks forever.
|
|
137
|
+
#[cfg(target_os = "macos")]
|
|
138
|
+
let _ = tray.set_title(Some(if active {
|
|
139
|
+
title.unwrap_or_default()
|
|
140
|
+
} else {
|
|
141
|
+
String::new()
|
|
142
|
+
}));
|
|
143
|
+
#[cfg(not(target_os = "macos"))]
|
|
144
|
+
let _ = title;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Single writer for the status item's recording mode: the pill invokes this
|
|
148
|
+
/// with `active` mirroring its own visibility (capture live, not the
|
|
149
|
+
/// completion card) and a preformatted timer title. Everything the menu bar
|
|
150
|
+
/// shows about recording flows through here — no inference from recorder
|
|
151
|
+
/// events on the Rust side.
|
|
152
|
+
#[tauri::command]
|
|
153
|
+
pub async fn tray_recording_status(
|
|
154
|
+
app: tauri::AppHandle,
|
|
155
|
+
active: bool,
|
|
156
|
+
title: Option<String>,
|
|
157
|
+
) -> Result<(), String> {
|
|
158
|
+
apply_tray_mode(&app, active, title);
|
|
159
|
+
Ok(())
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Backstop for the one failure the pill cannot report: its window dying
|
|
163
|
+
/// while the tray still shows recording mode.
|
|
164
|
+
pub fn reset_tray_recording(app: &tauri::AppHandle) {
|
|
165
|
+
if tray_recording_active() {
|
|
166
|
+
apply_tray_mode(app, false, None);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
14
170
|
fn physical_tray_rect(rect: tauri::Rect) -> (i32, i32, i32, i32) {
|
|
15
171
|
let (x, y) = match rect.position {
|
|
16
172
|
tauri::Position::Physical(position) => (position.x, position.y),
|
|
@@ -306,10 +462,20 @@ pub fn build_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
306
462
|
active,
|
|
307
463
|
meeting_active
|
|
308
464
|
);
|
|
309
|
-
if
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
465
|
+
if tray_recording_active() && !meeting_active {
|
|
466
|
+
// The status item IS the stop button while recording — it
|
|
467
|
+
// shows a stop square and the elapsed time, so the click
|
|
468
|
+
// is an explicit Stop, not an implicit one. Route through
|
|
469
|
+
// the pill so its finishing hold and completion card run;
|
|
470
|
+
// if the pill window is somehow gone, stop directly.
|
|
471
|
+
if app.get_webview_window("toolbar").is_some() {
|
|
472
|
+
let _ = app.emit("clips:tray-stop-request", ());
|
|
473
|
+
} else {
|
|
474
|
+
let _ = app.emit("clips:recorder-stop", ());
|
|
475
|
+
}
|
|
476
|
+
} else if active && !meeting_active {
|
|
477
|
+
// Recording session exists but capture isn't live (setup,
|
|
478
|
+
// countdown, finishing) — restore the parked popover.
|
|
313
479
|
force_show_popover(app);
|
|
314
480
|
} else {
|
|
315
481
|
toggle_popover(app);
|
|
@@ -326,6 +492,21 @@ pub fn build_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
326
492
|
// resource table. `set_menu` is atomic — replacing the entire menu
|
|
327
493
|
// is the documented Tauri 2 way to update a tray (there's no
|
|
328
494
|
// partial-update API for items).
|
|
495
|
+
// Dead-man switch for recording mode: see TRAY_LAST_WRITE_MS.
|
|
496
|
+
let deadman_handle = app.handle().clone();
|
|
497
|
+
tauri::async_runtime::spawn(async move {
|
|
498
|
+
loop {
|
|
499
|
+
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
|
500
|
+
if tray_recording_active()
|
|
501
|
+
&& now_ms()
|
|
502
|
+
.saturating_sub(TRAY_LAST_WRITE_MS.load(std::sync::atomic::Ordering::SeqCst))
|
|
503
|
+
> 2_500
|
|
504
|
+
{
|
|
505
|
+
apply_tray_mode(&deadman_handle, false, None);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
329
510
|
let app_handle = app.handle().clone();
|
|
330
511
|
app.handle().listen("meetings:updated", move |event| {
|
|
331
512
|
#[derive(serde::Deserialize)]
|
|
@@ -352,3 +533,51 @@ pub fn build_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
352
533
|
|
|
353
534
|
Ok(())
|
|
354
535
|
}
|
|
536
|
+
|
|
537
|
+
#[cfg(test)]
|
|
538
|
+
mod tests {
|
|
539
|
+
use super::{tray_icon_mode, tray_icon_needs_write, TRAY_ICON_MODE_UNSET};
|
|
540
|
+
|
|
541
|
+
#[test]
|
|
542
|
+
fn writes_the_icon_on_the_first_call() {
|
|
543
|
+
assert!(tray_icon_needs_write(None, false));
|
|
544
|
+
assert!(tray_icon_needs_write(None, true));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
#[test]
|
|
548
|
+
fn skips_the_icon_when_the_mode_is_unchanged() {
|
|
549
|
+
// The pill re-invokes the status update four times a second to tick
|
|
550
|
+
// the timer; re-uploading the same image at that rate is the flash.
|
|
551
|
+
assert!(!tray_icon_needs_write(Some(true), true));
|
|
552
|
+
assert!(!tray_icon_needs_write(Some(false), false));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
#[test]
|
|
556
|
+
fn writes_the_icon_on_a_mode_flip_in_either_direction() {
|
|
557
|
+
assert!(tray_icon_needs_write(Some(false), true));
|
|
558
|
+
assert!(tray_icon_needs_write(Some(true), false));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
#[test]
|
|
562
|
+
fn ten_seconds_of_ticking_writes_the_icon_once() {
|
|
563
|
+
// The reported flash: the pill invokes the status update every 250ms
|
|
564
|
+
// to tick the timer, and every one of those used to re-upload the
|
|
565
|
+
// image. Ten seconds of recording is one icon write, not forty-one.
|
|
566
|
+
let mut applied: Option<bool> = None;
|
|
567
|
+
let mut writes = 0;
|
|
568
|
+
for _ in 0..41 {
|
|
569
|
+
if tray_icon_needs_write(applied, true) {
|
|
570
|
+
writes += 1;
|
|
571
|
+
applied = Some(true);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
assert_eq!(writes, 1);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
#[test]
|
|
578
|
+
fn reads_an_unset_mode_as_never_written() {
|
|
579
|
+
assert_eq!(tray_icon_mode(TRAY_ICON_MODE_UNSET), None);
|
|
580
|
+
assert_eq!(tray_icon_mode(0), Some(false));
|
|
581
|
+
assert_eq!(tray_icon_mode(1), Some(true));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
@@ -13,7 +13,6 @@ use crate::state::{
|
|
|
13
13
|
VoiceWakePopover,
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
const POPOVER_SHADOW_GUTTER_LOGICAL: f64 = 24.0;
|
|
17
16
|
static OAUTH_WINDOW_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
18
17
|
const POPOVER_DEFAULT_WIDTH_LOGICAL: f64 = 320.0;
|
|
19
18
|
const POPOVER_DEFAULT_HEIGHT_LOGICAL: f64 = 520.0;
|
|
@@ -110,13 +109,17 @@ pub fn set_capture_included(window: &WebviewWindow) {
|
|
|
110
109
|
}
|
|
111
110
|
|
|
112
111
|
pub fn build_popover_window(app: &mut tauri::App) -> Result<WebviewWindow, tauri::Error> {
|
|
113
|
-
let gutter = POPOVER_SHADOW_GUTTER_LOGICAL * 2.0;
|
|
114
112
|
let app_handle = app.handle().clone();
|
|
113
|
+
// The window is sized to the visible panel EXACTLY — elevation comes from
|
|
114
|
+
// the native NSWindow shadow (shadow(true) below), which macOS derives
|
|
115
|
+
// from the drawn rounded panel's alpha. A transparent CSS-shadow apron is
|
|
116
|
+
// never used here: its invisible margin eats clicks and reads as dead
|
|
117
|
+
// space around the UI.
|
|
115
118
|
WebviewWindowBuilder::new(app, "popover", WebviewUrl::App("index.html".into()))
|
|
116
119
|
.title("Clips")
|
|
117
120
|
.inner_size(
|
|
118
|
-
POPOVER_DEFAULT_WIDTH_LOGICAL
|
|
119
|
-
POPOVER_DEFAULT_HEIGHT_LOGICAL
|
|
121
|
+
POPOVER_DEFAULT_WIDTH_LOGICAL,
|
|
122
|
+
POPOVER_DEFAULT_HEIGHT_LOGICAL,
|
|
120
123
|
)
|
|
121
124
|
.position(2.0, 2.0)
|
|
122
125
|
.resizable(false)
|
|
@@ -127,7 +130,7 @@ pub fn build_popover_window(app: &mut tauri::App) -> Result<WebviewWindow, tauri
|
|
|
127
130
|
.skip_taskbar(true)
|
|
128
131
|
.visible(false)
|
|
129
132
|
.focused(true)
|
|
130
|
-
.shadow(
|
|
133
|
+
.shadow(true)
|
|
131
134
|
.accept_first_mouse(true)
|
|
132
135
|
// Tauri does not create a native child for window.open by default.
|
|
133
136
|
// Create it here with the opener's webview configuration so Google
|
|
@@ -4210,7 +4210,7 @@ export const editorChromeBridgeScript: string = `"use strict";
|
|
|
4210
4210
|
return null;
|
|
4211
4211
|
}
|
|
4212
4212
|
function stopNativeInteraction(e) {
|
|
4213
|
-
e.preventDefault();
|
|
4213
|
+
if (e.cancelable) e.preventDefault();
|
|
4214
4214
|
e.stopPropagation();
|
|
4215
4215
|
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
|
|
4216
4216
|
}
|
|
@@ -51,7 +51,7 @@ export const embeddedWheelBridgeScript: string = `"use strict";
|
|
|
51
51
|
return number;
|
|
52
52
|
}
|
|
53
53
|
function stopNativeInteraction(e) {
|
|
54
|
-
e.preventDefault();
|
|
54
|
+
if (e.cancelable) e.preventDefault();
|
|
55
55
|
e.stopPropagation();
|
|
56
56
|
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
|
|
57
57
|
}
|
|
@@ -72,7 +72,8 @@ export const embeddedWheelBridgeScript: string = `"use strict";
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
function onWheel(e) {
|
|
75
|
-
|
|
75
|
+
var zoomIntent = !!(e.ctrlKey || e.metaKey);
|
|
76
|
+
if (!wheelEnabled && !zoomIntent) return;
|
|
76
77
|
stopNativeInteraction(e);
|
|
77
78
|
postToParent({
|
|
78
79
|
type: "embedded-canvas-wheel",
|
|
@@ -9,14 +9,20 @@ export const zoomBridgeScript: string = `"use strict";
|
|
|
9
9
|
var target = document.documentElement || document.body || document;
|
|
10
10
|
function onWheel(e) {
|
|
11
11
|
if (!(e.ctrlKey || e.metaKey)) return;
|
|
12
|
-
e.preventDefault();
|
|
12
|
+
if (e.cancelable) e.preventDefault();
|
|
13
13
|
try {
|
|
14
14
|
window.parent.postMessage(
|
|
15
15
|
{
|
|
16
16
|
type: "pinch-zoom-wheel",
|
|
17
17
|
deltaY: e.deltaY,
|
|
18
18
|
clientX: e.clientX,
|
|
19
|
-
clientY: e.clientY
|
|
19
|
+
clientY: e.clientY,
|
|
20
|
+
// The parent classifies and scales from these three. Dropping the
|
|
21
|
+
// modifiers puts every pinch on the notch curve; dropping the mode
|
|
22
|
+
// makes a Firefox line tick read as 3px of travel.
|
|
23
|
+
deltaMode: e.deltaMode,
|
|
24
|
+
ctrlKey: !!e.ctrlKey,
|
|
25
|
+
metaKey: !!e.metaKey
|
|
20
26
|
},
|
|
21
27
|
"*"
|
|
22
28
|
);
|