Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,13 @@ objc2 = { version = "0.6", features = ["exception"] }
# Narration on the Mac: on-device speech recognition from the microphone. See `speech/macos.rs`.
# The same objc2 generation Tauri and wry already use.
block2 = "0.6"
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSError", "NSLocale", "NSOperation", "NSString"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSError", "NSLocale", "NSOperation", "NSString"] }
objc2-speech = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-avf-audio", "SFSpeechRecognizer", "SFSpeechRecognitionRequest", "SFSpeechRecognitionResult", "SFSpeechRecognitionTask", "SFTranscription"] }
objc2-avf-audio = { version = "0.3", default-features = false, features = ["std", "block2", "AVAudioEngine", "AVAudioNode", "AVAudioIONode", "AVAudioFormat", "AVAudioBuffer", "AVAudioTime", "AVAudioTypes"] }
objc2-av-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "AVCaptureDevice", "AVMediaFormat"] }
# The shell's own messages in an alert with OpenBot's icon. See `native_alert.rs`.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSAlert", "NSApplication", "NSButton", "NSControl", "NSImage", "NSResponder", "NSView", "NSWindow"] }
# The shell's own messages in an alert with OpenBot's icon. See `native_alert.rs`. `NSAppearance`:
# the system's light or dark before the main window exists (`first_paint::system_dark`).
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSAlert", "NSAppearance", "NSApplication", "NSButton", "NSControl", "NSImage", "NSResponder", "NSView", "NSWindow"] }

# Narration on Windows: the SAPI in-process recognizer. See `speech/sapi.rs`. The version Tauri uses.
[target.'cfg(windows)'.dependencies]
Expand Down
74 changes: 72 additions & 2 deletions desktop/src-tauri/src/first_paint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,20 @@ thread's messages while it waits (webview2-com's `wait_with_pump`), so that is w
to appear. An out-of-context WinEvent hook on the main thread hears the window being shown and runs
there, on the thread that installed it, from that same message loop ([`SetWinEventHook`][hook]).

On macOS the window showed a blank dark grey (the system's own window background, #1b1b1b in
Dark Mode) for up to half a second, then one to three white frames (WKWebView's default white),
before the setup page painted, and blank white in light. So on macOS too the window is built on the
setup screens' ground, in light or dark from the start: Tauri's `background_color` sets the
window's background ([`NSWindow.backgroundColor`][nswindow]), and wry turns off the webview's own
white (`drawsBackground`) and sets the colour behind its page ([`underPageBackgroundColor`][under],
wry 0.54.2 and later), so the ground shows through until the page paints. No cover is needed there:
the wait is a fraction of a second, not Windows' minute.

[background]: https://learn.microsoft.com/microsoft-edge/webview2/reference/win32/icorewebview2controller2#put_defaultbackgroundcolor
[tauri]: https://docs.rs/tauri/2/tauri/webview/struct.WebviewWindowBuilder.html#method.background_color
[hook]: https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-setwineventhook
[nswindow]: https://developer.apple.com/documentation/appkit/nswindow/backgroundcolor
[under]: https://developer.apple.com/documentation/webkit/wkwebview/underpagebackgroundcolor
*/

/// The cover's heading: the words the setup page uses while OpenBot gets ready.
Expand Down Expand Up @@ -122,7 +133,37 @@ pub fn system_dark() -> bool {
.is_ok_and(|light| light == 0)
}

#[cfg(not(windows))]
/**
Whether macOS is in Dark Mode, which the setup screens follow when OpenBot's own page has not said
yet: the application's effective appearance matched to Aqua or Dark Aqua, as tao reads it for the
window's theme ([`effectiveAppearance`][app], [`bestMatch(from:)`][match]). The main window is built
before it has a theme of its own to ask, so this asks the application. AppKit answers on the main
thread only, which is where `build_main_window` runs; anywhere else this says light.

[app]: https://developer.apple.com/documentation/appkit/nsapplication/effectiveappearance
[match]: https://developer.apple.com/documentation/appkit/nsappearance/bestmatch(from:)
*/
#[cfg(target_os = "macos")]
pub fn system_dark() -> bool {
objc2::MainThreadMarker::new().is_some_and(|main_thread| {
dark_appearance(
&objc2_app_kit::NSApplication::sharedApplication(main_thread).effectiveAppearance(),
)
})
}

/// Whether `appearance` is a dark one: closer to Dark Aqua than to Aqua.
#[cfg(target_os = "macos")]
fn dark_appearance(appearance: &objc2_app_kit::NSAppearance) -> bool {
use objc2_app_kit::{NSAppearanceNameAqua, NSAppearanceNameDarkAqua};
// SAFETY: AppKit's own appearance names, constants for the life of the process.
let (light, dark) = unsafe { (NSAppearanceNameAqua, NSAppearanceNameDarkAqua) };
appearance
.bestMatchFromAppearancesWithNames(&objc2_foundation::NSArray::from_slice(&[light, dark]))
.is_some_and(|name| &*name == dark)
}

#[cfg(not(any(windows, target_os = "macos")))]
pub fn system_dark() -> bool {
false
}
Expand All @@ -133,7 +174,8 @@ pub use native::{cover_now, page_painted, shown_after_start, uncover_unless_pain
#[cfg(not(windows))]
pub use elsewhere::{page_painted, shown_after_start, uncover_unless_painted, watch};

/// Off Windows nothing is covered: WebKit draws the window's own background until the page paints.
/// Off Windows nothing is covered. On macOS the window and its webview are built on the setup
/// screens' ground (`build_main_window`), so that ground is what shows until the page paints.
#[cfg(not(windows))]
mod elsewhere {
use super::Palette;
Expand Down Expand Up @@ -805,4 +847,32 @@ mod tests {
assert_eq!(sheet(1500, 1257, 1.5).card[..3], [318, 38, 1182]);
assert_eq!(sheet(600, 600, 1.0).card[0], 24);
}

/// A fresh Mac in Dark Mode opened setup on the light ground: before OpenBot's page has said
/// light or dark, the window follows the system's, and every dark appearance AppKit has reads
/// as dark.
#[cfg(target_os = "macos")]
#[test]
fn a_dark_mac_builds_the_window_on_the_dark_ground() {
use objc2_app_kit::{
NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua,
NSAppearanceNameVibrantDark, NSAppearanceNameVibrantLight,
};
let dark = |name: &objc2_app_kit::NSAppearanceName| {
dark_appearance(&NSAppearance::appearanceNamed(name).expect("AppKit has it"))
};
// SAFETY: AppKit's own appearance names, constants for the life of the process.
let (aqua, dark_aqua, vibrant_light, vibrant_dark) = unsafe {
(
NSAppearanceNameAqua,
NSAppearanceNameDarkAqua,
NSAppearanceNameVibrantLight,
NSAppearanceNameVibrantDark,
)
};
assert!(dark(dark_aqua));
assert!(dark(vibrant_dark));
assert!(!dark(aqua));
assert!(!dark(vibrant_light));
}
}
43 changes: 37 additions & 6 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6342,7 +6342,7 @@ fn setup_palette<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> first_paint::P
}

/// The setup screens' ground as a window colour.
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn ground_color(palette: first_paint::Palette) -> tauri::window::Color {
let (red, green, blue) = palette.ground;
tauri::window::Color(red, green, blue, 0xff)
Expand Down Expand Up @@ -6479,13 +6479,14 @@ fn build_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Res
None => builder,
};
/*
* Never blank white while WebView2 starts (`first_paint`): on Windows the window, and the
* webview's default background under the page, are the setup screens' ground, and from its
* first show until the setup page paints, "Getting OpenBot ready" covers it. The cover goes up
* while `build()` waits for WebView2, and is put back on top of WebView2's own window after.
* Never a blank grey or white window before the page paints (`first_paint`): on macOS and
* Windows the window, and the webview's background under the page, are the setup screens'
* ground, in light or dark, from the first frame. On Windows, from its first show until the
* setup page paints, "Getting OpenBot ready" covers it too. The cover goes up while `build()`
* waits for WebView2, and is put back on top of WebView2's own window after.
*/
let palette = setup_palette(app);
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "macos", target_os = "windows"))]
let builder = builder.background_color(ground_color(palette));
let watching = first_paint::watch(palette);
let building = std::time::Instant::now();
Expand Down Expand Up @@ -16266,6 +16267,36 @@ mod tests {
assert!(window.is_visible().unwrap_or(true));
}

/// The Mac pass on 806a8317f, recorded at 30 fps: before the setup page painted, the window
/// showed the system's dark grey for up to half a second, then WKWebView's white for a few
/// frames, and blank white in light. The main window is built on the setup screens' ground on
/// macOS as on Windows, the ground the title bar takes once the setup page loads, so the first
/// frame is already OpenBot's own colour in light and in dark.
#[test]
fn the_main_window_is_built_on_the_setup_ground_on_macos_and_windows() {
let main = include_str!("main.rs");
let body = &main[main.find("fn build_main_window").unwrap()..];
let body = &body[..body.find("\n}\n").unwrap()];
let set = body
.find("let builder = builder.background_color(ground_color(palette));")
.expect("the main window's builder takes the setup ground");
let gate = body[..set].trim_end().lines().last().unwrap().trim();
assert_eq!(
gate, r#"#[cfg(any(target_os = "macos", target_os = "windows"))]"#,
"the setup ground is set on the builder on macOS and Windows"
);
assert!(body.contains("let palette = setup_palette(app);"), "{body}");
assert!(set < body.find(".build()?").unwrap(), "{body}");
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
assert_eq!(ground_color(first_paint::palette(false)), SETUP_BACKGROUND);
assert_eq!(
ground_color(first_paint::palette(true)),
SETUP_BACKGROUND_DARK
);
}
}

/// The developer's app is trusted only at a loopback IP, so `localhost` is read as `127.0.0.1`.
#[test]
fn the_developers_app_at_localhost_is_shown_at_127_0_0_1() {
Expand Down
47 changes: 47 additions & 0 deletions desktop/src-tauri/src/recorder_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,53 @@ mod tests {
assert!(stop.contains("Stop-Process -Force"));
}

/// On a fresh Windows box the Installing page sat still on "Output folder" for about two
/// minutes: the stop started PowerShell and WMI to look for processes under a cli-bundle
/// folder that wasn't there yet. The stop runs only when this installation has that folder,
/// which it does on an update or a reinstall over a bundled CLI, and a running node.exe keeps.
#[test]
fn the_bundled_cli_stop_runs_only_when_there_is_a_cli_bundle_to_stop() {
let stop = nsis_macro("OPENBOT_STOP_BUNDLED_CLI");
let lines: Vec<&str> = stop
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with(';'))
.collect();
assert_eq!(
lines.first().copied(),
Some(r#"${If} ${FileExists} "$INSTDIR\cli-bundle\*.*""#),
"{stop}"
);
assert_eq!(lines.last().copied(), Some("${EndIf}"), "{stop}");
assert_eq!(stop.matches("${If}").count(), 1, "{stop}");
assert_eq!(stop.matches("${EndIf}").count(), 1, "{stop}");
// Everything it runs is inside the check: the folder handed on, PowerShell, its result.
for step in ["System::Call", "nsExec::Exec", "Pop $0"] {
let at = lines
.iter()
.position(|line| line.starts_with(step))
.unwrap_or_else(|| panic!("no {step} in {stop}"));
assert!(
at > 0 && at < lines.len() - 1,
"{step} runs outside the check"
);
}
// It is the same folder the script stops processes under.
assert!(stop.contains(r#"("OPENBOT_CLI_BUNDLE", "$INSTDIR\cli-bundle")"#));
// And it still runs on every install and uninstall, after the old version is removed.
let preinstall = nsis_macro("NSIS_HOOK_PREINSTALL");
assert!(
preinstall.find("OPENBOT_UNINSTALL_OLD_VERSION").unwrap()
< preinstall
.find("!insertmacro OPENBOT_STOP_BUNDLED_CLI")
.unwrap(),
"{preinstall}"
);
assert!(
nsis_macro("NSIS_HOOK_PREUNINSTALL").contains("!insertmacro OPENBOT_STOP_BUNDLED_CLI")
);
}

/// The text of `hooks.nsh`'s macro `name`, between `!macro name` and `!macroend`.
fn nsis_macro(name: &str) -> String {
include_str!("../windows/hooks.nsh")
Expand Down
13 changes: 10 additions & 3 deletions desktop/src-tauri/windows/hooks.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,17 @@
; for each one it stopped (30 seconds at most) before the installer goes on to replace the file.
; The folder reaches PowerShell as data, in OPENBOT_CLI_BUNDLE, never inside the script: a profile folder like
; C:\Users\O'Brien would end a quoted string there, and [ and ] are wildcards to -like.
; Only when this installation has a cli-bundle folder: nothing can run from a folder that isn't there, and Windows
; keeps a running program's file, so its folder, until it exits. On a fresh computer the folder isn't there yet, and
; starting PowerShell and WMI there to find nothing held the Installing page still on "Output folder" for about two
; minutes. An update or a reinstall over a bundled CLI has the folder, and stops it as before.
; IfFileExists with \*.* is how NSIS asks whether a folder exists (https://nsis.sourceforge.io/Docs/Chapter4.html).
!macro OPENBOT_STOP_BUNDLED_CLI
System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("OPENBOT_CLI_BUNDLE", "$INSTDIR\cli-bundle")'
nsExec::Exec `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$bundle = $$env:OPENBOT_CLI_BUNDLE.TrimEnd('\') + '\'; Get-CimInstance -ClassName Win32_Process -ErrorAction SilentlyContinue | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith($$bundle, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Get-Process -Id $$_.ProcessId -ErrorAction SilentlyContinue } | Stop-Process -Force -PassThru -ErrorAction SilentlyContinue | ForEach-Object { $$null = $$_.WaitForExit(30000) }"`
Pop $0
${If} ${FileExists} "$INSTDIR\cli-bundle\*.*"
System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("OPENBOT_CLI_BUNDLE", "$INSTDIR\cli-bundle")'
nsExec::Exec `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$bundle = $$env:OPENBOT_CLI_BUNDLE.TrimEnd('\') + '\'; Get-CimInstance -ClassName Win32_Process -ErrorAction SilentlyContinue | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith($$bundle, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Get-Process -Id $$_.ProcessId -ErrorAction SilentlyContinue } | Stop-Process -Force -PassThru -ErrorAction SilentlyContinue | ForEach-Object { $$null = $$_.WaitForExit(30000) }"`
Pop $0
${EndIf}
!macroend

; Installing over an installed OpenBot with "Uninstall before installing" uninstalls the old version
Expand Down
Loading