diff --git a/rivet/installer/windows/build.ps1 b/rivet/installer/windows/build.ps1 index 380fd32..018fd1c 100644 --- a/rivet/installer/windows/build.ps1 +++ b/rivet/installer/windows/build.ps1 @@ -70,6 +70,13 @@ Copy-Item (Join-Path $installer "setup-ui\assets\logo.ico") (Join-Path $stage "r Copy-Item (Join-Path $repository "LICENSE") (Join-Path $stage "LICENSE") Copy-Item (Join-Path $rivet "runtime\windows\fonts\OFL-*.txt") $stage +# A binary built for the feature tests starts browsers with sync and sign-in off. It never ships. +. (Join-Path $installer "test-flags.ps1") +$tainted = @(Get-TestFlagBinaries @(Get-ChildItem $stage -Recurse -File | Where-Object { $_.Extension -in '.exe', '.dll' } | ForEach-Object FullName)) +if ($tainted.Count -gt 0) { + throw "built with the feature tests' browser switches, which turn sync and sign-in off: $($tainted -join ', '). Build again without them." +} + # Authenticode: with RIVET_CODE_SIGN_CERT (a certificate thumbprint in the build account's store), # every program file, the package and setup.exe are signed, so Windows can tell a changed file. # RIVET_TIMESTAMP_URL is the RFC 3161 timestamp service of the certificate's issuer. The timestamp diff --git a/rivet/installer/windows/test-flags.ps1 b/rivet/installer/windows/test-flags.ps1 new file mode 100644 index 0000000..7ae5df1 --- /dev/null +++ b/rivet/installer/windows/test-flags.ps1 @@ -0,0 +1,12 @@ +# The feature tests build Rivet with browser switches that turn sync and sign-in off. A package made +# from such a build would start a person's browser without sync, so build.ps1 refuses it. These are +# the switches only that test build adds; the browser start a person gets never carries them. +$script:TestOnlySwitches = @('--disable-sync', 'msImplicitSignin,msEdgeSignIn,CalculateNativeWinOcclusion') + +# The files among `$Files` that carry a test-only browser switch. +function Get-TestFlagBinaries([string[]]$Files) { + foreach ($file in $Files) { + $text = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes($file)) + if (@($script:TestOnlySwitches | Where-Object { $text.Contains($_) }).Count -gt 0) { $file } + } +} diff --git a/rivet/platform/windows/src/bin/chrome-builds.rs b/rivet/platform/windows/src/bin/chrome-builds.rs index d1ca5ac..c278196 100644 --- a/rivet/platform/windows/src/bin/chrome-builds.rs +++ b/rivet/platform/windows/src/bin/chrome-builds.rs @@ -160,7 +160,12 @@ fn port( let wanted = row_port::wanted_names(&named); drop(old); let mut new = std::collections::HashMap::>::new(); + // `views::View::ConvertPointToScreen` is found by its own name, for a prior row without it. + let mut screen_origin = Vec::new(); symbols::publics(new_pdb, &mut |address, name| { + if row_port::is_screen_origin_name(name) { + screen_origin.push(address); + } let name = row_port::stable_name(name); if wanted.contains(name) { new.entry(name.to_owned()).or_default().push(address); @@ -178,7 +183,7 @@ fn port( }, ) .collect(); - let (row, mut report) = row_port::port_row( + let (mut row, mut report) = row_port::port_row( &prior, &named, &NewBuild { @@ -188,6 +193,7 @@ fn port( private: &private, }, ); + row_port::seed_screen_origin(&mut row, &screen_origin, &pe, &mut report); report.review.extend(row_port::hand_only_review(&prior)); let text = |value: &dyn erased::Json| value.pretty(); std::fs::write(row_path, text(&row)).map_err(|error| error.to_string())?; diff --git a/rivet/platform/windows/src/row_port.rs b/rivet/platform/windows/src/row_port.rs index 383e477..33b0dbd 100644 --- a/rivet/platform/windows/src/row_port.rs +++ b/rivet/platform/windows/src/row_port.rs @@ -62,6 +62,8 @@ pub struct PortReport { pub derived: Vec, /// Values that are not addresses and have no recipe, so they were carried over. pub carried: Vec, + /// Optional parts the new build could not supply. The runtime does without them. + pub left_out: Vec, } fn is_address(value: u64) -> bool { @@ -1348,6 +1350,61 @@ fn check_frame_slot(frame: &mut Value, pe: &PeFile, report: &mut PortReport) { } } +/// The start of the public name of `views::View::ConvertPointToScreen(const View*, gfx::Point*)`: +/// a static function returning nothing. The other overload returns the point and is named `SA?AV`. +pub const SCREEN_ORIGIN_NAME: &str = "?ConvertPointToScreen@View@views@@SAX"; + +/// How many bytes of that function's start the runtime compares before it ever calls it. +const SCREEN_ORIGIN_BYTES: usize = 12; + +/// Whether a public name is the one `screen_origin` is found by. +pub fn is_screen_origin_name(name: &str) -> bool { + stable_name(name).starts_with(SCREEN_ORIGIN_NAME) +} + +/// The first whole instructions of a function, at least `SCREEN_ORIGIN_BYTES` long. +fn function_start(pe: &PeFile, address: u32) -> Option> { + if !pe.is_code(address) { + return None; + } + let window = pe.read(address, 32)?; + let (ends, _) = x64::boundaries(window, SCREEN_ORIGIN_BYTES); + let end = *ends.iter().find(|end| **end >= SCREEN_ORIGIN_BYTES)?; + Some(window[..end].to_vec()) +} + +/// Adds `screen_origin`, `[address, first bytes]` of `views::View::ConvertPointToScreen`, to a row +/// whose prior row had none, by the function's public name in the new build. A prior row that had +/// it was ported with every other address. A build with no single place for the name goes without +/// it, and the runtime keeps placing bubbles from the window's monitor, so the row is not stopped. +pub fn seed_screen_origin(row: &mut Value, places: &[u32], pe: &PeFile, report: &mut PortReport) { + if row["build"]["screen_origin"].is_array() { + return; + } + let mut places = places.to_vec(); + places.sort_unstable(); + places.dedup(); + let found = match places.as_slice() { + [only] => function_start(pe, *only).map(|bytes| (*only, bytes)), + _ => None, + }; + match found { + Some((address, bytes)) => { + row["build"]["screen_origin"] = + json!([write_address(u64::from(address)), hex_text(&bytes)]); + report.derived.push(format!( + "build.screen_origin: {SCREEN_ORIGIN_NAME} at {address:#x}" + )); + } + None => { + row["build"]["screen_origin"] = Value::Null; + report.left_out.push(format!( + "build.screen_origin: no single readable function named {SCREEN_ORIGIN_NAME}: {places:x?}" + )); + } + } +} + /// The review items for the parts of the prior row that only a person writes today. A row made /// without them opens Rivet's frame with those features off, so it goes to a person instead of /// the table. The runtime names them in the prior row's `hand_only` list. @@ -1489,6 +1546,44 @@ mod tests { .collect() } + #[test] + fn nfr3_screen_origin_is_found_by_its_public_name_in_the_new_build() { + // push rsi; push rdi; sub rsp, 0x28; mov rsi, rdx; mov rdi, rcx; call. + let start = hex("56574883ec284889d64889cfe800000000").unwrap(); + let bytes = module(&[(0x101400, start.as_slice())]); + let pe = PeFile::parse(&bytes).unwrap(); + let mut row = prior(); + let mut report = PortReport::default(); + seed_screen_origin(&mut row, &[0x101400, 0x101400], &pe, &mut report); + assert_eq!( + row["build"]["screen_origin"], + json!(["0x101400", "56574883ec284889d64889cf"]) + ); + assert!(report.review.is_empty() && report.left_out.is_empty()); + // A row that already has it keeps the place the port gave it. + seed_screen_origin(&mut row, &[0x101500], &pe, &mut report); + assert_eq!(row["build"]["screen_origin"][0], "0x101400"); + // No name, or two places for it, leaves the part out without stopping the row. + for places in [&[][..], &[0x101400, 0x101500][..]] { + let mut row = prior(); + let mut report = PortReport::default(); + seed_screen_origin(&mut row, places, &pe, &mut report); + assert!(row["build"]["screen_origin"].is_null()); + assert_eq!(report.left_out.len(), 1); + assert!(report.review.is_empty()); + } + // Only the in-place overload, which returns nothing, is the one. + assert!(is_screen_origin_name( + "?ConvertPointToScreen@View@views@@SAXPEBV12@PEAVPoint@gfx@@@Z" + )); + assert!(!is_screen_origin_name( + "?ConvertPointToScreen@View@views@@SA?AVPoint@gfx@@PEBV12@AEBV34@@Z" + )); + assert!(!is_screen_origin_name( + "?ConvertRectToScreen@View@views@@SAXPEBV12@PEAVRect@gfx@@@Z" + )); + } + #[test] fn nfr3_a_new_row_takes_addresses_by_name_and_bytes_from_the_new_module() { let code = new_code(); diff --git a/rivet/platform/windows/src/row_watch.rs b/rivet/platform/windows/src/row_watch.rs index e17a9b1..c960533 100644 --- a/rivet/platform/windows/src/row_watch.rs +++ b/rivet/platform/windows/src/row_watch.rs @@ -216,6 +216,7 @@ pub const FEATURES: &[&str] = &[ "split-grid-3", "split-grid-4", "bar-layout", + "bar-folded", "mini-pop-out", "mini-move", "mini-return", diff --git a/rivet/runtime/windows/rows/table.json b/rivet/runtime/windows/rows/table.json index c9f750f..41d56c3 100644 --- a/rivet/runtime/windows/rows/table.json +++ b/rivet/runtime/windows/rows/table.json @@ -1,5 +1,663 @@ { "rows": [ + { + "build": { + "app_window": { + "add_child": "0x549da90", + "add_child_prologue": "4157415641554154565755534881ec88", + "bounds_changed_slot": "0x4b", + "browser_context": "0x55756e0", + "browser_context_offset": null, + "browser_context_prologue": "488b8190020000c3cccccccccccccccc", + "browser_type": "0x10", + "destructor_slot": "0x9", + "detach": "0x9fadbb0", + "detach_prologue": "5657534883ec404889d6488b057f54a3", + "feature_impl": "0x1e54e0", + "feature_impl_prologue": "564883ec204889ce8b411085c0", + "focus_behavior": "0xf90710", + "focus_behavior_prologue": "56574883ec3889d74889ce488b051e29", + "insert": "0x9fad6a0", + "insert_prologue": "4157415641554154565755534883ec68", + "operator_new": "0x59fc230", + "operator_new_prologue": "40534883ec20488bd9e8a28520fe4885", + "request_focus": "0x27f0b60", + "request_focus_prologue": "56574883ec284889ce488b01488b4050", + "request_focus_slot": null, + "set_bounds": "0x49703b0", + "set_bounds_prologue": "4157415641554154565755534881ece8", + "set_contents": "0x1148800", + "set_contents_prologue": "5657534883ec304889d74889cee83e6b", + "view_children": "0x1a0", + "view_parent": "0x198", + "web_view_ctor": "0xba8700", + "web_view_ctor_prologue": "5657534883ec304889ce488b052fa9e3", + "web_view_size": "0x528", + "window_features": null + }, + "browser": "Google Chrome", + "bubble_anchor": [ + "0xaa72dc0", + "41574156415541545657534881ecd0000000" + ], + "layout": { + "browser_view_view_vtable": "0xfce91b0", + "browser_window_in_view": "0x1f0", + "caption_container_offset": "0x3c8", + "caption_ink_ids": [ + 1644, + 1645, + 1646 + ], + "caption_paint": "0x2162030", + "caption_paint_prologue": "e90b000000cccccc", + "caption_paint_slot": "0x72", + "caption_size": "0x2f78d50", + "caption_size_slot": "0x4a", + "caption_vtable": "0xfd6f810", + "corner_paint": "0xa27b3e0", + "corner_paint_prologue": "4157415641554154", + "corner_vtable": "0xfd38210", + "draw_color": "0x349cd40", + "draw_color_prologue": "4883ec38488b", + "frame_browser_window_offset": "0x340", + "frame_ids": [ + 316, + 317, + 320, + 321, + 324 + ], + "frame_vtable": "0xfce1070", + "get_color_provider": "0x47a0870", + "get_color_provider_prologue": "56574883ec78", + "layout": "0x198b250", + "layout_immediately": "0x49713c0", + "layout_immediately_prologue": "415741564155415456575553", + "layout_prologue": "415656575553", + "layout_slot": "0x17", + "on_paint": "0x14ebbc0", + "on_paint_prologue": "4157415641554154", + "on_paint_slot": "0x59", + "provider_verified": true, + "set_bounds_rect": "0x49703b0", + "set_bounds_rect_prologue": "4157415641554154", + "set_layer": "0xf86250", + "set_layer_prologue": "56574883ec28", + "set_masks": "0x2fa9080", + "set_masks_prologue": "488b8970010000", + "set_opaque": "0xba1c00", + "set_opaque_prologue": "564883ec20", + "set_radius": "0x1c46820", + "set_radius_prologue": "5657534883ec30", + "stack_at_top": "0xaa90920", + "stack_at_top_prologue": "415741565657", + "top_inset": "0x198b7f0", + "top_inset_adjust": 0, + "top_inset_slot": "0x7f", + "view_bounds_offset": "0x1c8" + }, + "module": "chrome.dll", + "navigation": { + "active_web_contents": "0xbaa3d30", + "browser_view_for_window": "0xbaa3800", + "content_window_for_hwnd": "0x2f9bf10", + "gurl_drop": "0x3bc5800", + "gurl_empty": "0x54bbb30", + "gurl_from_text": "0x4765340", + "gurl_wide": false, + "open_url": "0x81c7b80", + "open_url_params": "0x6ff88b0", + "open_url_params_drop": "0x2144a80", + "signatures": [ + [ + "0x2f9bf10", + "564883ec20e8" + ], + [ + "0xbaa3800", + "4883ec28e8" + ], + [ + "0x4765340", + "4157415641554154" + ], + [ + "0x54bbb30", + "4889c80f57c0" + ], + [ + "0x3bc5800", + "56574883ec28" + ], + [ + "0x6ff88b0", + "415656575553" + ], + [ + "0x2144a80", + "4156565753" + ], + [ + "0x81c7b80", + "4157415641545657" + ], + [ + "0xbaa3d30", + "488b8160050000" + ] + ], + "supported": true + }, + "omnibox": { + "allocate": "0x59fc230", + "app_layout_proposed": "0xa2752b0", + "app_layout_slot": "0x10", + "app_layout_vtable": "0xfc1d358", + "browser_context": "0x55756e0", + "classifier_for_profile": "0x2f82bf0", + "classify": "0x63cd10", + "controller_new": "0x1b5a8e0", + "controller_published_result": "0x648", + "controller_size": "0x940", + "default_providers": "0x1b54f10", + "delete": "0x59fc3e0", + "input_drop": "0x188b4b0", + "input_new": "0x25380f0", + "input_omit_asynchronous": "0x1b3", + "input_prevent_inline": "0x1b0", + "input_size": "0x478", + "match_contents": "0x2a0", + "match_description": "0x2d0", + "match_destination": "0x68", + "match_drop": "0x1b78bd0", + "match_new": "0x1b788f0", + "match_size": "0x6c0", + "profile_from_context": "0x4bc8300", + "provider_client_new": "0x8cde80", + "provider_client_size": "0x78", + "scheme_classifier_new": "0x2f82eb0", + "scheme_classifier_size": "0x10", + "security_level": "0x56bd450", + "security_state_drop": "0x56bd630", + "show_page_info": "0x9d66470", + "signatures": [ + [ + "0x4bc8300", + "4889c8c3" + ], + [ + "0x55756e0", + "488b8190020000c3" + ], + [ + "0x2f82bf0", + "564883ec204889ce" + ], + [ + "0x63cd10", + "4157415641554154565755534881ec78" + ], + [ + "0x1b788f0", + "56574883ec480f297c2430" + ], + [ + "0x1b78bd0", + "41565657534883ec384889ce" + ], + [ + "0x47beb40", + "4157415641554154565755534881ecc8" + ], + [ + "0x56bd450", + "41565657534883ec48" + ], + [ + "0x56bd630", + "56574883ec284889ce" + ], + [ + "0x59fc3e0", + "e9bb5a25fe" + ], + [ + "0x9d66470", + "41574156415541545657534883ec60" + ], + [ + "0x8cde80", + "4531c0e9" + ], + [ + "0x2f82eb0", + "56574883ec284889d7" + ], + [ + "0x1b54f10", + "4157415641554154565755534883ec28" + ], + [ + "0x1b5a8e0", + "4157415641554154565755534881ec98" + ], + [ + "0x25380f0", + "564883ec404489c04889ce" + ], + [ + "0x188b4b0", + "41565657534883ec284889ce" + ], + [ + "0x1b2eea0", + "4157415641554154565755534881ec08" + ], + [ + "0x59fc230", + "40534883ec20488bd9" + ] + ], + "start": "0x1b2eea0", + "visible_security_state": "0x47beb40" + }, + "page_tools": { + "browser_context": [ + "0x55756e0", + "488b8190020000c3" + ], + "profile_from_context": [ + "0x4bc8300", + "4889c8c3" + ], + "show_action": [ + "0x5b3c2b0", + "4889c8c7010000000048c7410800000000c3" + ], + "toggle_devtools": [ + "0xba51500", + "41574156415541545657534881ecb0000000" + ], + "update_target_url": [ + "0x2f475d0", + "4157415641554154565755534881ec28010000" + ] + }, + "screen_origin": [ + "0x9397b0", + "41565657534881ece8000000" + ], + "side_panel": { + "browser_offset": "0x560", + "browser_view_view_vtable": "0xfce91b0", + "color_source": "0x98", + "contents_container": "0xb8", + "contents_outline": "0x58", + "coordinator_browser": null, + "coordinator_vtable": "0xfcc6688", + "entry_count": 24, + "entry_id_to_string": "0x1e4db80", + "entry_id_to_string_prologue": "564883ec304889ce", + "entry_key_size": null, + "entry_showing": "0x3476990", + "entry_showing_prologue": "488b491080793001", + "event_handler_for_point": "0x1967b60", + "extension_entry": 23, + "extensions_by_toggle": null, + "features_coordinator": "0x330", + "features_offset": "0xd0", + "generate_colors_css": "0x55af050", + "generate_colors_css_prologue": "415741564155415456575553", + "get_color_provider": "0x1f3b9f0", + "get_color_provider_prologue": "488b8998000000488b01488b4008", + "hidden_entries": [], + "host_view": "0x38", + "layout_immediately": "0x49713c0", + "layout_immediately_prologue": "415741564155415456575553", + "layout_vtable": "0xfc1d598", + "old_layout": null, + "page_area": "0x50", + "proposed": "0x14131a0", + "proposed_slot": "0x10", + "registry_end": null, + "registry_entries": null, + "registry_from": "0x2f0f660", + "registry_from_prologue": "4881c1a8000000", + "registry_in_coordinator": null, + "show": "0x9705f80", + "show_prologue": "41565657534883ec58", + "side_panel": "0xc0", + "side_panel_animation": "0xc8", + "side_panel_vtable": "0xfcd4ef0", + "source_in_widget": "0x48", + "supported": true, + "toggle": "0x970e610", + "toggle_prologue": "415741565657534883ec60", + "toolbar": "0x60", + "vertical_tabs": "0x80" + }, + "split": { + "active_member": "0x3c8", + "add_to_new_split": "0x9fb90a0", + "allocate": "0x59fc230", + "calculate_layout": "0x238a600", + "calculate_layout_prologue": "415741564155415456575553", + "container_count_member": "0x358", + "container_delegate_offset": "0x320", + "container_layout": "0x2109680", + "containers_member": "0x350", + "flex_layout": "0x51c9200", + "flex_layout_host": "0x38", + "get_split_for_tab": "0x2f5ba90", + "layout_delegate_offset": "0x328", + "location_bar_vtable": "0xfcf3e30", + "mini_toolbar_member": "0x440", + "outline_member": "0x448", + "remove_split": "0x9fbb220", + "schedule_paint": "0x47a0a80", + "schedule_paint_prologue": "564883ec4048", + "set_border_slot": "0x1e", + "set_rounded_corners": "0x3483a50", + "set_rounded_corners_changed": "0x3483a90", + "set_rounded_corners_parity": "0x3483a5d", + "set_rounded_corners_prologue": "f30f10020f2e81840400007533", + "set_visible_slot": "0x12", + "signatures": [ + [ + "0x9fb90a0", + "415741564155415456575553" + ], + [ + "0x9fbb220", + "41565657534883ec58" + ], + [ + "0x2f5ba90", + "564883ec204585c078" + ], + [ + "0x59fc230", + "40534883ec20488bd9" + ] + ], + "split_adds_active": false, + "split_insets_member": "0x404", + "split_vector_ends_by_pointer": false, + "supported": true, + "update_border": "0x20f3330", + "update_border_prologue": "41565657534883ec484889ce", + "view_size": "0x1d0", + "web_contents_focused": "0xa27f190", + "web_contents_focused_prologue": "56574883ec28488b8198030000" + }, + "tabs": { + "activate_tab": "0x9fb1a50", + "active_web_contents": "0xbaa3d30", + "add_to_new_group": "0x9fb9d50", + "add_to_new_group_prologue": "415741564154565755534881", + "allocate": "0x59fc230", + "allocate_prologue": "40534883ec20488bd9", + "browser_in_view": "0x560", + "browser_view_for_window": "0xbaa3800", + "change_group": "0x9fbc1f0", + "close_tab": "0x9fb4e30", + "content_window_for_hwnd": "0x2f9bf10", + "get_group": "0x2f5ba40", + "get_tab_model": "0x4bf5820", + "groups": true, + "has_group": "0x9fa49b0", + "last_committed_url": "0x55756f0", + "lookup_group": "0x9fa4a10", + "new_tab": "0x9e6b490", + "new_tab_address": null, + "new_tab_by_open_url": null, + "new_tab_delay_ms": 0, + "new_tab_prologue": "", + "new_tab_takes_address": false, + "signatures": [ + [ + "0x2f5ba40", + "564883ec204889d64585c078" + ], + [ + "0x9fa49b0", + "56574883ec384989d0488b05" + ], + [ + "0x9fa4a10", + "41565657534883ec384889d6" + ], + [ + "0x9fbc1f0", + "415741564155415456575553" + ], + [ + "0x2f9bf10", + "564883ec20e8" + ], + [ + "0xbaa3800", + "4883ec28e8" + ], + [ + "0xbaa3d30", + "488b8160050000" + ], + [ + "0x4bf5820", + "488b4130c3" + ], + [ + "0x3481560", + "488b018b4020c3" + ], + [ + "0x458950", + "4883ec2885d2" + ], + [ + "0x9fb1a50", + "56574883ec38" + ], + [ + "0x9fb4e30", + "56574883ec28" + ], + [ + "0xa49700", + "4883ec28488b81" + ], + [ + "0x9e6b490", + "4157415641545657" + ], + [ + "0x55756f0", + "486381d4020000" + ], + [ + "0xac14c0", + "488b81b0020000" + ] + ], + "supported": true, + "tab_at": "0x458950", + "tab_collection": "0x0", + "tab_contents": "0x0", + "tab_count": "0x3481560", + "tab_object": "0x7fff0000", + "title": "0xa49700", + "visible_url": "0xac14c0" + }, + "toolbar": { + "content_window_for_hwnd": "0x2f9bf10", + "dev": { + "colours": [ + [ + 316, + 4280953905 + ], + [ + 1590, + 4280953905 + ], + [ + 796, + 0 + ], + [ + 797, + 402653183 + ], + [ + 1595, + 4291086286 + ], + [ + 1596, + 4291086286 + ], + [ + 1597, + 4285559675 + ], + [ + 1600, + 4294967295 + ], + [ + 1598, + 4294967295 + ], + [ + 1599, + 4285559675 + ], + [ + 1603, + 4279703579 + ], + [ + 1611, + 402653183 + ], + [ + 1612, + 620756991 + ], + [ + 1644, + 4294967295 + ], + [ + 1645, + 4294967295 + ], + [ + 1646, + 4294967295 + ], + [ + 1005, + 4291086286 + ], + [ + 1006, + 4291086286 + ], + [ + 1007, + 4294967295 + ], + [ + 1048, + 4291086286 + ], + [ + 1049, + 4291086286 + ], + [ + 1052, + 4291086286 + ], + [ + 1055, + 4294967295 + ], + [ + 1056, + 4291086286 + ] + ], + "console": 40005, + "font_list_new": "0x2fc4af0", + "font_list_new_prologue": "5657534883ec204889d34889ce", + "font_new": "0x2fc4e00", + "font_new_prologue": "564883ec204889ce4889d14489c2", + "inspect": 40023 + }, + "get_color": "0x52b0790", + "get_color_bytes": "415741564154565755534883ec2089d7", + "get_widget_slot": "0xb", + "skip": "", + "toolbar_layout": "0x1309170", + "widget_for_window": "0x2f9df30" + }, + "version": [ + 156, + 0, + 8078, + 81 + ], + "vertical": { + "browser_offset": "0x1b8", + "browser_view_for_browser": "0x2119b0", + "browser_view_view_vtable": "0xfce91b0", + "destructor": "0x2890200", + "destructor_prologue": "56574883ec2889d74889cee8", + "detour_strip": false, + "hwnd_for_view": "0x348d8a0", + "invalidate_layout": "0x44a1140", + "invalidate_layout_prologue": "415741564155415456575553", + "mode_offset": "0x1e8", + "notify_mode_changed": "0x9f8eb70", + "region_minimum_prologue": "56574883ec284889d64889cf48c70200000000", + "region_minimum_size": "0xbaa13b0", + "region_preferred_size": "0xbaa1440", + "region_preferred_size_slot": "0xfd84f40", + "set_bounds_rect": "0x7fff0000", + "set_bounds_rect_prologue": "00", + "should_display": "0x3481180", + "should_draw_tab_strip": "0x500f570", + "should_draw_tab_strip_prologue": "565753", + "slot": "0xfc1d120", + "supported": true, + "toolbar_in_view": "0x0", + "toolbar_leading": 0, + "update_collapse_action": "0x208f7d0", + "width_offset": "0x1cc", + "window_feature": "0x7fff0000", + "window_feature_callers": [ + "0x0", + "0x0" + ], + "window_feature_prologue": "00", + "window_feature_tab_strip": 0 + } + }, + "frame": { + "browser": "Google Chrome", + "client_view_bounds": 55062832, + "client_view_slot": 103, + "frame_view_vtable": 265162864, + "hwnd_for_view": 55105696, + "hwnd_for_view_prologue": "564883ec204889ce488b01488b4058", + "module": "chrome.dll", + "version": "156.0.8078.81" + } + }, { "build": { "app_window": { @@ -1952,6 +2610,7 @@ "Brave 157.1.99.55": "22DC58117C04ED6E4C4C44205044422E1", "Google Chrome 156.0.8078.74": "713E6CA96EBDD5814C4C44205044422E1", "Google Chrome 156.0.8078.80": "B2C7C3B72F30D3ED4C4C44205044422E1", + "Google Chrome 156.0.8078.81": "4A4A604319CF7D794C4C44205044422E1", "Google Chrome 156.0.8078.82": "A28166C00E1AB5384C4C44205044422E1", "Google Chrome 157.0.8103.38": "B445A620A73B6EA94C4C44205044422E1", "Google Chrome 157.0.8103.39": "037147ED9738B0F84C4C44205044422E1", diff --git a/rivet/runtime/windows/src/appbar.rs b/rivet/runtime/windows/src/appbar.rs index aecd95e..961bf07 100644 --- a/rivet/runtime/windows/src/appbar.rs +++ b/rivet/runtime/windows/src/appbar.rs @@ -1373,6 +1373,17 @@ impl Appbar { self.address.SetContent(&surface)?; } self.address_painted = Some((bar.clone(), dpi)); + if crate::trace::tracing() { + crate::trace::trace_changed( + "bar painted", + &painted_line( + [bar.x, bar.y, bar.width, bar.height], + bar.pane, + bar.reserve, + scale, + ), + ); + } self.device.Commit() } @@ -2760,10 +2771,58 @@ pub(crate) fn make_fonts_known_to_gdi() { }); } +/// Where the top bar's controls were painted, in client pixels, as `name left right` each, so a +/// test can click them where they show. `rect` is the bar's place and size in client pixels. +pub(crate) fn painted_line(rect: [f32; 4], pane: bool, reserve: f32, scale: f32) -> String { + use crate::features::address_bar::{caption_buttons, parts_reserving}; + let [x, y, width, height] = rect; + let w = width / scale; + let px = |at: f32| (x + at * scale).round() as i32; + let mut parts: Vec = parts_reserving(w, pane, reserve) + .into_iter() + .map(|(part, at, pw)| format!("{part:?} {} {}", px(at), px(at + pw))) + .collect(); + if !pane { + for (button, at, bw) in caption_buttons(w) { + parts.push(format!("{button:?} {} {}", px(at), px(at + bw))); + } + } + format!( + "address bar: painted at {} {} {}x{} px: {}", + x.round() as i32, + y.round() as i32, + width.round() as i32, + height.round() as i32, + parts.join(", ") + ) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn fr12_the_painted_bar_names_each_controls_place_in_client_pixels() { + let line = painted_line([20.0, 10.0, 2000.0, 88.0], false, 0.0, 2.0); + assert!( + line.starts_with("address bar: painted at 20 10 2000x88 px: Back "), + "{line}" + ); + // The parts the bar hit-tests are the ones named, at the same places. + for (part, at, pw) in crate::features::address_bar::parts_reserving(1000.0, false, 0.0) { + let named = format!( + "{part:?} {} {}", + (20.0 + at * 2.0).round() as i32, + (20.0 + (at + pw) * 2.0).round() as i32 + ); + assert!(line.contains(&named), "{named} in {line}"); + } + assert!( + line.ends_with(&format!("Close {} 2020", 20 + 2 * (1000 - 46))), + "{line}" + ); + } + #[test] fn fr11_a_page_in_developer_mode_has_the_dark_row_of_the_study() { // `.toolbar.dev` of rivet-shell-demo.html: ground #2a2c31, lower edge #17181b, icons #c4c7ce. diff --git a/rivet/runtime/windows/src/browser/chromium.rs b/rivet/runtime/windows/src/browser/chromium.rs index 59ffa51..f5131c5 100644 --- a/rivet/runtime/windows/src/browser/chromium.rs +++ b/rivet/runtime/windows/src/browser/chromium.rs @@ -104,6 +104,10 @@ impl TabMoves for Chromium { fn read_word(address: usize) -> Option { use windows_sys::Win32::System::Diagnostics::Debug::ReadProcessMemory; use windows_sys::Win32::System::Threading::GetCurrentProcess; + // A word known readable is read in place, without a system call. + if let Some(value) = crate::chromium::memory::read_word(address) { + return Some(value); + } let mut value = 0usize; let mut done = 0usize; // ReadProcessMemory fails, instead of faulting, on an address that is not mapped. diff --git a/rivet/runtime/windows/src/builds/brave.rs b/rivet/runtime/windows/src/builds/brave.rs index 3d92c89..eeb8057 100644 --- a/rivet/runtime/windows/src/builds/brave.rs +++ b/rivet/runtime/windows/src/builds/brave.rs @@ -43,6 +43,7 @@ pub static BRAVE: Build = Build { omnibox: None, page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: None, browser: "Brave", module: "chrome.dll", @@ -309,6 +310,7 @@ pub static BRAVE_63: Build = Build { omnibox: Some(super::omnibox_rows::OMNIBOX_BRAVE_63), page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: Some(AppWindow { feature_impl: Some(0x00107B60), feature_impl_prologue: &[ @@ -710,6 +712,7 @@ pub static BRAVE_157_55: Build = Build { omnibox: Some(super::omnibox_rows::OMNIBOX_BRAVE_157_55), page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: Some(AppWindow { feature_impl: Some(0x0017C0B0), feature_impl_prologue: &[ diff --git a/rivet/runtime/windows/src/builds/chrome.rs b/rivet/runtime/windows/src/builds/chrome.rs index c495b52..81e0e5d 100644 --- a/rivet/runtime/windows/src/builds/chrome.rs +++ b/rivet/runtime/windows/src/builds/chrome.rs @@ -52,6 +52,7 @@ pub static CHROME: Build = Build { omnibox: None, page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: None, browser: "Google Chrome", module: "chrome.dll", @@ -391,6 +392,7 @@ pub static CHROME_156_74: Build = Build { omnibox: Some(super::omnibox_rows::OMNIBOX_CHROME_156_74), page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: Some(AppWindow { feature_impl: Some(0x001BE6D0), feature_impl_prologue: &[ @@ -810,6 +812,7 @@ pub static CHROME_156_80: Build = Build { omnibox: Some(super::omnibox_rows::OMNIBOX_CHROME_156_80), page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: Some(AppWindow { feature_impl: Some(0x001E54E0), feature_impl_prologue: &[ @@ -1257,6 +1260,7 @@ pub static CHROME_157_38: Build = Build { omnibox: Some(super::omnibox_rows::OMNIBOX_CHROME_157_38), page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: Some(AppWindow { feature_impl: Some(0x0012D300), feature_impl_prologue: &[ @@ -1661,6 +1665,7 @@ pub static CHROME_150: Build = Build { omnibox: None, page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: None, browser: "Google Chrome", module: "chrome.dll", diff --git a/rivet/runtime/windows/src/builds/edge.rs b/rivet/runtime/windows/src/builds/edge.rs index bb7c4f3..7af0022 100644 --- a/rivet/runtime/windows/src/builds/edge.rs +++ b/rivet/runtime/windows/src/builds/edge.rs @@ -51,6 +51,7 @@ pub static EDGE: Build = Build { omnibox: None, page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: None, browser: "Microsoft Edge", module: "msedge.dll", diff --git a/rivet/runtime/windows/src/builds/mod.rs b/rivet/runtime/windows/src/builds/mod.rs index 4c91d89..b5335aa 100644 --- a/rivet/runtime/windows/src/builds/mod.rs +++ b/rivet/runtime/windows/src/builds/mod.rs @@ -55,6 +55,10 @@ pub struct Build { /// `BubbleDialogDelegate::GetAnchorRect` and its first bytes, when the row carries them. A /// built-in row keeps it in the bubble anchor's own list instead. pub bubble_anchor: Option<(usize, &'static [u8])>, + /// `views::View::ConvertPointToScreen(const View*, gfx::Point*)` and its first bytes, when the + /// row carries them: where Chromium itself puts a view in screen DIPs. Without it, bubbles are + /// placed from the window's monitor. + pub screen_origin: Option<(usize, &'static [u8])>, } } diff --git a/rivet/runtime/windows/src/builds/samsung.rs b/rivet/runtime/windows/src/builds/samsung.rs index 608110b..cf5ba5b 100644 --- a/rivet/runtime/windows/src/builds/samsung.rs +++ b/rivet/runtime/windows/src/builds/samsung.rs @@ -9,6 +9,7 @@ pub static SAMSUNG: Build = Build { omnibox: Some(super::omnibox_rows::OMNIBOX_SAMSUNG_29), page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: Some(AppWindow { feature_impl: Some(0x001FBB10), feature_impl_prologue: &[ diff --git a/rivet/runtime/windows/src/builds/tests.rs b/rivet/runtime/windows/src/builds/tests.rs index 754aa4d..d1d3757 100644 --- a/rivet/runtime/windows/src/builds/tests.rs +++ b/rivet/runtime/windows/src/builds/tests.rs @@ -1619,4 +1619,16 @@ fn nfr6_a_table_row_carries_the_page_tools_and_bubble_anchor_of_its_prior_row() crate::builds::bubble_anchor_of(build), crate::builds::bubble_anchor_of(&CHROME_157_38) ); + // No built-in row has `ConvertPointToScreen`; a table row found by its name brings it. + assert!(chrome["build"]["screen_origin"].is_null()); + assert_eq!(build.screen_origin, None); + row["screen_origin"] = serde_json::json!(["0x1234560", "56574883ec284889d64889cf"]); + let build = Build::from_row(&row).unwrap(); + assert_eq!( + build.screen_origin, + Some(( + 0x1234560, + &[0x56, 0x57, 0x48, 0x83, 0xEC, 0x28, 0x48, 0x89, 0xD6, 0x48, 0x89, 0xCF][..] + )) + ); } diff --git a/rivet/runtime/windows/src/builds/whale.rs b/rivet/runtime/windows/src/builds/whale.rs index 62c888c..770434a 100644 --- a/rivet/runtime/windows/src/builds/whale.rs +++ b/rivet/runtime/windows/src/builds/whale.rs @@ -19,6 +19,7 @@ pub static WHALE: Build = Build { omnibox: Some(super::omnibox_rows::OMNIBOX_WHALE_57), page_tools: None, bubble_anchor: None, + screen_origin: None, app_window: Some(AppWindow { // `WindowFeatureController::SupportsWindowFeatureImpl`, found statically and unverified: the // function `BrowserView::ShouldDrawTabStrip` asks through the thunk at 0x8104DF0 diff --git a/rivet/runtime/windows/src/chromium/memory.rs b/rivet/runtime/windows/src/chromium/memory.rs index f1a8da9..eb7e6a9 100644 --- a/rivet/runtime/windows/src/chromium/memory.rs +++ b/rivet/runtime/windows/src/chromium/memory.rs @@ -59,7 +59,40 @@ pub(crate) fn readable(address: usize, length: usize) -> bool { last <= region.1 } +/// The pointer-sized value at `address`, read in place once the word is known readable. `None` +/// for an address that is not, which the caller may still try another way. +pub(crate) fn read_word(address: usize) -> Option { + readable(address, 8).then(|| unsafe { *(address as *const usize) }) +} + +/// Whether one of the words of the `length` bytes at `address`, from byte `from` on, is `value`, +/// read in place in one go. `None` when the bytes are not all readable. +pub(crate) fn holds_word(address: usize, from: usize, length: usize, value: usize) -> Option { + if !readable(address, length) { + return None; + } + let words = unsafe { std::slice::from_raw_parts(address as *const usize, length / 8) }; + Some(words.iter().skip(from / 8).any(|word| *word == value)) +} + pub(crate) fn prologue_is(address: usize, expected: &[u8]) -> bool { readable(address & !7, 8) && unsafe { std::slice::from_raw_parts(address as *const u8, expected.len()) } == expected } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nfr2_browser_words_are_read_in_place_once_readable() { + let words: Vec = vec![7, 11, 0x1234, 99]; + let at = words.as_ptr() as usize; + assert_eq!(read_word(at + 16), Some(0x1234)); + assert_eq!(holds_word(at, 8, 32, 0x1234), Some(true)); + // The first word is skipped, as a view's own table pointer is. + assert_eq!(holds_word(at, 8, 32, 7), Some(false)); + assert_eq!(read_word(0), None); + assert_eq!(holds_word(0, 8, 32, 7), None); + } +} diff --git a/rivet/runtime/windows/src/chromium/mod.rs b/rivet/runtime/windows/src/chromium/mod.rs index fddfded..713c6d1 100644 --- a/rivet/runtime/windows/src/chromium/mod.rs +++ b/rivet/runtime/windows/src/chromium/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod image; pub(crate) mod memory; pub(crate) mod reflect; +pub(crate) mod screen; pub(crate) mod view; diff --git a/rivet/runtime/windows/src/chromium/screen.rs b/rivet/runtime/windows/src/chromium/screen.rs new file mode 100644 index 0000000..d9672fb --- /dev/null +++ b/rivet/runtime/windows/src/chromium/screen.rs @@ -0,0 +1,69 @@ +//! Where Chromium itself puts a view on the screen. +//! +//! `views::View::ConvertPointToScreen(const View*, gfx::Point*)` is the conversion Chromium's own +//! bubbles use for their anchor, so a place found through it is the browser's own, in screen DIPs, +//! even for a window whose scale differs from that of the monitor it is on. A row may carry the +//! function with its first bytes. The bytes are checked against the loaded module once, and the +//! function is called only after they matched. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +static CONVERT: AtomicUsize = AtomicUsize::new(0); +type ConvertPointToScreen = unsafe extern "system" fn(*const core::ffi::c_void, *mut [i32; 2]); + +/// The fewest bytes that tell a function start apart. +const LEAST_PROLOGUE: usize = 8; + +/// Whether the bytes found at the recorded place are the ones the row records. +fn accepts(found: Option<&[u8]>, recorded: &[u8]) -> bool { + recorded.len() >= LEAST_PROLOGUE && found == Some(recorded) +} + +/// Checks the row's `ConvertPointToScreen` against the loaded module, and says what it found. +pub(crate) fn install() -> &'static str { + let Some((at, prologue)) = crate::builds::current().and_then(|build| build.screen_origin) + else { + return "the row has no ConvertPointToScreen"; + }; + let Some(image) = crate::chromium::image::recorded() else { + return "no recorded module"; + }; + if !accepts(unsafe { image.bytes(at, prologue.len()) }, prologue) { + return "ConvertPointToScreen is not the recorded one"; + } + CONVERT.store(image.base + at, Ordering::Release); + "ConvertPointToScreen checked" +} + +/// The top left corner of `view` in screen DIPs, as Chromium puts it. `None` when the row has no +/// checked `ConvertPointToScreen`. +/// +/// # Safety +/// `view` must be a live `views::View` of the browser, used on the browser's UI thread. +pub(crate) unsafe fn origin(view: usize) -> Option<(i32, i32)> { + let at = CONVERT.load(Ordering::Acquire); + if at == 0 || view == 0 { + return None; + } + let convert: ConvertPointToScreen = std::mem::transmute(at); + let mut point = [0i32; 2]; + convert(view as *const core::ffi::c_void, &mut point); + Some((point[0], point[1])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fr12_screen_origin_is_called_only_after_its_bytes_matched() { + let recorded: &[u8] = &[0x56, 0x57, 0x48, 0x83, 0xEC, 0x28, 0x48, 0x89, 0xD6]; + assert!(accepts(Some(recorded), recorded)); + assert!(!accepts(Some(&recorded[1..]), recorded)); + assert!(!accepts(None, recorded)); + // Too few bytes say nothing about which function lies there. + assert!(!accepts(Some(&recorded[..4]), &recorded[..4])); + // Without a checked function there is no origin, and nothing is called. + assert_eq!(unsafe { origin(0x1000) }, None); + } +} diff --git a/rivet/runtime/windows/src/features/address_bar.rs b/rivet/runtime/windows/src/features/address_bar.rs index a7183fd..6bec37f 100644 --- a/rivet/runtime/windows/src/features/address_bar.rs +++ b/rivet/runtime/windows/src/features/address_bar.rs @@ -281,6 +281,10 @@ struct Bar { refresh_step: Option, /// Whether the query being re-read may complete inline. refresh_inline: bool, + /// The caret the input method's windows were last placed at, in client pixels. + placed: Option<[i32; 4]>, + /// The bar's place, left, top and width in DIPs, when it was last drawn. + drawn_at: Option<[f32; 3]>, } thread_local! { @@ -309,6 +313,8 @@ fn with_bar(window: HWND, work: impl FnOnce(&mut Bar) -> R) -> R { deleting: false, refresh_step: None, refresh_inline: false, + placed: None, + drawn_at: None, }); work(bar) }) @@ -604,6 +610,7 @@ pub(crate) unsafe fn view(window: HWND) -> Option { if bar.field.as_ref().is_some_and(|field| field.take_changed()) { bar.generation += 1; } + bar.drawn_at = Some([x, y, width]); View { dev, url: url.clone(), @@ -689,14 +696,32 @@ pub(crate) unsafe fn with_field_pixels( }) } -unsafe fn part_at(window: HWND, point: POINT) -> Option { +/// The control of the top bar under a client pixel. The window's hit test asks it too, so the bar +/// takes the clicks of its controls wherever the browser would put its own title bar. +pub(crate) unsafe fn part_at(window: HWND, point: POINT) -> Option { let [x, y, width] = place(window)?; + // The bar takes clicks where it is now. When it was drawn elsewhere, as when the browser moved + // its panel without a repaint after, it is drawn again here before the click can come. + if !busy() && needs_redraw_at(with_bar(window, |bar| bar.drawn_at), [x, y, width]) { + crate::frame::request_bar_paint(window); + } let f = factor(window); let (px, py) = (point.x as f32 / f - x, point.y as f32 / f - y); + part_in(width, in_pane(window), dev_reserve(window), px, py) +} + +/// Whether a bar drawn at `drawn` must be drawn again to show at `now`, its place for clicks. +pub(crate) fn needs_redraw_at(drawn: Option<[f32; 3]>, now: [f32; 3]) -> bool { + drawn.is_some_and(|drawn| drawn != now) +} + +/// The control of a bar `width` DIPs wide at `px`, `py` DIPs from its top left corner, from the +/// same places the bar is painted at. +pub(crate) fn part_in(width: f32, pane: bool, reserve: f32, px: f32, py: f32) -> Option { if py < 0.0 || py >= ROW_HEIGHT as f32 { return None; } - let parts = parts_reserving(width, in_pane(window), dev_reserve(window)); + let parts = parts_reserving(width, pane, reserve); // The chip lies inside the address, so it is looked for first. parts .iter() @@ -737,7 +762,9 @@ unsafe fn begin(window: HWND, bar: &mut Bar) { bar.queried = url.clone(); bar.original = url; bar.editing = true; + bar.placed = None; bar.generation += 1; + crate::trace::trace("address bar: editing begins"); } /// The timer that re-reads the browser's result while a query's slower providers answer. It runs @@ -775,6 +802,7 @@ unsafe fn end(bar: &mut Bar) { bar.queried.clear(); bar.inline.clear(); bar.deleting = false; + bar.placed = None; bar.generation += 1; } @@ -895,9 +923,92 @@ pub(crate) fn completed(typed: &str, inline: &str) -> (String, usize, usize) { ) } +/// Whether the field's `text` asks the browser again: only while editing, and only when it is +/// neither the text last asked for nor that text with the browser's own inline completion, which +/// Rivet set itself. +pub(crate) fn needs_query(editing: bool, text: &str, queried: &str, inline: &str) -> bool { + editing && text != queried && (inline.is_empty() || text.strip_prefix(queried) != Some(inline)) +} + +/// What a typing message can change on screen: the text and its selection, the caret, and the +/// list with its pick and inline completion. The text engine reports a change for many keys that +/// change none of these, such as a key down before its character, so its own report is not used. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Shown { + pub text: String, + pub selection: (usize, usize), + pub caret: Option<[i32; 4]>, + pub rows: usize, + pub selected: Option, + pub inline: String, +} + +fn bar_state(window: HWND) -> Option { + with_bar(window, |bar| { + let field = bar.field.as_ref()?; + Some(Shown { + text: field.text(), + selection: selection(field), + caret: field.caret().map(|r| [r.left, r.top, r.right, r.bottom]), + rows: bar.suggestions.len(), + selected: bar.selected, + inline: bar.inline.clone(), + }) + }) +} + +/// Whether the input method's windows go to `caret` again. Placing them makes the input method +/// notify the window, so they are placed only when the caret moved. +pub(crate) fn place_again(placed: Option<[i32; 4]>, caret: [i32; 4]) -> bool { + placed != Some(caret) +} + +/// What one message of typing cost Rivet on the browser's UI thread, in microseconds, and how many +/// queries it started. The trace shows one line per key, so a slow key names the step that took the +/// time. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct KeyWork { + pub starts: u32, + pub forward_us: u64, + pub query_us: u64, + pub inline_us: u64, + pub draw_us: u64, +} + +impl KeyWork { + pub(crate) fn total_us(&self) -> u64 { + self.forward_us + self.query_us + self.inline_us + self.draw_us + } + + pub(crate) fn line(&self, message: u32, key: usize) -> String { + format!( + "address bar: key {message:#x} {key:#x} took {} us: forward {} query {} inline {} draw {} starts {}", + self.total_us(), + self.forward_us, + self.query_us, + self.inline_us, + self.draw_us, + self.starts + ) + } +} + +/// Microseconds since `start`. +fn micros_since(start: std::time::Instant) -> u64 { + start.elapsed().as_micros() as u64 +} + +/// Writes the work of one key to the trace, when the trace is on. +fn trace_key(message: u32, key: usize, work: KeyWork) { + if crate::trace::tracing() { + crate::trace::trace(&work.line(message, key)); + } +} + /// Asks the browser for suggestions when the text changed since it last asked. The browser answers /// at once, so nothing waits or polls: the list is drawn again only when the text changed. -unsafe fn refresh_suggestions(window: HWND) { +unsafe fn refresh_suggestions(window: HWND) -> KeyWork { + let mut work = KeyWork::default(); let Some((text, inline)) = with_bar(window, |bar| { let field = bar.field.as_ref()?; let text = field.text(); @@ -906,10 +1017,7 @@ unsafe fn refresh_suggestions(window: HWND) { bar.inline.clear(); bar.generation += 1; } - if !bar.editing - || text == bar.queried - || (!bar.inline.is_empty() && text == format!("{}{}", bar.queried, bar.inline)) - { + if !needs_query(bar.editing, &text, &bar.queried, &bar.inline) { return None; } Some(( @@ -917,14 +1025,17 @@ unsafe fn refresh_suggestions(window: HWND) { may_complete_inline(&text, selection(field), bar.deleting), )) }) else { - return; + return work; }; let inline = inline && !composing(window); + let asked = std::time::Instant::now(); let found = if text.trim().is_empty() { Some(Vec::new()) } else { + work.starts += 1; crate::features::omnibox::suggest_with(window, &text, inline, true, true) }; + work.query_us = micros_since(asked); // Each key starts the re-reads over; the last key's query is the one that answers. let first = next_refresh(0, true); with_bar(window, |bar| { @@ -950,10 +1061,12 @@ unsafe fn refresh_suggestions(window: HWND) { .unwrap_or_default(); if !bar.inline.is_empty() { if let Some(field) = bar.field.as_ref() { + let completing = std::time::Instant::now(); let (full, start, end) = completed(&text, &bar.inline); field.set_text(&full); field.send(EM_SETSEL, start, end as isize); field.take_changed(); + work.inline_us = micros_since(completing); } } bar.queried = text.clone(); @@ -966,6 +1079,7 @@ unsafe fn refresh_suggestions(window: HWND) { format!("{rows:?} inline {:?}", bar.inline) }); crate::trace::trace(&format!("address bar: suggestions for {text:?}: {shown}")); + work } /// Opens a suggestion as the browser's dropdown does. @@ -1037,8 +1151,10 @@ pub(crate) unsafe fn message( with_bar(window, |bar| bar.refresh_step = None); return Some(0); }; + let read = std::time::Instant::now(); let found = crate::features::omnibox::suggest_with(window, &text, inline, true, false); + let read_us = micros_since(read); let changed = with_bar(window, |bar| { let Some(mut found) = found else { return false; @@ -1072,6 +1188,7 @@ pub(crate) unsafe fn message( bar.generation += 1; true }); + let drawing = std::time::Instant::now(); if changed { let shown = with_bar(window, |bar| { let rows: Vec<&str> = bar @@ -1084,7 +1201,14 @@ pub(crate) unsafe fn message( crate::trace::trace(&format!( "address bar: suggestions for {text:?} later: {shown}" )); - crate::frame::update_appbar(window); + crate::frame::update_top_bar(window); + } + if crate::trace::tracing() { + crate::trace::trace(&format!( + "address bar: refresh {step} took {} us: read {read_us} draw {} changed {changed}", + micros_since(read), + micros_since(drawing) + )); } let editing = with_bar(window, |bar| bar.editing); match next_refresh(step + 1, editing) { @@ -1300,9 +1424,18 @@ pub(crate) unsafe fn message( with_bar(window, |bar| { bar.deleting = matches!(wparam as u16, 0x08 | 0x2E) }); + let before = bar_state(window); + let clock = std::time::Instant::now(); let used = forward(window, message, wparam, lparam); - refresh_suggestions(window); - crate::frame::update_appbar(window); + let forward_us = micros_since(clock); + let mut work = refresh_suggestions(window); + let drawing = std::time::Instant::now(); + if bar_state(window) != before { + crate::frame::update_top_bar(window); + } + work.forward_us = forward_us; + work.draw_us = micros_since(drawing); + trace_key(message, wparam, work); used.or(Some(0)) } }, @@ -1311,9 +1444,18 @@ pub(crate) unsafe fn message( return Some(0); } with_bar(window, |bar| bar.deleting = wparam == 0x08); + let before = bar_state(window); + let clock = std::time::Instant::now(); let used = forward(window, message, wparam, lparam); - refresh_suggestions(window); - crate::frame::update_appbar(window); + let forward_us = micros_since(clock); + let mut work = refresh_suggestions(window); + let drawing = std::time::Instant::now(); + if bar_state(window) != before { + crate::frame::update_top_bar(window); + } + work.forward_us = forward_us; + work.draw_us = micros_since(drawing); + trace_key(message, wparam, work); used.or(Some(0)) } WM_KEYUP @@ -1324,11 +1466,21 @@ pub(crate) unsafe fn message( | WM_IME_CHAR | WM_IME_REQUEST | WM_INPUTLANGCHANGE => { + let before = bar_state(window); + let clock = std::time::Instant::now(); let used = forward(window, message, wparam, lparam); + let forward_us = micros_since(clock); + let mut work = KeyWork::default(); if matches!(message, WM_IME_COMPOSITION | WM_IME_ENDCOMPOSITION) { - refresh_suggestions(window); + work = refresh_suggestions(window); } - crate::frame::update_appbar(window); + let drawing = std::time::Instant::now(); + if bar_state(window) != before { + crate::frame::update_top_bar(window); + } + work.forward_us = forward_us; + work.draw_us = micros_since(drawing); + trace_key(message, wparam, work); if message == WM_INPUTLANGCHANGE { None } else { @@ -1363,19 +1515,41 @@ pub(crate) unsafe fn message( } unsafe fn forward(window: HWND, message: u32, wparam: WPARAM, lparam: LPARAM) -> Option { + let sending = std::time::Instant::now(); let (used, caret) = with_bar(window, |bar| { let field = bar.field.as_ref()?; let used = field.send(message, wparam, lparam); + let sent_us = micros_since(sending); + if sent_us >= 2_000 && crate::trace::tracing() { + crate::trace::trace(&format!( + "address bar: text engine took {sent_us} us for {message:#x}" + )); + } if field.take_changed() { bar.generation += 1; } - Some((used, field.caret())) + // Placing the windows makes the input method send notices of its own, which need no placing. + let placing = !matches!(message, WM_IME_NOTIFY | WM_IME_SETCONTEXT | WM_IME_REQUEST); + let caret = field.caret().filter(|caret| { + if !placing { + return false; + } + let at = [caret.left, caret.top, caret.right, caret.bottom]; + let again = place_again(bar.placed, at); + bar.placed = Some(at); + again + }); + Some((used, caret)) })?; - // Placing the windows makes the input method send notices of its own, which need no placing. - if let Some(caret) = - caret.filter(|_| !matches!(message, WM_IME_NOTIFY | WM_IME_SETCONTEXT | WM_IME_REQUEST)) - { + if let Some(caret) = caret { + let placing = std::time::Instant::now(); place_input_method(window, caret); + let placed_us = micros_since(placing); + if placed_us >= 2_000 && crate::trace::tracing() { + crate::trace::trace(&format!( + "address bar: input method placed in {placed_us} us for {message:#x}" + )); + } } used } @@ -1409,6 +1583,7 @@ unsafe fn place_input_method(window: HWND, caret: RECT) { } unsafe fn act(window: HWND, part: Part) { + crate::trace::trace(&format!("address bar: act {part:?}")); let command: isize = match part { Part::Back => 1, Part::Forward => 2, @@ -1550,7 +1725,7 @@ unsafe fn redraw(window: HWND, redraw: Redraw) { match redraw { Redraw::Nothing => {} Redraw::Caret => crate::frame::update_address_caret(window), - Redraw::Bar => crate::frame::update_appbar(window), + Redraw::Bar => crate::frame::update_top_bar(window), } } @@ -1570,6 +1745,116 @@ pub(crate) fn caret(window: HWND) -> Option { mod tests { use super::*; + #[test] + fn fr12_each_control_answers_where_it_is_painted_shown_and_folded() { + // A bar beside the shown appbar and the wider bar of a folded one, as the browser lays + // the panel out for each. + for width in [1156.0, 1422.0] { + for (part, at, w) in parts_reserving(width, false, 0.0) { + if part == Part::Chip { + continue; + } + let middle = at + w / 2.0; + assert_eq!( + part_in(width, false, 0.0, middle, ROW_HEIGHT as f32 / 2.0), + Some(part), + "{part:?} of a {width} DIP bar" + ); + } + assert_eq!(part_in(width, false, 0.0, 10.0, -1.0), None); + assert_eq!(part_in(width, false, 0.0, 10.0, ROW_HEIGHT as f32), None); + } + } + + #[test] + fn nfr2_one_key_asks_the_browser_once_and_rivets_own_completion_never() { + // A key down and up leave the text alone, so only the character asks. + let mut starts = 0; + let (mut queried, inline) = ("exa".to_string(), String::new()); + for text in ["exa", "exam", "exam"] { + if needs_query(true, text, &queried, &inline) { + starts += 1; + queried = text.to_string(); + } + } + assert_eq!(starts, 1); + // The browser's completion that Rivet set after the typed text asks nothing again. + assert!(!needs_query(true, "example.com", "exa", "mple.com")); + assert!(needs_query(true, "examp", "exa", "mple.com")); + assert!(!needs_query(false, "examp", "exa", "")); + assert!(!needs_query(true, "exa", "exa", "mple.com")); + } + + #[test] + fn fr12_a_bar_drawn_where_it_no_longer_takes_clicks_is_drawn_again() { + // Shown beside the appbar, then folded: the panel moved left and grew. + let shown = [276.0, 8.0, 1156.0]; + let folded = [8.0, 8.0, 1424.0]; + assert!(needs_redraw_at(Some(shown), folded)); + assert!(needs_redraw_at(Some(folded), shown)); + assert!(!needs_redraw_at(Some(folded), folded)); + // A bar never drawn yet is drawn with the window's first paint. + assert!(!needs_redraw_at(None, folded)); + } + + #[test] + fn nfr2_a_key_that_shows_nothing_new_draws_nothing() { + let before = Shown { + text: "exa".into(), + selection: (3, 3), + caret: Some([40, 4, 41, 20]), + rows: 3, + selected: None, + inline: String::new(), + }; + // A key down before its character, or a key up, leaves what the bar shows as it was. + assert_eq!(Some(before.clone()), Some(before.clone())); + // A character, a caret move or a new list each draw. + let typed = Shown { + text: "exam".into(), + selection: (4, 4), + caret: Some([48, 4, 49, 20]), + ..before.clone() + }; + assert_ne!(Some(before.clone()), Some(typed)); + let moved = Shown { + caret: Some([30, 4, 31, 20]), + selection: (2, 2), + ..before.clone() + }; + assert_ne!(Some(before.clone()), Some(moved)); + let listed = Shown { + rows: 5, + ..before.clone() + }; + assert_ne!(Some(before), Some(listed)); + } + + #[test] + fn nfr2_the_input_method_is_placed_only_when_the_caret_moved() { + assert!(place_again(None, [10, 4, 11, 20])); + assert!(!place_again(Some([10, 4, 11, 20]), [10, 4, 11, 20])); + assert!(place_again(Some([10, 4, 11, 20]), [18, 4, 19, 20])); + } + + #[test] + fn nfr2_a_keys_work_is_traced_step_by_step() { + let work = KeyWork { + starts: 1, + forward_us: 20, + query_us: 4000, + inline_us: 80, + draw_us: 1000, + }; + assert_eq!(work.total_us(), 5100); + // windows-features.ps1 reads this line back, so its shape is part of the test contract. + assert_eq!( + work.line(0x102, 0x61), + "address bar: key 0x102 0x61 took 5100 us: forward 20 query 4000 inline 80 draw 1000 starts 1" + ); + assert_eq!(KeyWork::default().total_us(), 0); + } + #[test] fn fr12_late_suggestions_are_read_a_few_times_after_a_key_and_never_at_idle() { // After a key: three reads, at 100, then 200, then 500 ms, then nothing more. diff --git a/rivet/runtime/windows/src/features/app_window.rs b/rivet/runtime/windows/src/features/app_window.rs index 26b60bd..87dd96e 100644 --- a/rivet/runtime/windows/src/features/app_window.rs +++ b/rivet/runtime/windows/src/features/app_window.rs @@ -402,6 +402,9 @@ pub(crate) unsafe fn flip_new_tab(window: HWND, offsets: &[usize]) -> bool { unsafe fn safe_word(address: usize) -> Option { use windows_sys::Win32::System::Diagnostics::Debug::ReadProcessMemory; use windows_sys::Win32::System::Threading::GetCurrentProcess; + if let Some(value) = crate::chromium::memory::read_word(address) { + return Some(value); + } let mut value = 0usize; let mut done = 0usize; let ok = ReadProcessMemory( diff --git a/rivet/runtime/windows/src/features/bubble_anchor.rs b/rivet/runtime/windows/src/features/bubble_anchor.rs index c68cceb..8773090 100644 --- a/rivet/runtime/windows/src/features/bubble_anchor.rs +++ b/rivet/runtime/windows/src/features/bubble_anchor.rs @@ -240,6 +240,34 @@ fn client_to_screen_dips( ] } +/// A rect in a window's client DIPs as screen DIPs, from where Chromium puts the window's top left +/// corner. Rivet's rows sit at whole DIPs of the window, so they move with that corner as they are. +pub(crate) fn at_origin(origin: (i32, i32), [x, y, w, h]: [f32; 4]) -> Rect { + [ + origin.0 + x.round() as i32, + origin.1 + y.round() as i32, + w.round() as i32, + h.round() as i32, + ] +} + +/// The page, address and chip a bubble anchors to: where Chromium puts them when the row lets +/// Rivet ask it, else where the window's monitor says they are. Chromium places its own bubbles +/// from the same answer, so a window whose scale differs from its monitor's still gets its bubble +/// by its address row. +pub(crate) fn placed(chromium: Option<[Rect; 3]>, monitor: [Rect; 3]) -> [Rect; 3] { + chromium.unwrap_or(monitor) +} + +/// The largest distance in DIPs between the same edge of two lists of rects. +pub(crate) fn largest_difference(one: &[Rect], other: &[Rect]) -> i32 { + one.iter() + .zip(other) + .flat_map(|(a, b)| a.iter().zip(b).map(|(x, y)| (x - y).abs())) + .max() + .unwrap_or(0) +} + /// Client DIPs of `window` as screen DIPs. unsafe fn to_screen(window: HWND, rect: [f32; 4]) -> Rect { let factor = crate::frame::layout_dpi(window).max(96) as f32 / 96.0; @@ -252,8 +280,11 @@ unsafe fn to_screen(window: HWND, rect: [f32; 4]) -> Rect { crate::os::win32::monitors_of(window), ) } -/// The page area, the address and the chip of a Rivet window, in screen DIPs. -unsafe fn targets(window: HWND) -> Option<(Rect, Rect, Rect)> { +/// The page area, the address and the chip of a Rivet window, in its client DIPs and in screen DIPs. +type Targets = ([[f32; 4]; 3], (Rect, Rect, Rect)); + +/// The `Targets` of a Rivet window. +unsafe fn targets(window: HWND) -> Option { let (address, chip) = crate::features::address_bar::anchors(window)?; let mut client: RECT = std::mem::zeroed(); GetClientRect(window, &mut client); @@ -266,9 +297,12 @@ unsafe fn targets(window: HWND) -> Option<(Rect, Rect, Rect)> { client.bottom as f32 / factor - below, ]; Some(( - to_screen(window, page), - to_screen(window, address), - to_screen(window, chip), + [page, address, chip], + ( + to_screen(window, page), + to_screen(window, address), + to_screen(window, chip), + ), )) } @@ -294,7 +328,7 @@ unsafe extern "system" fn get_anchor_rect( if result.is_null() { return result; } - if let Some((page, address, chip)) = targets(window) { + if let Some((client, (page, address, chip))) = targets(window) { let original = *result; crate::trace::trace(&format!( "bubble anchor: original {original:?} address {address:?} chip {chip:?} client origin {:?} layout dpi {}", @@ -305,6 +339,21 @@ unsafe extern "system" fn get_anchor_rect( }, crate::frame::layout_dpi(window) )); + // Where Chromium itself puts the same rows, beside the place derived from the monitor. + let view = crate::features::frame_layout::frame_view(window).unwrap_or(0); + let own = crate::chromium::screen::origin(view).map(|origin| { + let own = client.map(|rect| at_origin(origin, rect)); + let off = largest_difference(&own, &[page, address, chip]); + crate::trace::trace(&format!( + "bubble anchor: chromium origin {origin:?} page {:?} address {:?} chip {:?} same {} off {off}", + own[0], + own[1], + own[2], + off <= 1 + )); + own + }); + let [page, address, chip] = placed(own, [page, address, chip]); *result = choose( *result, page, @@ -345,6 +394,10 @@ pub(crate) fn install() { }) }; crate::trace::trace(&format!("bubble anchor: hook {result:?}")); + crate::trace::trace(&format!( + "bubble anchor: screen origin {}", + crate::chromium::screen::install() + )); } #[cfg(test)] @@ -389,6 +442,32 @@ mod tests { ] ); } + #[test] + fn fr12_rows_move_with_the_window_corner_chromium_reports() { + // A window parked off every monitor at a scale its nearest monitor does not have: Chromium + // put its frame view's corner at 9402,0 DIPs, and the chip 16 DIPs into the row follows it. + let chip = at_origin((9402, 0), [16.4, 16.0, 22.0, 28.0]); + assert_eq!(chip, [9418, 16, 22, 28]); + assert_eq!( + at_origin((-1920, -300), [0.0, 0.0, 0.0, 0.0]), + [-1920, -300, 0, 0] + ); + // The trace's same flag: equal places, one DIP of rounding, and the 441 DIPs once seen. + assert_eq!(largest_difference(&[chip], &[chip]), 0); + assert_eq!(largest_difference(&[chip], &[[9419, 16, 22, 28]]), 1); + assert_eq!( + largest_difference(&[ADDRESS, chip], &[ADDRESS, [8977, 16, 22, 28]]), + 441 + ); + // Chromium's place wins where the row lets Rivet ask; the monitor's stays otherwise, as for + // the built-in rows, Whale and Samsung Internet. + let monitor = [PAGE, ADDRESS, [8977, 16, 22, 28]]; + let own = [PAGE, ADDRESS, chip]; + assert_eq!(placed(Some(own), monitor), own); + assert_eq!(placed(None, monitor), monitor); + assert_eq!(choose([0, 0, 0, 0], PAGE, own[1], own[2], true), chip); + } + #[test] fn fr12_bubble_at_the_monitor_edge_moves_to_the_address_bar() { // Brave's save bubble was seen at x=4863, past the window. diff --git a/rivet/runtime/windows/src/features/frame_layout.rs b/rivet/runtime/windows/src/features/frame_layout.rs index 4fabc1e..6cd592e 100644 --- a/rivet/runtime/windows/src/features/frame_layout.rs +++ b/rivet/runtime/windows/src/features/frame_layout.rs @@ -1027,6 +1027,7 @@ pub unsafe fn relayout(window: HWND) -> bool { unsafe extern "system" fn frame_layout(view: *mut c_void) { let (original, set_bounds, height) = LAYOUT_HOOK.get().expect("patched after it is stored"); original(view); + crate::features::second_view::note_layout(); let Some(Ok(hook)) = HOOK.get() else { return }; remember_view((hook.hwnd_for_view)(view), view); if height((hook.hwnd_for_view)(view)).is_none() { diff --git a/rivet/runtime/windows/src/features/second_view.rs b/rivet/runtime/windows/src/features/second_view.rs index 6b046e1..64f5367 100644 --- a/rivet/runtime/windows/src/features/second_view.rs +++ b/rivet/runtime/windows/src/features/second_view.rs @@ -323,6 +323,11 @@ pub(crate) fn has_room(window: HWND) -> bool { } } +/// The slot of the split that Rivet last gave the keyboard, zero for the window's own page. +pub(crate) fn focused() -> usize { + FOCUSED.load(Ordering::Acquire) +} + /// Remembers which page of the split has the keyboard, for a page of another window. pub(crate) fn set_focused(slot: usize) { FOCUSED.store(slot, Ordering::Release); @@ -489,6 +494,11 @@ pub(crate) fn is_open(window: HWND) -> bool { unsafe fn word(address: usize) -> Option { use windows_sys::Win32::System::Diagnostics::Debug::ReadProcessMemory; use windows_sys::Win32::System::Threading::GetCurrentProcess; + // A walk over the browser's views reads many thousands of words, so a word known readable is + // read in place rather than with a system call each. + if let Some(value) = crate::chromium::memory::read_word(address) { + return Some(value); + } let mut value = 0usize; let mut done = 0usize; let ok = ReadProcessMemory( @@ -565,6 +575,9 @@ unsafe fn views_with_vtable(root: usize, vtable: usize) -> Vec { /// Whether a page view holds this page in one of its words, as `WebView::web_contents_`. unsafe fn holds(view: usize, page: usize, size: usize) -> bool { + if let Some(found) = crate::chromium::memory::holds_word(view, 8, size, page) { + return found; + } (8..size) .step_by(8) .any(|offset| word(view + offset) == Some(page)) @@ -1411,12 +1424,24 @@ pub(crate) unsafe fn page_area(window: HWND) -> Option<[i32; 4]> { let [x, y, w, h] = window_area(&state); return Some([x, y, x + w, y + h]); } - // With no split, the page view is the view of the browser view that holds the active page. + // With no split, the page view is the view of the browser view that holds the active page. It + // is looked for once per layout and page; a paint in between reads its bounds alone. let base = base()?; let own = active_page(window); - let parent = browser_view(window)?; let end = base + crate::chromium::image::recorded().map_or(0, |image| image.size); let size = recorded("size").unwrap_or(0x528); + let epoch = layout_epoch(); + if let Some(main) = known_page(window as isize, own, epoch) { + if own != 0 + && word(main).is_some_and(|vptr| vptr > base && vptr < end) + && holds(main, own, size) + { + let [x, y] = window_origin(main); + let [_, _, w, h] = bounds_of(main); + return Some([x, y, x + w, y + h]); + } + } + let parent = browser_view(window)?; let mut queue = vec![(parent, 0usize)]; let mut best: Option<(i64, usize)> = None; let mut visited = 0; @@ -1441,12 +1466,64 @@ pub(crate) unsafe fn page_area(window: HWND) -> Option<[i32; 4]> { .map(|child| (child, depth + 1)), ); } + remember_page(window as isize, own, epoch, best.map(|(_, view)| view)); let (_, main) = best?; let [x, y] = window_origin(main); let [_, _, w, h] = bounds_of(main); Some([x, y, x + w, y + h]) } +/// Counts the browser's layouts and window size and scale changes, after which a page view found +/// before is looked for again. +static LAYOUT_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// The browser laid a window out, or a window's size or scale changed. +pub(crate) fn note_layout() { + LAYOUT_EPOCH.fetch_add(1, Ordering::Relaxed); +} + +fn layout_epoch() -> u64 { + LAYOUT_EPOCH.load(Ordering::Relaxed) +} + +/// The page view of a window as last found, for which page and after which layout. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FoundPage { + own: usize, + epoch: u64, + view: usize, +} + +thread_local! { + static FOUND_PAGES: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// The page view found for `window` showing page `own`, when nothing was laid out since. +fn known_page(window: isize, own: usize, epoch: u64) -> Option { + FOUND_PAGES.with(|all| { + all.borrow() + .get(&window) + .filter(|found| found.own == own && found.epoch == epoch) + .map(|found| found.view) + }) +} + +/// Keeps what a walk found for `window`, or forgets the window when it found nothing. +fn remember_page(window: isize, own: usize, epoch: u64, view: Option) { + FOUND_PAGES.with(|all| { + let mut all = all.borrow_mut(); + match view { + Some(view) => { + all.insert(window, FoundPage { own, epoch, view }); + } + None => { + all.remove(&window); + } + } + }); +} + /// The window's page area in window DIPs with the rail's strip included, which is where the /// Sidebar's rail is drawn. pub(crate) unsafe fn full_page_area(window: HWND) -> Option<[i32; 4]> { @@ -1768,9 +1845,11 @@ pub(crate) unsafe fn on_mouse( // The click may have made the page's tab active, which moves the page into the // window's page view. Rivet puts it back now, and again after the browser's own // messages, since the activation may come later. - if !hold(window) { - focus_slot(window, slot); - } + let held = hold(window); + let focused = !held && focus_slot(window, slot); + crate::trace::trace(&format!( + "second view: press in slot {slot}, page held back {held}, focus asked {focused}" + )); post_layout(window); Some(result) } @@ -1900,6 +1979,33 @@ pub(crate) mod bare { } } +#[cfg(test)] +mod page_tests { + use super::*; + + #[test] + fn nfr2_a_paint_without_a_layout_does_not_walk_the_views_again() { + let (window, page) = (0x5150_i32 as isize, 0x7000); + let epoch = layout_epoch(); + assert_eq!( + known_page(window, page, epoch), + None, + "nothing found yet: walk" + ); + remember_page(window, page, epoch, Some(0x9000)); + // A paint with the same page and no layout since takes the view found before. + assert_eq!(known_page(window, page, epoch), Some(0x9000)); + assert_eq!(known_page(window, page, epoch), Some(0x9000)); + // Another page, or a layout, walks again. + assert_eq!(known_page(window, 0x7008, epoch), None); + note_layout(); + assert_eq!(known_page(window, page, layout_epoch()), None); + // A walk that found nothing leaves nothing to take. + remember_page(window, page, layout_epoch(), None); + assert_eq!(known_page(window, page, layout_epoch()), None); + } +} + #[cfg(test)] mod tests { use super::{ diff --git a/rivet/runtime/windows/src/features/tab_strip.rs b/rivet/runtime/windows/src/features/tab_strip.rs index c8e37cd..cd156af 100644 --- a/rivet/runtime/windows/src/features/tab_strip.rs +++ b/rivet/runtime/windows/src/features/tab_strip.rs @@ -147,6 +147,11 @@ const BOUNDS: usize = 0x1C0; /// A word of memory that may not belong to a view, read so that a wrong guess cannot fault. unsafe fn read(address: usize, bytes: usize, out: *mut u8) -> bool { use windows_sys::Win32::System::Diagnostics::Debug::ReadProcessMemory; + // Memory known readable is copied in place, without a system call for every word. + if address > 0x10000 && crate::chromium::memory::readable(address, bytes) { + std::ptr::copy_nonoverlapping(address as *const u8, out, bytes); + return true; + } address > 0x10000 && ReadProcessMemory( windows_sys::Win32::System::Threading::GetCurrentProcess(), diff --git a/rivet/runtime/windows/src/frame.rs b/rivet/runtime/windows/src/frame.rs index 792c84b..c625721 100644 --- a/rivet/runtime/windows/src/frame.rs +++ b/rivet/runtime/windows/src/frame.rs @@ -2141,12 +2141,80 @@ unsafe fn nudge(window: HWND, why: &str) -> bool { /// Hides the docked appbar so the page takes its width, or docks it again. unsafe fn toggle_appbar(window: HWND) { close_menu(window); + let before = frame_layout::panel_bounds(window).map(|panel| panel.x); edit_layout(window, |layout| layout.toggle()); crate::trace::trace(&format!("appbar: hidden {}", layout_of(window).collapsed)); sync_strip(window); SLIDE.with(|slides| slides.borrow_mut().remove(&(window as isize))); KillTimer(window, PEEK_TIMER); relayout(window); + let after = frame_layout::panel_bounds(window).map(|panel| panel.x); + if needs_size_nudge(before, after, sizing_state(window)) { + nudge_size(window, before, after); + } + // The panel moves with the appbar, and the top bar with it. + request_bar_paint(window); +} + +/// Whether a fold or show must be laid out by a size change: the browser laid the window out +/// again, yet its page panel did not move, as Brave and Whale keep the client area they laid out +/// until the window's size changes. Only a window of its own size is resized: never a minimized, +/// maximized, snapped or fullscreen one. +fn needs_size_nudge(before: Option, after: Option, state: NudgeState) -> bool { + before.is_some() + && before == after + && !state.minimized + && !state.maximized + && !state.snapped + && !state.fullscreen +} + +/// The window's size state, for a size change after a fold or show. +unsafe fn sizing_state(window: HWND) -> NudgeState { + use windows_sys::Win32::UI::WindowsAndMessaging::IsWindowArranged; + NudgeState { + minimized: IsIconic(window) != 0, + maximized: IsZoomed(window) != 0, + snapped: IsWindowArranged(window) != 0, + fullscreen: GetWindowLongPtrW(window, GWL_STYLE) as u32 & WS_THICKFRAME == 0, + ..NudgeState::default() + } +} + +/// Shrinks the window by one pixel without drawing and puts its exact rect back, which lays the +/// browser's root view out at once, and traces what happened. +unsafe fn nudge_size(window: HWND, before: Option, after: Option) { + use windows_sys::Win32::UI::WindowsAndMessaging::{GetWindowRect, SWP_NOREDRAW}; + let mut rect: RECT = std::mem::zeroed(); + GetWindowRect(window, &mut rect); + let (width, height) = (rect.right - rect.left, rect.bottom - rect.top); + SetWindowPos( + window, + std::ptr::null_mut(), + 0, + 0, + width, + height - 1, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOREDRAW, + ); + SetWindowPos( + window, + std::ptr::null_mut(), + rect.left, + rect.top, + width, + height, + SWP_NOZORDER | SWP_NOACTIVATE, + ); + let mut now: RECT = std::mem::zeroed(); + GetWindowRect(window, &mut now); + let restored = (now.left, now.top, now.right, now.bottom) + == (rect.left, rect.top, rect.right, rect.bottom); + crate::trace::trace(&format!( + "appbar: the panel stayed at {before:?} after {after:?}, so window {:#x} was resized by one pixel and back, rect restored {restored}, panel now at {:?}", + window as usize, + frame_layout::panel_bounds(window).map(|panel| panel.x) + )); } /// Shows or puts away the floating appbar of a hidden one, sliding it. @@ -3119,8 +3187,82 @@ pub(crate) fn redraw_appbar(window: crate::os::WindowId) { /// Places the appbar visual over the strip the browser's layout left free and redraws it when its /// size or DPI changed. +/// Paints the top bar alone: its text, caret, hover and list. Typing in the bar changes nothing +/// else of the window's surface, so it never redraws the rest. +pub(crate) unsafe fn update_top_bar(window: HWND) { + if crate::features::address_bar::busy() { + return; + } + let mut laps = Laps::start(); + let dpi = layout_dpi(window); + let top_bar = if framed(window) { + crate::features::address_bar::view(window) + } else { + None + }; + laps.mark("view"); + APPBARS.with(|appbars| { + if let Some(appbar) = appbars.borrow_mut().get_mut(&(window as isize)) { + if let Err(error) = appbar.update_address(window as isize, top_bar.as_ref(), dpi) { + log(&format!("rivet: could not draw the top bar: {error}")); + } + } + }); + laps.mark("paint"); + if laps.total_us() >= SLOW_UPDATE_US && crate::trace::tracing() { + crate::trace::trace(&laps.line().replacen("appbar:", "top bar:", 1)); + } +} + +/// The steps of one surface update and how long each took, in microseconds. +pub(crate) struct Laps { + started: std::time::Instant, + last: std::time::Instant, + steps: Vec<(&'static str, u64)>, +} + +impl Laps { + pub(crate) fn start() -> Self { + let now = std::time::Instant::now(); + Self { + started: now, + last: now, + steps: Vec::new(), + } + } + + /// Ends the step `name` now. + pub(crate) fn mark(&mut self, name: &'static str) { + let now = std::time::Instant::now(); + self.steps + .push((name, now.duration_since(self.last).as_micros() as u64)); + self.last = now; + } + + pub(crate) fn total_us(&self) -> u64 { + self.last.duration_since(self.started).as_micros() as u64 + } + + pub(crate) fn line(&self) -> String { + let steps: Vec = self + .steps + .iter() + .map(|(name, us)| format!("{name} {us}")) + .collect(); + format!( + "appbar: update took {} us: {}", + self.total_us(), + steps.join(" ") + ) + } +} + +/// An update slower than this is traced with its steps. +const SLOW_UPDATE_US: u64 = 2_000; + pub(crate) unsafe fn update_appbar(window: HWND) { crate::features::activity::count(crate::features::activity::Counter::AppbarUpdate); + let mut laps = Laps::start(); let mut client: RECT = std::mem::zeroed(); GetClientRect(window, &mut client); let dpi = layout_dpi(window); @@ -3139,6 +3281,7 @@ pub(crate) unsafe fn update_appbar(window: HWND) { // browser's layout leaves the strip free. The window's own top border runs across the whole // window, so the appbar starts below it exactly like the browser's title bar. let top = top_border(window); + laps.mark("layout"); APPBARS.with(|appbars| { if let Some(appbar) = appbars.borrow_mut().get_mut(&(window as isize)) { let hover = APPBAR_STATE.with(|states| { @@ -3155,6 +3298,7 @@ pub(crate) unsafe fn update_appbar(window: HWND) { if let Err(error) = appbar.update_rows(&rows, dpi) { log(&format!("rivet: could not draw the page rows: {error}")); } + laps.mark("rows"); // A repaint asked for from inside the bar's own work waits for the next one. if !crate::features::address_bar::busy() { let top_bar = if visible { @@ -3166,6 +3310,7 @@ pub(crate) unsafe fn update_appbar(window: HWND) { log(&format!("rivet: could not draw the top bar: {error}")); } } + laps.mark("address"); let preview = if visible { crate::features::link_preview::view(window) } else { @@ -3212,6 +3357,7 @@ pub(crate) unsafe fn update_appbar(window: HWND) { if let Err(error) = appbar.update_dev(bar.as_ref(), dpi) { log(&format!("rivet: could not draw the developer bar: {error}")); } + laps.mark("overlays"); // The warning keeps clear of the corner where the Sidebar's open button shows, which is // the lower right corner of the page area, or of the window when no page area is known. let per_dip = dpi as f32 / 96.0; @@ -3230,6 +3376,7 @@ pub(crate) unsafe fn update_appbar(window: HWND) { "rivet: could not draw the held build warning: {error}" )); } + laps.mark("held"); let (spaces, current) = with_spaces(window, |spaces| (spaces.list().to_vec(), spaces.current())); appbar.set_state( @@ -3241,15 +3388,23 @@ pub(crate) unsafe fn update_appbar(window: HWND) { open_menu_kind(window), ); appbar.set_layout(!layout.collapsed, dragging(window)); + laps.mark("model"); let height = client.bottom - top; if let Err(error) = appbar.update(visible, 0, top, width, height, dpi) { log(&format!("rivet: could not draw the appbar: {error}")); } + laps.mark("appbar"); appbar.set_dark_row(crate::features::dev_page::window_in_dev_mode(window)); let (x, y, size) = { let (x, y, w, _) = toggle_rect(page_x(window)); (scale(x, dpi), scale(y, dpi), scale(w, dpi)) }; + if crate::trace::tracing() { + crate::trace::trace_changed( + "toggle", + &format!("appbar: toggle at {x} {y} {size} px"), + ); + } if let Err(error) = appbar.update_toggle(visible, x, y, size, dpi) { log(&format!("rivet: could not draw the toggle: {error}")); } @@ -3268,10 +3423,16 @@ pub(crate) unsafe fn update_appbar(window: HWND) { "rivet: could not draw the floating appbar: {error}" )); } + laps.mark("toggle"); sync_ground(window, appbar, visible, dpi); + laps.mark("ground"); crate::side_rail::update(window, appbar, visible, dpi); + laps.mark("rail"); } }); + if laps.total_us() >= SLOW_UPDATE_US && crate::trace::tracing() { + crate::trace::trace(&laps.line()); + } } /// The pages of a window as the holes of its ground: each page of a split with its top row, or @@ -3367,9 +3528,26 @@ fn panel_moved(window: HWND) { } } }); + // The top bar is the panel's first row, so it moves with the panel. It is painted after + // this layout, never inside it, and until then its old place would show where the bar no + // longer takes clicks, as after the appbar folds. + PostMessageW(window, bar_moved_message(), 0, 0); } } +const BAR_MOVED_MESSAGE: &str = "Rivet.BarMoved"; + +/// Asks for the top bar to be painted again after the message being handled. +pub(crate) fn request_bar_paint(window: HWND) { + unsafe { + PostMessageW(window, bar_moved_message(), 0, 0); + } +} + +fn bar_moved_message() -> u32 { + unsafe { RegisterWindowMessageW(wide(BAR_MOVED_MESSAGE).as_ptr()) } +} + /// The thickness of the visible window border along the top edge. A maximized window has none. unsafe fn top_border(window: HWND) -> i32 { if IsZoomed(window) != 0 { @@ -3496,6 +3674,11 @@ unsafe fn hit_test(window: HWND, lparam: LPARAM) -> StripHit { && row_hit(window, point).is_none() && crate::dev_bar::hit(window, point).is_none() { + // The bar's controls are Rivet's, at the places it painted them, whatever the browser's + // own hit test would say of its title bar there. + if crate::features::address_bar::part_at(window, point).is_some() { + return StripHit::Client; + } let mut client: RECT = std::mem::zeroed(); GetClientRect(window, &mut client); // The panel starts one gap below the client area's top edge, so the row is measured from it. @@ -3528,6 +3711,22 @@ unsafe extern "system" fn browser_proc( wparam: WPARAM, lparam: LPARAM, ) -> LRESULT { + // Where a character goes when Rivet's bar does not take it: the window with the thread's + // keyboard focus, and the page of the split Rivet last gave the keyboard. + if message == 0x0102 && crate::trace::tracing() { + crate::trace::trace(&format!( + "keys: character {wparam:#x} to window {:#x}, keyboard focus {:#x}, split page {}", + window as usize, + GetFocus() as usize, + crate::features::second_view::focused() + )); + } + if matches!( + message, + windows_sys::Win32::UI::WindowsAndMessaging::WM_SIZE | WM_DPICHANGED + ) { + crate::features::second_view::note_layout(); + } let result = handle(window, message, wparam, lparam); after_event(window, message, wparam); result @@ -3668,6 +3867,10 @@ unsafe fn handle(window: HWND, message: u32, wparam: WPARAM, lparam: LPARAM) -> toggle_appbar(window); return 0; } + if message == bar_moved_message() && attached(window) { + update_top_bar(window); + return 0; + } // Rivet's top bar takes the pointer over it and, while its address is edited, the keyboard and // the input method. if attached(window) && !menu_open(window) && !tab_dragging(window) && !dragging(window) { @@ -4046,6 +4249,7 @@ unsafe fn handle(window: HWND, message: u32, wparam: WPARAM, lparam: LPARAM) -> StripHit::Top => HTTOP as LRESULT, StripHit::TopLeft => HTTOPLEFT as LRESULT, StripHit::Appbar | StripHit::Resizer => APPBAR_HIT as LRESULT, + StripHit::Client => 1, // HTCLIENT StripHit::Minimize => HTMINBUTTON as LRESULT, StripHit::Maximize => HTMAXBUTTON as LRESULT, StripHit::Close => HTCLOSE as LRESULT, @@ -4577,6 +4781,61 @@ unsafe fn handle(window: HWND, message: u32, wparam: WPARAM, lparam: LPARAM) -> } } +#[cfg(test)] +mod nudge_tests { + use super::*; + + #[test] + fn fr2_a_fold_resizes_the_window_only_when_its_panel_did_not_move() { + let normal = NudgeState::default(); + // The relayout moved the panel: nothing more. + assert!(!needs_size_nudge(Some(268), Some(8), normal)); + // The panel stayed where it was: a size change lays it out. + assert!(needs_size_nudge(Some(268), Some(268), normal)); + // No panel known yet: the window's own first layout comes. + assert!(!needs_size_nudge(None, None, normal)); + // Never a window without a size of its own. + for state in [ + NudgeState { + minimized: true, + ..normal + }, + NudgeState { + maximized: true, + ..normal + }, + NudgeState { + snapped: true, + ..normal + }, + NudgeState { + fullscreen: true, + ..normal + }, + ] { + assert!(!needs_size_nudge(Some(268), Some(268), state)); + } + } +} + +#[cfg(test)] +mod laps_tests { + use super::*; + + #[test] + fn nfr2_a_slow_surface_update_names_its_steps() { + let mut laps = Laps::start(); + laps.mark("rows"); + laps.mark("appbar"); + let line = laps.line(); + assert!(line.starts_with("appbar: update took "), "{line}"); + assert!(line.contains(" us: rows "), "{line}"); + assert!(line.contains(" appbar "), "{line}"); + assert_eq!(laps.steps.len(), 2); + assert!(laps.total_us() >= laps.steps.iter().map(|(_, us)| *us).sum::()); + } +} + #[cfg(test)] mod tests { #[test] diff --git a/rivet/runtime/windows/src/lib.rs b/rivet/runtime/windows/src/lib.rs index f379f6b..13bea41 100644 --- a/rivet/runtime/windows/src/lib.rs +++ b/rivet/runtime/windows/src/lib.rs @@ -189,6 +189,9 @@ enum StripHit { Resizer, /// Belongs to the appbar's own content. Appbar, + /// A control of Rivet's top bar, which takes its clicks in the client area. + #[cfg_attr(not(windows), allow(dead_code))] + Client, /// The caption buttons, which Windows needs by name for Snap Layouts. #[cfg_attr(not(windows), allow(dead_code))] Minimize, diff --git a/scripts/tests/installer.test.sh b/scripts/tests/installer.test.sh index 0fdbeaa..f2e1e28 100755 --- a/scripts/tests/installer.test.sh +++ b/scripts/tests/installer.test.sh @@ -43,3 +43,23 @@ for needle in 'release-sign $Version $manifest $setup $msi' 'release-check $mani fi done echo "ok fr8 a release is signed and hash-checked when it is built" + +# NFR-5: a package is never made from a binary built with the feature tests' switches, which start +# the browser with sync and sign-in off. +if ! grep -qF 'Get-TestFlagBinaries' "$build"; then + echo "nfr5: build.ps1 does not refuse binaries built with test switches" >&2 + exit 1 +fi +if command -v pwsh >/dev/null 2>&1; then + probe="$repo_root/rivet/target/installer-test-flags" + rm -rf "$probe" && mkdir -p "$probe" + printf 'MZ ... arguments --no-first-run --lang=en-US ...' >"$probe/clean.exe" + printf 'MZ ... --disable-sync --disable-features=msImplicitSignin,msEdgeSignIn,CalculateNativeWinOcclusion,Translate ...' >"$probe/tainted.exe" + found="$(pwsh -NoProfile -Command ". '$repo_root/rivet/installer/windows/test-flags.ps1'; Get-TestFlagBinaries @('$probe/clean.exe', '$probe/tainted.exe') | ForEach-Object { Split-Path -Leaf \$_ }")" + rm -rf "$probe" + if [ "$found" != "tainted.exe" ]; then + echo "nfr5: the test switch check found '$found', not only tainted.exe" >&2 + exit 1 + fi +fi +echo "ok nfr5 a package never carries a binary built with the feature tests' switches" diff --git a/scripts/tests/windows-features-lib.ps1 b/scripts/tests/windows-features-lib.ps1 index 5f4d50f..77e5304 100644 --- a/scripts/tests/windows-features-lib.ps1 +++ b/scripts/tests/windows-features-lib.ps1 @@ -497,3 +497,34 @@ function Test-FirstFrames($Frames, $Steady, [int]$X0, [int]$X1, [int]$Y1) { function Test-VisibleBreach([bool]$OnMonitor, [string]$TestDesktop) { $OnMonitor -and -not $TestDesktop } + +# Why a parked window's scale does not count, or $null when it does. `Scales` are the layout DPIs +# the runtime traced for the window, first to last, and `MonitorDpi` is the DPI of the monitor the +# park is nearest. A window opened on one monitor and parked nearest another of another scale +# rightly takes that monitor's scale. Any other scale, such as the system scale a window level with +# a monitor's band can drop to, would run every row at a scale no person's window has there. +# The work one key cost the runtime, from its `address bar: key` trace lines: the key's messages +# summed, in microseconds, with each step and the queries the key started. +function Get-KeyWork([string[]]$Lines) { + $work = [pscustomobject]@{ Total = 0; Forward = 0; Query = 0; Inline = 0; Draw = 0; Starts = 0; Messages = 0 } + foreach ($line in $Lines) { + if ($line -notmatch '^address bar: key \S+ \S+ took (\d+) us: forward (\d+) query (\d+) inline (\d+) draw (\d+) starts (\d+)$') { continue } + $work.Total += [int64]$Matches[1]; $work.Forward += [int64]$Matches[2]; $work.Query += [int64]$Matches[3] + $work.Inline += [int64]$Matches[4]; $work.Draw += [int64]$Matches[5]; $work.Starts += [int]$Matches[6]; $work.Messages++ + } + $work +} + +# The value below which `Share` of the numbers lie, from the sorted list. +function Get-Percentile([double[]]$Values, [double]$Share) { + if (-not $Values -or $Values.Count -eq 0) { return 0 } + $sorted = @($Values | Sort-Object) + $sorted[[Math]::Min($sorted.Count - 1, [int][Math]::Floor($Share * $sorted.Count))] +} + +function Get-ParkScaleError([int[]]$Scales, [int]$MonitorDpi) { + if (-not $Scales -or $Scales.Count -eq 0 -or $MonitorDpi -le 0) { return $null } + $settled = $Scales[-1] + if ($settled -eq $MonitorDpi) { return $null } + "the parked window settled at scale $settled (first $($Scales[0])), not at its nearest monitor's $MonitorDpi" +} diff --git a/scripts/tests/windows-features.ps1 b/scripts/tests/windows-features.ps1 index a1b05a7..f42e609 100644 --- a/scripts/tests/windows-features.ps1 +++ b/scripts/tests/windows-features.ps1 @@ -16,13 +16,15 @@ # rows run only when no other run is active (windows-test-safety.ps1). param( [string[]]$Browsers = @('chrome', 'edge', 'whale', 'brave', 'samsung'), - [string[]]$Features = @('split', 'sidebar', 'newtab', 'space', 'split-start', 'split-menu', 'split-menu-start', 'split-drag', 'split-drag-start', 'split-shortcut', 'split-one-tab', 'split-maximised', 'split-space', 'split-click', 'split-rows', 'split-drop-top', 'split-drop-bottom', 'split-grid-3', 'split-grid-4', 'bar-layout', 'mini-pop-out', 'mini-move', 'mini-return', 'mini-close', 'tab-sections', 'tab-close-select', 'tab-reopen', 'tab-archive', 'ctrl-t-setting', 'link-new-tab', 'command-bar', 'link-preview', 'rivet-settings', 'link-rules', 'address-suggest', 'appbar-width', 'appbar-peek', 'open-link-running', 'open-link-start', 'credentials-browser', 'credentials-system', 'view-click', 'view-click-other', 'view-keys', 'view-ime', 'tab-moves', 'first-frame', 'fullscreen-sidebar', 'taskbar-identity', 'taskbar-relaunch', 'sidebar-scale-monitor', 'split-panel-look', 'sidebar-floats', 'sidebar-open-button', 'dev-row', 'address-editing', 'security-chip', 'permission-bubble', 'password-bubble', 'typing-keeps-active', 'cli-control', 'cli-input', 'cli-hidden'), + [string[]]$Features = @('split', 'sidebar', 'newtab', 'space', 'split-start', 'split-menu', 'split-menu-start', 'split-drag', 'split-drag-start', 'split-shortcut', 'split-one-tab', 'split-maximised', 'split-space', 'split-click', 'split-rows', 'split-drop-top', 'split-drop-bottom', 'split-grid-3', 'split-grid-4', 'bar-layout', 'bar-folded', 'mini-pop-out', 'mini-move', 'mini-return', 'mini-close', 'tab-sections', 'tab-close-select', 'tab-reopen', 'tab-archive', 'ctrl-t-setting', 'link-new-tab', 'command-bar', 'link-preview', 'rivet-settings', 'link-rules', 'address-suggest', 'appbar-width', 'appbar-peek', 'open-link-running', 'open-link-start', 'credentials-browser', 'credentials-system', 'view-click', 'view-click-other', 'view-keys', 'view-ime', 'tab-moves', 'first-frame', 'fullscreen-sidebar', 'taskbar-identity', 'taskbar-relaunch', 'sidebar-scale-monitor', 'split-panel-look', 'sidebar-floats', 'sidebar-open-button', 'dev-row', 'address-editing', 'security-chip', 'permission-bubble', 'password-bubble', 'typing-keeps-active', 'cli-control', 'cli-input', 'cli-hidden'), [string]$OutDir, [switch]$NoBuild, # A browser build outside its usual folder, such as one a row job downloaded. Needs one -Browsers. [string]$Executable, # The run stops before a row when the output drive has less free space than this. - [double]$MinFreeGigabytes = 20 + [double]$MinFreeGigabytes = 20, + # Builds and runs the optimised build, as the installer ships it, for timing rows. + [switch]$Release ) $ErrorActionPreference = 'Continue' # The whole run, its build and every browser, runs on a desktop of its own. @@ -34,7 +36,7 @@ $Browsers = @($Browsers | ForEach-Object { $_ -split ',' }) $Features = @($Features | ForEach-Object { $_ -split ',' }) $repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $root = Join-Path $repo 'rivet' -$debug = Join-Path $root 'target\debug' +$debug = Join-Path $root $(if ($Release) { 'target\release' } else { 'target\debug' }) # Debug switches and the trace of one launch live in its own test profile, `rivet-debug`, which only # that browser process reads. Tests of other checkouts on this machine never take its commands. @@ -76,6 +78,10 @@ public static class FeatureWin{ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h); + [DllImport("user32.dll")] static extern IntPtr MonitorFromWindow(IntPtr h,uint f); + [DllImport("shcore.dll")] static extern int GetDpiForMonitor(IntPtr m,int type,out uint x,out uint y); + // The effective DPI of the monitor nearest the window, or 0 when it cannot be read. + public static int NearestMonitorDpi(IntPtr h){uint x,y;return GetDpiForMonitor(MonitorFromWindow(h,2),0,out x,out y)==0?(int)x:0;} [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h,uint c); [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr h,StringBuilder s,int n); [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr GetProp(IntPtr h,string n); @@ -173,7 +179,7 @@ if ($NoBuild) { [IO.File]::WriteAllText($f, $orig.Replace($marker, "$push $marker")) Push-Location $root try { - $output = & $cargo build -q 2>&1 + $output = if ($Release) { & $cargo build -q --release 2>&1 } else { & $cargo build -q 2>&1 } $code = $LASTEXITCODE if ($code -ne 0) { $output | Select-Object -Last 40 | ForEach-Object { Write-Host $_ } } } finally { Pop-Location; [IO.File]::WriteAllText($f, $orig) } @@ -232,6 +238,10 @@ $script:ParkW = 1800; $script:ParkH = 1067 # same, so there the park sits level with the primary monitor, where a bubble the browser fits onto # the display stays by the window's row. $script:ParkY = 1000 +# The park's left edge. RIVET_TEST_PARK_X ("-9000") parks left of every monitor instead, nearest the +# primary, so a run takes the primary monitor's scale rather than that of a monitor on its right. +$script:ParkX = 9000 +if ($env:RIVET_TEST_PARK_X -match '^-?\d+$') { $script:ParkX = [int]$env:RIVET_TEST_PARK_X } if ($script:OnRunner) { Add-Type -AssemblyName System.Windows.Forms $script:ParkY = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Y @@ -241,7 +251,7 @@ $script:ParkedSize = $null function Test-Parked($ctx) { $r = New-Object FeatureWin+R; [FeatureWin]::GetWindowRect($ctx.Hwnd, [ref]$r) | Out-Null $size = '{0}x{1}' -f ($r.Ri - $r.L), ($r.B - $r.T) - $placed = $r.L -eq 9000 -and $r.T -eq $script:ParkY + $placed = $r.L -eq $script:ParkX -and $r.T -eq $script:ParkY if (-not $script:OnRunner) { return $placed -and $size -eq ('{0}x{1}' -f $script:ParkW, $script:ParkH) } if (-not $placed) { return $false } $stable = $script:ParkedSize -eq $size @@ -286,7 +296,7 @@ function Open-Scene($ctx, $state, $Url, $Seed, [string[]]$Extra = @()) { $held = 0 foreach ($try in 1..60) { if (Test-Parked $ctx) { $held++; if ($held -ge 6) { break } } - else { $held = 0; [FeatureWin]::SetWindowPos($ctx.Hwnd, [IntPtr]::Zero, 9000, $script:ParkY, $script:ParkW, $script:ParkH, 0x14) | Out-Null } + else { $held = 0; [FeatureWin]::SetWindowPos($ctx.Hwnd, [IntPtr]::Zero, $script:ParkX, $script:ParkY, $script:ParkW, $script:ParkH, 0x14) | Out-Null } Start-Sleep -Milliseconds 250 } if ($held -lt 6 -and -not $ctx.Error) { @@ -295,18 +305,17 @@ function Open-Scene($ctx, $state, $Url, $Seed, [string[]]$Extra = @()) { # whether its profile holds a crash report. $alive = @(Get-MyProcesses $ctx.ExeName $ctx.Profile).Count $dumps = @(Get-ChildItem (Join-Path $ctx.Profile 'Crashpad\reports') -Filter *.dmp -ErrorAction SilentlyContinue).Count - $ctx.Error = "the window did not stay at its off-screen park: it is at $($r.L),$($r.T) $($r.Ri - $r.L)x$($r.B - $r.T), not 9000,$($script:ParkY) $($script:ParkW)x$($script:ParkH); window alive $([FeatureWin]::IsWindow($ctx.Hwnd)), $alive browser processes, $dumps crash reports" + $ctx.Error = "the window did not stay at its off-screen park: it is at $($r.L),$($r.T) $($r.Ri - $r.L)x$($r.B - $r.T), not $($script:ParkX),$($script:ParkY) $($script:ParkW)x$($script:ParkH); window alive $([FeatureWin]::IsWindow($ctx.Hwnd)), $alive browser processes, $dumps crash reports" } [FeatureWin]::PostMessage($ctx.Hwnd, 0x86, [IntPtr]1, [IntPtr]0) | Out-Null Start-Sleep 8 $dpi = Get-LayoutDpi $ctx - # The window must keep the scale it was first laid out at: a park that moves it to another scale - # would run every row at a scale no person's window has there. + # The parked window must settle at the scale of the monitor it is nearest, as a person's window + # there has; Get-ParkScaleError says why another scale does not count. $hex = '{0:x}' -f $ctx.Hwnd.ToInt64() $scales = @(Read-Log | Where-Object { $_ -match "^layout dpi: window 0x$hex at (\d+)$" } | ForEach-Object { [int]($_ -replace '.* at ', '') }) - if ($scales.Count -ge 2 -and $scales[0] -ne $scales[-1] -and -not $ctx.Error) { - $ctx.Error = "the window's scale changed after it was parked: $($scales[0]) then $($scales[-1])" - } + $parkScale = Get-ParkScaleError $scales ([FeatureWin]::NearestMonitorDpi($ctx.Hwnd)) + if ($parkScale -and -not $ctx.Error) { $ctx.Error = $parkScale } if ($dpi) { $ctx.Dpi = $dpi } elseif (-not $ctx.Error) { $ctx.Error = 'the runtime traced no scale for the window' } # Every scene checks that the attach finished: the browser laid the window out for Rivet's frame. if (-not $ctx.Error) { @@ -751,8 +760,11 @@ function Invoke-SplitPath($Name, $shots, [string]$Feature, [string]$Path, [strin $detail = "after the $Path path: $($on.Reason)" if ($ThenClick) { # A click in the page that is not active, posted to the window under it as a real click would be. + # It goes to the middle of the first page as measured in the capture, since a fixed place lands + # in the second page on a small window such as a runner's. + $first = Get-PageRegion $split $script:ColourA $r = New-Object FeatureWin+R; [FeatureWin]::GetWindowRect($ctx.Hwnd, [ref]$r) | Out-Null - $p = New-Object FeatureWin+P; $p.X = $r.L + 700; $p.Y = $r.T + 600 + $p = New-Object FeatureWin+P; $p.X = $r.L + [int](($first.X0 + $first.X1) / 2); $p.Y = $r.T + [int](($first.Y0 + $first.Y1) / 2) $target = [FeatureWin]::Inside($ctx.Hwnd, $p) if ($target -eq [IntPtr]::Zero) { return @('NOT_DRIVEN', $split.Path, 'no window found at the first pane') } $c = New-Object FeatureWin+P; $c.X = $p.X; $c.Y = $p.Y @@ -2500,10 +2512,14 @@ function Invoke-FullscreenSidebar($Name, $shots) { $style = [SurfaceWin]::GetLong($ctx.Hwnd, -16) $bare = [IntPtr]([int64]$style -band (-bnot [int64]0x40000)) [SurfaceWin]::SetLong($ctx.Hwnd, -16, $bare) | Out-Null - # One pixel wider than the parked window and back, which a runner's small display leaves alone. + # Narrower than the parked window by two DIPs and back. A wider window can be held at the most + # Windows allows, which on a runner's small display is the parked width, so nothing would move. + # One pixel at a scale above 100 percent can leave the width in DIPs the same, and the browser + # then lays nothing out. $pr = New-Object FeatureWin+R; [FeatureWin]::GetWindowRect($ctx.Hwnd, [ref]$pr) | Out-Null $pw = $pr.Ri - $pr.L; $ph = $pr.B - $pr.T - [FeatureWin]::SetWindowPos($ctx.Hwnd, [IntPtr]::Zero, 0, 0, $pw + 1, $ph, 0x36) | Out-Null + $narrower = [int][Math]::Ceiling(2 * $s) + [FeatureWin]::SetWindowPos($ctx.Hwnd, [IntPtr]::Zero, 0, 0, $pw - $narrower, $ph, 0x36) | Out-Null Start-Sleep 4 $full = Get-Capture $ctx (Join-Path $shots 'fullscreen-sidebar-2-full.png') [SurfaceWin]::SetLong($ctx.Hwnd, -16, $style) | Out-Null @@ -2985,6 +3001,180 @@ public static class CloakWin{ } '@ +# FR-12, NFR-2: typing in the address bar costs the browser's UI thread little per key. Each key is +# sent as a person's typing arrives, key down, character and key up, and waited for, so the round +# trip is the time until the bar shows it. The runtime traces its own work per message, the query, +# the inline completion and the redraw, which this sums per key. English first, then Korean +# syllables as the input method hands them over. +function Invoke-AddressTyping($Name, $shots) { + $ctx, $state = Start-Scene $Name 'address-typing' $urlA + try { + Open-Scene $ctx $state $urlA $null + if ($ctx.Error -and $ctx.Error -notin @('attach-failed', 'attach-incomplete')) { return @('NOT_DRIVEN', $null, $ctx.Error) } + $one = Get-Capture $ctx (Join-Path $shots 'address-typing-1-page.png') + if (-not $one) { return @('NOT_DRIVEN', $null, 'no capture') } + if (-not (Test-Attached $ctx)) { return @('FAIL', $one.Path, (Get-AttachFailure $ctx)) } + $keys = @() + foreach ($text in 'rivet browser typing', '한국어 주소창 입력 속도를 재는 문장') { + if (-not (Send-Flag $ctx 'rivet-tab.txt' 'bar')) { return @('NOT_DRIVEN', $one.Path, 'the runtime did not take the bar command') } + foreach ($ch in $text.ToCharArray()) { + $vk = if ([int]$ch -lt 128) { [int][char]::ToUpperInvariant($ch) } else { 0xE5 } + $before = @(Read-Log).Count + $r = [IntPtr]::Zero + $clock = [Diagnostics.Stopwatch]::StartNew() + [SurfaceWin]::SendMessageTimeout($ctx.Hwnd, 0x100, [IntPtr]$vk, [IntPtr]1, 2, 5000, [ref]$r) | Out-Null + [SurfaceWin]::SendMessageTimeout($ctx.Hwnd, 0x102, [IntPtr][int]$ch, [IntPtr]1, 2, 5000, [ref]$r) | Out-Null + [SurfaceWin]::SendMessageTimeout($ctx.Hwnd, 0x101, [IntPtr]$vk, [IntPtr]0xC0000001, 2, 5000, [ref]$r) | Out-Null + $trip = $clock.Elapsed.TotalMilliseconds + Start-Sleep -Milliseconds 120 + $work = Get-KeyWork @(Read-Log | Select-Object -Skip $before) + $keys += [pscustomobject]@{ Char = $ch; Trip = $trip; Work = $work } + } + Start-Sleep -Milliseconds 900 + } + $shot = Get-Capture $ctx (Join-Path $shots 'address-typing-2-typed.png') + $log = @(Read-Log) + $ticks = @($log | Where-Object { $_ -match '^address bar: refresh \d+ took (\d+) us' } | ForEach-Object { [double]($_ -replace '^address bar: refresh \d+ took (\d+) us.*$', '$1') / 1000 }) + $measured = @($keys | Where-Object { $_.Work.Messages -gt 0 }) + if ($measured.Count -lt $keys.Count / 2) { return @('NOT_DRIVEN', $shot.Path, "only $($measured.Count) of $($keys.Count) keys reached the bar's trace") } + # The first key builds the browser's autocomplete once for the profile, which later keys reuse. + $later = @($measured | Select-Object -Skip 1) + $ms = @($later | ForEach-Object { $_.Work.Total / 1000 }) + $step = { param($field) '{0:N1}' -f (Get-Percentile @($later | ForEach-Object { $_.Work.$field / 1000 }) 0.5) } + $starts = ($later | ForEach-Object { $_.Work.Starts } | Measure-Object -Maximum).Maximum + $median = Get-Percentile $ms 0.5; $p90 = Get-Percentile $ms 0.9; $max = ($ms | Measure-Object -Maximum).Maximum + $trips = @($later | ForEach-Object { $_.Trip }) + $summary = ('{0} keys: Rivet work per key median {1:N1} ms, p90 {2:N1}, max {3:N1} (median forward {4}, query {5}, inline {6}, draw {7}); round trip median {8:N1} ms, p90 {9:N1}; first key {10:N1} ms; most queries started by one key {11}; {12} re-reads, median {13:N1} ms, max {14:N1}' -f ` + $later.Count, $median, $p90, $max, (& $step 'Forward'), (& $step 'Query'), (& $step 'Inline'), (& $step 'Draw'), ` + (Get-Percentile $trips 0.5), (Get-Percentile $trips 0.9), ($measured[0].Work.Total / 1000), $starts, $ticks.Count, (Get-Percentile $ticks 0.5), (($ticks + 0) | Measure-Object -Maximum).Maximum) + $perKey = ($measured | ForEach-Object { '{0}:{1:N1}/{2:N1}' -f $_.Char, ($_.Work.Total / 1000), $_.Trip }) -join ' ' + $detail = "$summary. per key (work/trip ms): $perKey" + if ($starts -gt 1) { return @('FAIL', $shot.Path, "a key started more than one query. $detail") } + if ($p90 -ge 16) { return @('FAIL', $shot.Path, "typing costs the UI thread too much. $detail") } + @('PASS', $shot.Path, $detail) + } finally { Stop-Scene $ctx } +} + +# FR-12: the top bar's controls answer where they are drawn, with the appbar shown and folded. +# The runtime traces where it painted each control in client pixels. Each control's middle must +# hold what was drawn there, the window's hit test must give it to the control (the client area for +# the bar, the caption codes for the window buttons), and a click there must reach it. Window +# buttons are only hit-tested, never clicked. +function Get-PaintedBar { + $line = @(Read-Log | Where-Object { $_ -match '^address bar: painted at ' }) | Select-Object -Last 1 + if (-not $line -or $line -notmatch 'painted at (-?\d+) (-?\d+) (\d+)x(\d+) px: (.*)$') { return $null } + $bar = [pscustomobject]@{ Line = $line; X = [int]$Matches[1]; Y = [int]$Matches[2]; W = [int]$Matches[3]; H = [int]$Matches[4]; Parts = @{} } + foreach ($m in [regex]::Matches($Matches[5], '(\w+) (-?\d+) (-?\d+)')) { $bar.Parts[$m.Groups[1].Value] = @([int]$m.Groups[2].Value, [int]$m.Groups[3].Value) } + $bar +} + +# How many pixels of a capture between columns X0 and X1 and rows Y0 and Y1 are dark, as a glyph's ink is. +function Get-InkCount($Capture, [int]$X0, [int]$X1, [int]$Y0, [int]$Y1) { + $n = 0 + for ($y = [Math]::Max(0, $Y0); $y -lt [Math]::Min($Capture.Height, $Y1); $y++) { + for ($x = [Math]::Max(0, $X0); $x -lt [Math]::Min($Capture.Width, $X1); $x++) { + $p = $Capture.Pixels[$y * $Capture.Width + $x] + if (((($p -shr 16) -band 255) + (($p -shr 8) -band 255) + ($p -band 255)) -lt 330) { $n++ } + } + } + $n +} + +# What is wrong with the bar's controls of the window as it is now, as a list; empty when nothing is. +function Test-BarControls($ctx, [string]$Stage, $Capture) { + $bar = Get-PaintedBar + if (-not $bar) { return @("$Stage`: the runtime traced no painted bar") } + $r = New-Object FeatureWin+R; [FeatureWin]::GetWindowRect($ctx.Hwnd, [ref]$r) | Out-Null + $o = New-Object FeatureWin+P; $o.X = 0; $o.Y = 0 + [FeatureWin]::ClientToScreen($ctx.Hwnd, [ref]$o) | Out-Null + $dx = $o.X - $r.L; $dy = $o.Y - $r.T + $cy = $bar.Y + [int]($bar.H / 2) + $codes = @{ Minimize = 8; Maximize = 9; Close = 20 } + $bad = @() + foreach ($name in 'Back', 'Forward', 'Reload', 'Address', 'Minimize', 'Maximize', 'Close') { + $p = $bar.Parts[$name] + if (-not $p) { $bad += "$Stage`: $name was not painted"; continue } + $cx = [int](($p[0] + $p[1]) / 2) + $want = if ($codes.ContainsKey($name)) { $codes[$name] } else { 1 } + $hit = Get-HitTest $ctx ($cx + $dx) ($cy + $dy) + if ($hit -ne $want) { $bad += "$Stage`: $name painted at $($p[0])-$($p[1]) hits $hit at its middle, not $want"; continue } + if ($name -in 'Back', 'Reload', 'Close') { + $ink = Get-InkCount $Capture ($p[0] + $dx) ($p[1] + $dx) ($bar.Y + $dy) ($bar.Y + $bar.H + $dy) + if ($ink -lt 4) { $bad += "$Stage`: nothing is drawn where $name was painted ($($p[0])-$($p[1]))"; continue } + } + if ($codes.ContainsKey($name)) { continue } + $before = @(Read-Log).Count + Send-ClientMouse $ctx 0x201 $cx $cy 1 + Start-Sleep -Milliseconds 120 + Send-ClientMouse $ctx 0x202 $cx $cy + $answer = if ($name -eq 'Address') { '^address bar: editing begins' } else { "^address bar: act $name$" } + if (-not (Wait-Log $before $answer 5)) { $bad += "$Stage`: a click on the middle of $name did not reach it" } + if ($name -eq 'Address') { foreach ($i in 1..3) { Send-Key $ctx 0x1B; Start-Sleep -Milliseconds 300 } } + Start-Sleep -Milliseconds 600 + } + $bad +} + +# The appbar's own fold button, where the runtime painted it, clicked as a person clicks it: the +# window's hit test must give it to the appbar, and the press and release go there. +function Send-ToggleClick($ctx) { + $line = @(Read-Log | Where-Object { $_ -match '^appbar: toggle at ' }) | Select-Object -Last 1 + if (-not $line -or $line -notmatch 'toggle at (-?\d+) (-?\d+) (\d+) px') { return 'the runtime traced no fold button' } + $cx = [int]$Matches[1] + [int]([int]$Matches[3] / 2); $cy = [int]$Matches[2] + [int]([int]$Matches[3] / 2) + $r = New-Object FeatureWin+R; [FeatureWin]::GetWindowRect($ctx.Hwnd, [ref]$r) | Out-Null + $o = New-Object FeatureWin+P; $o.X = 0; $o.Y = 0 + [FeatureWin]::ClientToScreen($ctx.Hwnd, [ref]$o) | Out-Null + $hit = Get-HitTest $ctx ($cx + $o.X - $r.L) ($cy + $o.Y - $r.T) + if ($hit -ne 18) { return "the fold button at $cx,$cy hits $hit, not the appbar's 18" } + Send-AppbarNc $ctx 0xA1 $cx $cy + Start-Sleep -Milliseconds 120 + Send-AppbarNc $ctx 0xA2 $cx $cy + $null +} + +# As a person's window: three tabs, one pinned and two in a folder, folded and shown three times, +# with the fold button and with the command, and every control clicked after each change. A held +# build shows its warning over the page meanwhile. +function Invoke-BarFolded($Name, $shots) { + $ctx, $state = Start-Scene $Name 'bar-folded' $urlA + try { + $why = Open-ThreeTabs $ctx $state $shots 'bar-folded' + if ($why) { return @('NOT_DRIVEN', $null, $why) } + foreach ($command in 'pin 2', 'folder 1 Launch Atlas', 'into 0') { + if (-not (Send-Flag $ctx 'rivet-tab.txt' $command)) { return @('NOT_DRIVEN', $null, "the runtime did not take '$command'") } + } + $one = Get-Capture $ctx (Join-Path $shots 'bar-folded-0-shown.png') + if (-not $one) { return @('NOT_DRIVEN', $null, 'no capture') } + $notes = @(); $bad = @(@(Test-BarControls $ctx 'shown' $one)) + $notes += (Get-PaintedBar).Line -replace '^address bar: ', 'shown: ' + $hidden = $false + foreach ($step in 1..6) { + $before = @(Read-Log).Count + # Odd steps fold, even steps show again; the fold button does it but for the third and fourth. + $byButton = $step -notin 3, 4 + if ($byButton) { + $why = Send-ToggleClick $ctx + if ($why) { $bad += "step $step`: $why"; break } + } else { + Send-RivetMessage $ctx 'Rivet.ToggleAppbar' + } + $want = if ($hidden) { 'false' } else { 'true' } + if (-not (Wait-Log $before "^appbar: hidden $want$" 5)) { $bad += "step $step`: the appbar did not $(if ($hidden) { 'show' } else { 'fold' })"; break } + $hidden = -not $hidden + Start-Sleep 3 + $stage = "step $step $(if ($hidden) { 'folded' } else { 'shown' })$(if ($byButton) { ' by the button' } else { ' by the command' })" + $shot = Get-Capture $ctx (Join-Path $shots "bar-folded-$step.png") + $bad += @(Test-BarControls $ctx $stage $shot) + $notes += (Get-PaintedBar).Line -replace '^address bar: ', "$stage`: " + } + $last = Join-Path $shots 'bar-folded-1.png' + $painted = [string]::Join('; ', [string[]]$notes) + if ($bad.Count) { return @('FAIL', $last, ([string]::Join('; ', [string[]]$bad) + '. ' + $painted)) } + $reason = 'with three tabs, a pin and a folder, back, forward, reload, the address and the window buttons answer where they are drawn, shown and after three folds and three shows by the fold button and by the command. ' + $painted + @('PASS', $last, $reason) + } finally { Stop-Scene $ctx } +} + # --- Run safety ----------------------------------------------------------------------------- # Every row runs under RIVET_KEEP_OFFSCREEN with its Rivet files in this run's folder, and the # visible guard stops the run when a test window shows on a monitor, takes the foreground or holds @@ -3037,6 +3227,7 @@ foreach ($name in $Browsers) { 'split-grid-3' { Invoke-SplitGrid $name $shots 'split-grid-3' } 'split-grid-4' { Invoke-SplitGrid $name $shots 'split-grid-4' } 'bar-layout' { Invoke-BarLayout $name $shots } + 'bar-folded' { Invoke-BarFolded $name $shots } 'mini-pop-out' { Invoke-Mini $name $shots 'pop-out' } 'mini-move' { Invoke-Mini $name $shots 'move' } 'mini-return' { Invoke-Mini $name $shots 'return' } @@ -3074,6 +3265,7 @@ foreach ($name in $Browsers) { 'dev-row' { Invoke-DevRow $name $shots } 'address-editing' { if ($env:RIVET_TEST_DESKTOP) { @('NOT_DRIVEN', $null, 'the input method composes only on the desktop that has the keyboard, and a test desktop never has it') } else { Invoke-AddressEditing $name $shots } } 'security-chip' { Invoke-SecurityChip $name $shots } + 'address-typing' { Invoke-AddressTyping $name $shots } 'permission-bubble' { Invoke-PermissionBubble $name $shots } 'password-bubble' { Invoke-PasswordBubble $name $shots } 'view-reflection' { Invoke-ViewReflection $name $shots } diff --git a/scripts/tests/windows-features.selftest.ps1 b/scripts/tests/windows-features.selftest.ps1 index 4d4ade1..3f1bccf 100644 --- a/scripts/tests/windows-features.selftest.ps1 +++ b/scripts/tests/windows-features.selftest.ps1 @@ -377,5 +377,33 @@ foreach ($case in @(@($true, '', $true), @($true, 'WinSta0\rivet-test-1', $false if ($breach) { Write-Host "FAIL nfr5_only_the_persons_desktop_has_visible_monitors : $breach cases"; $failures++ } else { Write-Host 'ok nfr5_only_the_persons_desktop_has_visible_monitors' } +# A parked window counts at its nearest monitor's scale only. +$park = 0 +foreach ($case in @( + # Opened on a 200 percent primary, parked nearest a 125 percent monitor. + @(@(192, 120), 120, $true), + # One monitor at 200 percent: the window dropped to the system scale. + @(@(192, 96), 192, $false), + # Parked nearest a 125 percent monitor but left at the scale it opened at. + @(@(192), 120, $false), + @(@(120), 120, $true), + # No scale traced, or no monitor read: the scene's own checks say so. + @(@(), 120, $true), + @(@(192, 96), 0, $true))) { + if ([bool](-not (Get-ParkScaleError $case[0] $case[1])) -ne $case[2]) { $park++ } +} +if ($park) { Write-Host "FAIL nfr5_a_parked_window_counts_at_its_nearest_monitors_scale_only : $park cases"; $failures++ } +else { Write-Host 'ok nfr5_a_parked_window_counts_at_its_nearest_monitors_scale_only' } + +# A key's work is summed over its messages, and other lines do not count. +$kw = Get-KeyWork @( + 'address bar: key 0x100 0x41 took 900 us: forward 10 query 0 inline 0 draw 890 starts 0', + 'address bar: suggestions for "a": [] inline ""', + 'address bar: key 0x102 0x61 took 5100 us: forward 20 query 4000 inline 80 draw 1000 starts 1', + 'address bar: key 0x101 0x41 took 700 us: forward 5 query 0 inline 0 draw 695 starts 0') +if ($kw.Total -ne 6700 -or $kw.Query -ne 4000 -or $kw.Draw -ne 2585 -or $kw.Starts -ne 1 -or $kw.Messages -ne 3 -or (Get-Percentile @(5, 1, 9, 3) 0.5) -ne 5) { + Write-Host "FAIL nfr2_a_keys_work_is_read_from_its_trace_lines : $($kw | ConvertTo-Json -Compress)"; $failures++ +} else { Write-Host 'ok nfr2_a_keys_work_is_read_from_its_trace_lines' } + if ($failures) { Write-Host "$failures failed"; exit 1 } Write-Host 'windows-features selftest: all passed'