- commit
- 44b5c1152b31f207ff13245e8eaf1bc72cdd4ec0
- parent
- 0d986183270d7d59b785d4147876af0ebb4f973a
- author
- Dami <dami.etoile@gmail.com>
| M | src-tauri/ | 60 | +++++++++++++++++ |
| M | src-tauri/ | 1 | + |
| M | src-tauri/ | 1 | + |
| M | src/ | 103 | ++++++++++++++++++++++++++++++- |
| A | src/ | 87 | +++++++++++++++++++++++++ |
| A | src/ | 86 | +++++++++++++++++++++++++ |
@@ -394,6 +394,53 @@ pub fn development_connection_mode(state: tauri::State<'_, AppState>) -> Develop
394394 }395395 }396396 397+fn validate_client_window_size(width: f64, height: f64) -> Result<(f64, f64), String> {398+ const MIN_WIDTH: f64 = 480.0;399+ const MIN_HEIGHT: f64 = 320.0;400+ const MAX_DIMENSION: f64 = 8192.0;401+ if !width.is_finite()402+ || !height.is_finite()403+ || width.fract() != 0.0404+ || height.fract() != 0.0405+ || !(MIN_WIDTH..=MAX_DIMENSION).contains(&width)406+ || !(MIN_HEIGHT..=MAX_DIMENSION).contains(&height)407+ {408+ return Err("Client window size is outside the supported logical bounds".to_owned());409+ }410+ Ok((width, height))411+}412+413+414+pub fn set_client_window_size(415+ window: tauri::WebviewWindow,416+ logical_width: f64,417+ logical_height: f64,418+ unmaximize: bool,419+) -> Result<bool, String> {420+ let (width, height) = validate_client_window_size(logical_width, logical_height)?;421+ if window422+ .is_fullscreen()423+ .map_err(|error| format!("Could not inspect client fullscreen state: {error}"))?424+ {425+ return Ok(false);426+ }427+ if window428+ .is_maximized()429+ .map_err(|error| format!("Could not inspect client maximize state: {error}"))?430+ {431+ if !unmaximize {432+ return Ok(false);433+ }434+ window435+ .unmaximize()436+ .map_err(|error| format!("Could not restore the client window: {error}"))?;437+ }438+ window439+ .set_size(tauri::LogicalSize::new(width, height))440+ .map_err(|error| format!("Could not set the client stream geometry: {error}"))?;441+ Ok(true)442+}443+397444 398445 pub fn set_client_cursor_grab(window: tauri::WebviewWindow, grab: bool) -> Result<bool, String> {399446 if grab {@@ -454,6 +501,19 @@ mod tests {
454501 assert!(!should_apply_native_cursor_association(false, false));455502 }456503 504+ 505+ fn client_window_size_requires_bounded_integral_logical_dimensions() {506+ assert_eq!(507+ validate_client_window_size(1280.0, 864.0).unwrap(),508+ (1280.0, 864.0)509+ );510+ assert!(validate_client_window_size(479.0, 864.0).is_err());511+ assert!(validate_client_window_size(1280.0, 319.0).is_err());512+ assert!(validate_client_window_size(8193.0, 864.0).is_err());513+ assert!(validate_client_window_size(1280.5, 864.0).is_err());514+ assert!(validate_client_window_size(f64::NAN, 864.0).is_err());515+ }516+457517 458518 fn cursor_focus_reassertion_forces_tao_visibility_transition() {459519 let mut visibility = Vec::new();@@ -50,6 +50,7 @@ fn main() {
5050 commands::auth::key_derive_identity,5151 commands::state::development_connection_mode,5252 commands::state::set_client_cursor_grab,53+ commands::state::set_client_window_size,5354 commands::state::set_webcodecs_available,5455 commands::state::is_webcodecs_available,5556 commands::network::iroh_client_connect,@@ -82,6 +82,11 @@ import {
8282 selectPreferredController,8383 toGamepadInputState,8484 } from './controller-state.mjs';85+import {86+ constrainStreamWindowResize,87+ fitInitialStreamWindow,88+ fitStreamSurface,89+} from './window-geometry.mjs';8590 8691 let controlMode = false;8792 let connected = false;@@ -99,6 +104,9 @@ let inputCapabilities = {
99104 };100105 let frameWidth = 0;101106 let frameHeight = 0;107+let fittedStreamDimensions = null;108+let lastObservedWindowSize = null;109+let streamWindowResizeTimer = null;102110 let pointerSurfaceDimensions = null;103111 let remotePointerPosition = null;104112 let remotePointerVisible = false;@@ -712,6 +720,7 @@ async function connectHost() {
712720 document.getElementById('frame-canvas').style.display = 'block';713721 document.getElementById('placeholder').classList.add('hidden');714722 document.getElementById('control-bar').classList.add('visible');723+ void fitWindowToIncomingStream();715724 updateControlUI();716725 // Land controller users on the deliberate view-only/control boundary.717726 // The connect button is hidden at this point, so retaining its focus@@ -797,6 +806,10 @@ async function disconnect() {
797806 control: false,798807 };799808 pointerSurfaceDimensions = null;809+ fittedStreamDimensions = null;810+ lastObservedWindowSize = null;811+ if (streamWindowResizeTimer !== null) clearTimeout(streamWindowResizeTimer);812+ streamWindowResizeTimer = null;800813 remotePointerPosition = null;801814 remotePointerVisible = false;802815 clearPendingInput();@@ -1154,16 +1167,100 @@ setInterval(() => {
11541167 if (connected) updateStreamStats();11551168 }, 250);11561169 1170+function clientChromeHeight() {1171+ const topbar = document.querySelector('.topbar');1172+ const bottombar = document.querySelector('.bottombar');1173+ return Math.max(0, (topbar?.offsetHeight ?? 0) + (bottombar?.offsetHeight ?? 0));1174+}1175+1176+function sizeCanvasToIncomingStream() {1177+ if (frameWidth < 1 || frameHeight < 1) return;1178+ const main = canvas.parentElement;1179+ if (!main) return;1180+ const bounds = main.getBoundingClientRect();1181+ if (bounds.width <= 0 || bounds.height <= 0) return;1182+ const surface = fitStreamSurface({1183+ frameWidth,1184+ frameHeight,1185+ availableWidth: bounds.width,1186+ availableHeight: bounds.height,1187+ });1188+ canvas.style.width = `${surface.width}px`;1189+ canvas.style.height = `${surface.height}px`;1190+}1191+1192+async function applyClientWindowGeometry(geometry, unmaximize) {1193+ try {1194+ const applied = await invoke('set_client_window_size', {1195+ logicalWidth: geometry.width,1196+ logicalHeight: geometry.height,1197+ unmaximize,1198+ });1199+ if (applied) lastObservedWindowSize = { ...geometry };1200+ return applied;1201+ } catch (error) {1202+ console.warn('could not apply stream window geometry:', error);1203+ return false;1204+ }1205+}1206+1207+async function fitWindowToIncomingStream() {1208+ if (!connected || frameWidth < 1 || frameHeight < 1) return false;1209+ const dimensions = `${frameWidth}x${frameHeight}`;1210+ if (fittedStreamDimensions === dimensions) return false;1211+ const geometry = fitInitialStreamWindow({1212+ frameWidth,1213+ frameHeight,1214+ chromeHeight: clientChromeHeight(),1215+ availableWidth: window.screen.availWidth,1216+ availableHeight: window.screen.availHeight,1217+ });1218+ const applied = await applyClientWindowGeometry(geometry, true);1219+ if (applied) fittedStreamDimensions = dimensions;1220+ return applied;1221+}1222+1223+function scheduleStreamWindowAspectCorrection() {1224+ const observed = { width: window.innerWidth, height: window.innerHeight };1225+ const previous = lastObservedWindowSize ?? observed;1226+ lastObservedWindowSize = observed;1227+ if (streamWindowResizeTimer !== null) clearTimeout(streamWindowResizeTimer);1228+ streamWindowResizeTimer = setTimeout(() => {1229+ streamWindowResizeTimer = null;1230+ if (!connected || frameWidth < 1 || frameHeight < 1) return;1231+ const current = { width: window.innerWidth, height: window.innerHeight };1232+ let geometry;1233+ try {1234+ geometry = constrainStreamWindowResize({1235+ frameWidth,1236+ frameHeight,1237+ chromeHeight: clientChromeHeight(),1238+ width: current.width,1239+ height: current.height,1240+ previousWidth: previous.width,1241+ previousHeight: previous.height,1242+ });1243+ } catch (error) {1244+ console.warn('could not constrain stream window geometry:', error);1245+ return;1246+ }1247+ if (geometry !== null) void applyClientWindowGeometry(geometry, false);1248+ }, 80);1249+}1250+11571251 function processFramePayload(payload, binaryData = null) {11581252 const receivedAt = performance.now();11591253 frontendDeliveryRate.record(receivedAt);11601254 frontendDeliveryCadence.record(receivedAt);11611255 const { width, height, data, codec, keyframe, pts_micros: ptsMicros, discontinuity } = payload;11621256 if (discontinuity) resetAvSyncTelemetry();1257+ const dimensionsChanged = frameWidth !== width || frameHeight !== height;11631258 frameWidth = width;11641259 frameHeight = height;11651260 if (canvas.width !== width) canvas.width = width;11661261 if (canvas.height !== height) canvas.height = height;1262+ if (dimensionsChanged) sizeCanvasToIncomingStream();1263+ if (dimensionsChanged && connected) void fitWindowToIncomingStream();11671264 11681265 if (codec && codec !== activeCodec) {11691266 activeCodec = codec;@@ -2349,7 +2446,11 @@ document.addEventListener('pointerdown', () => {
23492446 document.querySelectorAll('.controller-focus').forEach((item) => item.classList.remove('controller-focus'));23502447 }, { capture: true });23512448 2352-window.addEventListener('resize', renderRemotePointer);2449+window.addEventListener('resize', () => {2450+ sizeCanvasToIncomingStream();2451+ renderRemotePointer();2452+ scheduleStreamWindowAspectCorrection();2453+});23532454 23542455 window.addEventListener('focus', () => {23552456 if (!controlMode || !inputCapabilities.relativePointer) return;@@ -0,0 +1,87 @@
1+const MIN_WINDOW_WIDTH = 480;2+const MIN_STREAM_HEIGHT = 270;3+const MAX_WINDOW_DIMENSION = 8192;4+5+function positiveFinite(value, name) {6+ if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be positive`);7+ return value;8+}9+10+function geometryInputs({ frameWidth, frameHeight, chromeHeight }) {11+ const width = positiveFinite(frameWidth, 'frame width');12+ const height = positiveFinite(frameHeight, 'frame height');13+ const chrome = Number(chromeHeight);14+ if (!Number.isFinite(chrome) || chrome < 0 || chrome > 512) {15+ throw new RangeError('window chrome height is invalid');16+ }17+ return { ratio: width / height, chrome };18+}19+20+function roundedGeometry(width, height) {21+ return Object.freeze({22+ width: Math.round(Math.min(MAX_WINDOW_DIMENSION, width)),23+ height: Math.round(Math.min(MAX_WINDOW_DIMENSION, height)),24+ });25+}26+27+export function fitInitialStreamWindow({28+ frameWidth,29+ frameHeight,30+ chromeHeight,31+ availableWidth,32+ availableHeight,33+ fill = 0.88,34+}) {35+ const { ratio, chrome } = geometryInputs({ frameWidth, frameHeight, chromeHeight });36+ const screenWidth = positiveFinite(availableWidth, 'available width');37+ const screenHeight = positiveFinite(availableHeight, 'available height');38+ if (!Number.isFinite(fill) || fill < 0.5 || fill > 1) throw new RangeError('window fill is invalid');39+40+ const widthLimit = screenWidth * fill;41+ const heightLimit = screenHeight * fill;42+ const streamHeightLimit = Math.max(1, heightLimit - chrome);43+ let width = Math.min(widthLimit, streamHeightLimit * ratio);44+ const minimumWidth = Math.min(MIN_WINDOW_WIDTH, widthLimit, streamHeightLimit * ratio);45+ width = Math.max(minimumWidth, width);46+ return roundedGeometry(width, chrome + width / ratio);47+}48+49+export function constrainStreamWindowResize({50+ frameWidth,51+ frameHeight,52+ chromeHeight,53+ width,54+ height,55+ previousWidth,56+ previousHeight,57+}) {58+ const { ratio, chrome } = geometryInputs({ frameWidth, frameHeight, chromeHeight });59+ const currentWidth = positiveFinite(width, 'window width');60+ const currentHeight = positiveFinite(height, 'window height');61+ const oldWidth = positiveFinite(previousWidth, 'previous window width');62+ const oldHeight = positiveFinite(previousHeight, 'previous window height');63+ const streamHeight = Math.max(1, currentHeight - chrome);64+ const expectedHeight = chrome + currentWidth / ratio;65+ if (Math.abs(expectedHeight - currentHeight) <= 1) return null;66+67+ const widthDelta = Math.abs(currentWidth - oldWidth);68+ const heightDelta = Math.abs(currentHeight - oldHeight);69+ if (widthDelta >= heightDelta) {70+ const adjustedWidth = Math.max(Math.min(MAX_WINDOW_DIMENSION, currentWidth), MIN_WINDOW_WIDTH);71+ return roundedGeometry(adjustedWidth, chrome + adjustedWidth / ratio);72+ }73+74+ const adjustedStreamHeight = Math.max(75+ Math.min(MAX_WINDOW_DIMENSION - chrome, streamHeight),76+ MIN_STREAM_HEIGHT,77+ );78+ return roundedGeometry(adjustedStreamHeight * ratio, chrome + adjustedStreamHeight);79+}80+81+export function fitStreamSurface({ frameWidth, frameHeight, availableWidth, availableHeight }) {82+ const { ratio } = geometryInputs({ frameWidth, frameHeight, chromeHeight: 0 });83+ const widthLimit = positiveFinite(availableWidth, 'available surface width');84+ const heightLimit = positiveFinite(availableHeight, 'available surface height');85+ const width = Math.min(widthLimit, heightLimit * ratio);86+ return roundedGeometry(width, width / ratio);87+}@@ -0,0 +1,86 @@
1+import assert from 'node:assert/strict';2+import test from 'node:test';3+4+import {5+ constrainStreamWindowResize,6+ fitInitialStreamWindow,7+ fitStreamSurface,8+} from './window-geometry.mjs';9+10+test('initial stream window fits the display while preserving the drawable aspect', () => {11+ const geometry = fitInitialStreamWindow({12+ frameWidth: 1280,13+ frameHeight: 800,14+ chromeHeight: 64,15+ availableWidth: 1440,16+ availableHeight: 900,17+ });18+19+ assert.deepEqual(geometry, { width: 1165, height: 792 });20+ assert.ok(Math.abs((geometry.height - 64) - geometry.width / 1.6) <= 1);21+});22+23+test('width-led resize corrects height and height-led resize corrects width', () => {24+ assert.deepEqual(constrainStreamWindowResize({25+ frameWidth: 1280,26+ frameHeight: 800,27+ chromeHeight: 64,28+ width: 1000,29+ height: 700,30+ previousWidth: 900,31+ previousHeight: 700,32+ }), { width: 1000, height: 689 });33+34+ assert.deepEqual(constrainStreamWindowResize({35+ frameWidth: 1280,36+ frameHeight: 800,37+ chromeHeight: 64,38+ width: 1000,39+ height: 800,40+ previousWidth: 1000,41+ previousHeight: 700,42+ }), { width: 1178, height: 800 });43+});44+45+test('already-correct geometry is stable and malformed dimensions fail closed', () => {46+ assert.equal(constrainStreamWindowResize({47+ frameWidth: 16,48+ frameHeight: 10,49+ chromeHeight: 64,50+ width: 1280,51+ height: 864,52+ previousWidth: 1200,53+ previousHeight: 814,54+ }), null);55+56+ assert.throws(() => fitInitialStreamWindow({57+ frameWidth: 0,58+ frameHeight: 800,59+ chromeHeight: 64,60+ availableWidth: 1440,61+ availableHeight: 900,62+ }), /frame width/);63+ assert.throws(() => fitInitialStreamWindow({64+ frameWidth: 1280,65+ frameHeight: 800,66+ chromeHeight: 900,67+ availableWidth: 1440,68+ availableHeight: 900,69+ }), /chrome/);70+});71+72+test('stream surface scales above native resolution without changing aspect ratio', () => {73+ assert.deepEqual(fitStreamSurface({74+ frameWidth: 1280,75+ frameHeight: 800,76+ availableWidth: 1920,77+ availableHeight: 1200,78+ }), { width: 1920, height: 1200 });79+80+ assert.deepEqual(fitStreamSurface({81+ frameWidth: 1280,82+ frameHeight: 800,83+ availableWidth: 1920,84+ availableHeight: 1080,85+ }), { width: 1728, height: 1080 });86+});