From 4aab5eda3e6792440ef54088b7633f593843da18 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Tue, 6 Oct 2026 09:03:09 +0530 Subject: [PATCH 1/2] fix: ios fullscreen behaviour --- docs/ios-port.md | 27 ++- platforms/ios/ads/AMBBannerLayout.swift | 4 + .../ios/runner/FullscreenBackControls.swift | 105 ++++++++++ platforms/ios/runner/WebFullscreen.swift | 44 +++- platforms/ios/runner/WebViewController.swift | 31 ++- platforms/ios/runner/lib/SystemService.swift | 15 +- platforms/ios/runnerTests/SystemUITests.swift | 18 +- .../runnerTests/WebFullscreenMenuTests.swift | 50 +++++ .../ios/runnerTests/WebFullscreenTests.swift | 165 +++++++++++++++ src/lib/fullscreen.js | 2 +- src/platforms/ios/fullscreen.ts | 194 ++++++++++++++++++ src/platforms/ios/index.ts | 7 + tests/unit/iosFullscreen.test.ts | 161 +++++++++++++++ 13 files changed, 789 insertions(+), 34 deletions(-) create mode 100644 platforms/ios/runner/FullscreenBackControls.swift create mode 100644 platforms/ios/runnerTests/WebFullscreenMenuTests.swift create mode 100644 platforms/ios/runnerTests/WebFullscreenTests.swift create mode 100644 src/platforms/ios/fullscreen.ts create mode 100644 tests/unit/iosFullscreen.test.ts diff --git a/docs/ios-port.md b/docs/ios-port.md index 72dc66c..a2a65b1 100644 --- a/docs/ios-port.md +++ b/docs/ios-port.md @@ -480,13 +480,26 @@ click handlers with feedback enabled. Eight focused editor, file-browser and haptic checks pass per edition; the normal-app project check above also used the quick-tools Save button. Physical haptic sensation remains unverified. -Main and preview WebViews enable element fullscreen. The existing `orientation` -plugin API applies portrait/landscape requests only during foreground fullscreen, -releases its policy on exit, reload or unlock, and temporarily restores normal -rotation while inactive. iOS rejects Android Back-handler registration; release -remains safe. Simulator tests verify real fullscreen entry, rotation, restoration -and preview viewport scaling. The main WebView uses an autoresizing child inside -a constrained wrapper because WebKit reparents it and removes its constraints. +The main WebView implements the standard element fullscreen API using a manual +popover and edge-to-edge native layout, preserving iframe state and touch +coordinates without WebKit's browser fullscreen presentation. Preview WebViews +retain WebKit fullscreen. The existing `orientation` plugin API applies +portrait/landscape requests only during foreground fullscreen, releases its policy +on exit, reload or unlock, and temporarily restores normal rotation while inactive. +A completed downward swipe (at least 40 points) starting anywhere across the top +(within 44 points below the top safe-area inset) +reveals the native Back button in main-app fullscreen. It hides after three seconds, +on backgrounding or on exit; tapping restarts its hide timer. A completed left-edge +swipe (at least 60 points) delivers Back directly. `fullscreen.setBackHandler(callback | null)` +receives these actions through `fullscreenbackbutton`; without a handler they exit +fullscreen. A visible modal within the fullscreen owner gets the first opportunity +to handle Escape, regardless of focus, so Back closes Astray's open pause menu and +resumes the game even after interacting with the menu or revealing the native button. +Registration clears on exit or reload, survives orientation unlock, and delivery +is disabled while inactive. Games such as Astray can open their pause menu while +remaining in landscape, then restore the editor through Exit. Simulator tests cover +ownership, callback failure, Back controls, touch coordinates, rotation and restoration. +Motion sensor readings require a physical device; simulated inputs are test fixtures. On iPadOS 18.6 and 26.5 with multitasking enabled, UIKit rejects programmatic orientation changes. The API rejects its promise and clears the requested policy; fullscreen entry/exit and normal device rotation remain available. Keep iPad diff --git a/platforms/ios/ads/AMBBannerLayout.swift b/platforms/ios/ads/AMBBannerLayout.swift index c4f1c6a..4811b11 100644 --- a/platforms/ios/ads/AMBBannerLayout.swift +++ b/platforms/ios/ads/AMBBannerLayout.swift @@ -39,6 +39,10 @@ final class AMBBannerLayout { guard !layingOut, let controller = plugin?.viewController else { return } layingOut = true defer { layingOut = false } + if controller.isFullscreenLayout { + for banner in banners { banner.bannerView?.isHidden = true } + return + } let root = controller.view! let safe = root.safeAreaLayoutGuide.layoutFrame let bottom = min(safe.maxY, root.keyboardLayoutGuide.layoutFrame.minY) diff --git a/platforms/ios/runner/FullscreenBackControls.swift b/platforms/ios/runner/FullscreenBackControls.swift new file mode 100644 index 0000000..02ad823 --- /dev/null +++ b/platforms/ios/runner/FullscreenBackControls.swift @@ -0,0 +1,105 @@ +import UIKit + +@MainActor +final class FullscreenBackControls: NSObject, UIGestureRecognizerDelegate { + private let button = UIButton(type: .system) + private let gesture = UIScreenEdgePanGestureRecognizer() + private let revealGesture = UIPanGestureRecognizer() + private let action: () -> Void + private var available = false + private var hideTask: Task? + + init(controller: UIViewController, action: @escaping () -> Void) { + self.action = action + super.init() + var configuration = UIButton.Configuration.gray() + configuration.image = UIImage(systemName: "chevron.left") + configuration.baseForegroundColor = .label + configuration.background.backgroundColor = .secondarySystemBackground + configuration.cornerStyle = .capsule + button.configuration = configuration + button.accessibilityLabel = "Back" + button.accessibilityIdentifier = "fullscreen-back" + button.isHidden = true + button.addTarget(self, action: #selector(back), for: .touchUpInside) + controller.view.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 44), + button.heightAnchor.constraint(equalToConstant: 44), + button.leadingAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.leadingAnchor, constant: 8), + button.topAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.topAnchor, constant: 8), + ]) + gesture.edges = .left + gesture.isEnabled = false + gesture.delegate = self + gesture.addTarget(self, action: #selector(swiped)) + controller.view.addGestureRecognizer(gesture) + revealGesture.isEnabled = false + revealGesture.delegate = self + revealGesture.maximumNumberOfTouches = 1 + revealGesture.addTarget(self, action: #selector(revealed)) + controller.view.addGestureRecognizer(revealGesture) + } + + func update(active: Bool, enabled: Bool, theme: String) { + available = active && enabled + if !available { hide() } + button.isEnabled = available + button.overrideUserInterfaceStyle = theme == "light" ? .light : .dark + gesture.isEnabled = available + revealGesture.isEnabled = available + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard gestureRecognizer === revealGesture, let view = revealGesture.view else { return true } + return touch.location(in: view).y <= view.safeAreaInsets.top + 44 + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer === revealGesture { + let velocity = revealGesture.velocity(in: revealGesture.view) + return velocity.y > abs(velocity.x) + } + let velocity = gesture.velocity(in: gesture.view) + return velocity.x > abs(velocity.y) + } + + @objc private func back() { + show() + action() + } + + @objc private func revealed() { + let translation = revealGesture.translation(in: revealGesture.view) + if revealGesture.state == .ended, translation.y >= 40, translation.y > abs(translation.x) { + show() + } + } + + @objc private func swiped() { + let translation = gesture.translation(in: gesture.view) + if gesture.state == .ended, translation.x >= 60, translation.x > abs(translation.y) { + action() + } + } + + private func show() { + guard available else { return } + hideTask?.cancel() + button.isHidden = false + hideTask = Task { [weak self] in + do { try await Task.sleep(for: .seconds(3)) } catch { return } + guard !Task.isCancelled else { return } + self?.hide() + } + } + + private func hide() { + hideTask?.cancel() + hideTask = nil + button.isHidden = true + } + + deinit { hideTask?.cancel() } +} diff --git a/platforms/ios/runner/WebFullscreen.swift b/platforms/ios/runner/WebFullscreen.swift index f4f98ea..03b5f87 100644 --- a/platforms/ios/runner/WebFullscreen.swift +++ b/platforms/ios/runner/WebFullscreen.swift @@ -5,25 +5,50 @@ import WebKit final class WebFullscreen: NSObject { private weak var controller: WebViewController? private weak var scene: UIWindowScene? - private var observation: NSKeyValueObservation? private var task: Task? private var pending: Callback? private var requestID = UUID() private var foreground = true + private var controls: FullscreenBackControls! + private var hasBackHandler = false + var isFullscreen: Bool { controller?.isFullscreenLayout == true } private(set) var requestedOrientation: UIInterfaceOrientationMask? init(controller: WebViewController) { self.controller = controller super.init() - observation = controller.webView.observe(\.fullscreenState, options: [.new]) { [weak self] webView, _ in - MainActor.assumeIsolated { - if webView.fullscreenState == .notInFullscreen || webView.fullscreenState == .exitingFullscreen { self?.reset() } - } - } + controls = FullscreenBackControls(controller: controller) { [weak self] in self?.back() } NotificationCenter.default.addObserver(self, selector: #selector(pause), name: UIApplication.willResignActiveNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(resume), name: UIApplication.didBecomeActiveNotification, object: nil) } + func setActive(_ active: Bool, callback: Callback? = nil) { + guard !active || foreground else { callback?.error("Fullscreen requires the foreground app."); return } + if !active { hasBackHandler = false; reset() } + controller?.setFullscreenLayout(active) + callback?.success() + } + + func setBackHandler(_ enabled: Bool, callback: Callback) { + guard !enabled || (foreground && isFullscreen) else { + callback.error("Back handler requires foreground fullscreen."); return + } + hasBackHandler = enabled + callback.success() + } + + func updateControls() { + controls.update(active: isFullscreen, enabled: foreground, theme: controller?.themeType ?? "dark") + } + + func back() { + guard foreground, isFullscreen else { return } + let script = hasBackHandler + ? "document.dispatchEvent(new Event('fullscreenbackbutton'));" + : "Promise.resolve(document.exitFullscreen()).catch(console.error);" + controller?.webView.evaluateJavaScript(script, completionHandler: nil) + } + func setOrientation(_ value: Any?, callback: Callback) { guard let value, !(value is NSNull) else { reset(); callback.success(); return } let mask: UIInterfaceOrientationMask @@ -49,19 +74,16 @@ final class WebFullscreen: NSObject { restorePolicy() } - private var isFullscreen: Bool { - let state = controller?.webView.fullscreenState - return state == .inFullscreen || state == .enteringFullscreen - } - @objc private func pause() { foreground = false + updateControls() if pending != nil { reset() } else { cancelPending(); restorePolicy() } } @objc private func resume() { foreground = true + updateControls() if let mask = requestedOrientation, isFullscreen, let scene { apply(mask, scene: scene) } } diff --git a/platforms/ios/runner/WebViewController.swift b/platforms/ios/runner/WebViewController.swift index 68abce5..4f73540 100644 --- a/platforms/ios/runner/WebViewController.swift +++ b/platforms/ios/runner/WebViewController.swift @@ -10,6 +10,9 @@ final class WebViewController: UIViewController { private(set) var fullscreen: WebFullscreen! private var contentTop: NSLayoutConstraint! private var contentBottom: NSLayoutConstraint! + private var normalLayout: [NSLayoutConstraint] = [] + private var fullscreenLayout: [NSLayoutConstraint] = [] + var isFullscreenLayout: Bool { fullscreenLayout.first?.isActive == true } private var scrollObservation: NSKeyValueObservation? override func viewDidLoad() { @@ -24,7 +27,6 @@ final class WebViewController: UIViewController { config.userContentController = contentController config.preferences.javaScriptCanOpenWindowsAutomatically = true - config.preferences.isElementFullscreenEnabled = true config.allowsInlineMediaPlayback = true webView = AppWebView(frame: .zero, configuration: config) webView.navigationDelegate = self @@ -37,7 +39,6 @@ final class WebViewController: UIViewController { webView.backgroundColor = view.backgroundColor webView.isOpaque = false - // WebKit reparents the WebView for fullscreen, removing its constraints. let content = UIView() view.addSubview(content) content.addSubview(webView) @@ -45,12 +46,19 @@ final class WebViewController: UIViewController { webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] contentTop = content.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) contentBottom = content.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor) - NSLayoutConstraint.activate([ + normalLayout = [ contentTop, content.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), content.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), contentBottom, - ]) + ] + fullscreenLayout = [ + content.topAnchor.constraint(equalTo: view.topAnchor), + content.leadingAnchor.constraint(equalTo: view.leadingAnchor), + content.trailingAnchor.constraint(equalTo: view.trailingAnchor), + content.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ] + NSLayoutConstraint.activate(normalLayout) // The keyboard layout guide resizes the editor; WebKit scrolling would shift it twice. scrollObservation = webView.scrollView.observe(\.contentOffset, options: [.new]) { scrollView, _ in @@ -80,6 +88,16 @@ final class WebViewController: UIViewController { view.layoutIfNeeded() } + func setFullscreenLayout(_ active: Bool) { + NSLayoutConstraint.deactivate(active ? normalLayout : fullscreenLayout) + NSLayoutConstraint.activate(active ? fullscreenLayout : normalLayout) + setThemeType(themeType) + setNeedsUpdateOfHomeIndicatorAutoHidden() + setNeedsUpdateOfScreenEdgesDeferringSystemGestures() + view.layoutIfNeeded() + bridge.adsService?.banners.layout() + } + @objc func appMovedToBackground() { let script = "document.dispatchEvent(new CustomEvent('pause'));" webView.evaluateJavaScript(script, completionHandler: nil) @@ -123,7 +141,9 @@ final class WebViewController: UIViewController { } var statusBarHidden = false - override var prefersStatusBarHidden: Bool { statusBarHidden } + override var prefersStatusBarHidden: Bool { statusBarHidden || isFullscreenLayout } + override var prefersHomeIndicatorAutoHidden: Bool { isFullscreenLayout } + override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { isFullscreenLayout ? .all : [] } var systemConfiguration: [String: Any] { let hardwareKeyboard = GCKeyboard.coalesced != nil @@ -137,6 +157,7 @@ final class WebViewController: UIViewController { func setThemeType(_ type: String) { themeType = type + fullscreen?.updateControls() setNeedsStatusBarAppearanceUpdate() var vc: UIViewController? = parent while let current = vc { diff --git a/platforms/ios/runner/lib/SystemService.swift b/platforms/ios/runner/lib/SystemService.swift index dae3a31..3e29dff 100644 --- a/platforms/ios/runner/lib/SystemService.swift +++ b/platforms/ios/runner/lib/SystemService.swift @@ -18,7 +18,7 @@ final class SystemService: BaseService { share.reset() DispatchQueue.main.async { [weak self] in IncomingLinks.shared.reset() - self?.viewController?.fullscreen.reset() + self?.viewController?.fullscreen.setActive(false) (self?.webView as? AppWebView)?.nativeContextMenuDisabled = false } } @@ -76,14 +76,23 @@ final class SystemService: BaseService { } case "get-intent": DispatchQueue.main.async { IncomingLinks.shared.current(callback) } case "set-intent-handler": DispatchQueue.main.async { IncomingLinks.shared.listen(callback) } + case "set-fullscreen": + DispatchQueue.main.async { [weak self] in + guard let fullscreen = self?.viewController?.fullscreen, + let active = args[safe: 0] as? Bool else { callback.error("Invalid fullscreen request"); return } + fullscreen.setActive(active, callback: callback) + } case "set-fullscreen-orientation": DispatchQueue.main.async { [weak self] in guard let fullscreen = self?.viewController?.fullscreen else { callback.error("View unavailable"); return } fullscreen.setOrientation(args[safe: 0], callback: callback) } case "set-fullscreen-back-handler": - if args[safe: 0] as? Bool == true { callback.error("Android Back handling is unavailable on iOS") } - else { callback.success() } + DispatchQueue.main.async { [weak self] in + guard let fullscreen = self?.viewController?.fullscreen, + let enabled = args[safe: 0] as? Bool else { callback.error("Invalid Back handler request"); return } + fullscreen.setBackHandler(enabled, callback: callback) + } case "set-native-context-menu-disabled": DispatchQueue.main.async { [weak self] in (self?.webView as? AppWebView)?.nativeContextMenuDisabled = (args[safe: 0] as? String == "true") diff --git a/platforms/ios/runnerTests/SystemUITests.swift b/platforms/ios/runnerTests/SystemUITests.swift index 5c3f3a1..a3e1e07 100644 --- a/platforms/ios/runnerTests/SystemUITests.swift +++ b/platforms/ios/runnerTests/SystemUITests.swift @@ -62,7 +62,7 @@ final class SystemUITests: BridgeTestCase { func testFullscreenOrientationRequiresSessionAndRestoresPolicyOnExit() async throws { let webView = try await editorWebView() let scene = try XCTUnwrap(webView.window?.windowScene) - XCTAssertTrue(webView.configuration.preferences.isElementFullscreenEnabled) + XCTAssertFalse(webView.configuration.preferences.isElementFullscreenEnabled) var responder: UIResponder? = webView while responder != nil, !(responder is WebViewController) { responder = responder?.next } let controller = try XCTUnwrap(responder as? WebViewController) @@ -77,16 +77,20 @@ final class SystemUITests: BridgeTestCase { await target.requestFullscreen();return !!document.fullscreenElement; """, arguments: [:], in: nil, contentWorld: .page) for _ in 0..<100 { - if webView.fullscreenState == .inFullscreen { break } + if controller.fullscreen.isFullscreen { break } try await Task.sleep(for: .milliseconds(20)) } - XCTAssertEqual(webView.fullscreenState, .inFullscreen) + XCTAssertTrue(controller.fullscreen.isFullscreen) + XCTAssertEqual(webView.fullscreenState, .notInFullscreen) + XCTAssertTrue(controller.prefersStatusBarHidden) + XCTAssertTrue(controller.prefersHomeIndicatorAutoHidden) + XCTAssertEqual(webView.convert(webView.bounds, to: controller.view), controller.view.bounds) XCTAssertGreaterThan(webView.bounds.width, 300) XCTAssertGreaterThan(webView.bounds.height, 300) try await checkOrientation(webView, scene: scene, controller: controller) _ = try await webView.callAsyncJavaScript("await document.exitFullscreen()", arguments: [:], in: nil, contentWorld: .page) for _ in 0..<100 { - if webView.fullscreenState == .notInFullscreen { break } + if !controller.fullscreen.isFullscreen { break } try await Task.sleep(for: .milliseconds(20)) } XCTAssertNil(AppDelegate.shared?.fullscreenOrientation) @@ -102,11 +106,11 @@ final class SystemUITests: BridgeTestCase { throw error } _ = try await webView.evaluateJavaScript("document.getElementById('ios-fullscreen-fixture')?.remove()") - let backUnsupported = try await webView.callAsyncJavaScript(""" + let backRequiresFullscreen = try await webView.callAsyncJavaScript(""" const call=enabled=>new Promise((resolve,reject)=>Bridge.exec(resolve,reject,'System','set-fullscreen-back-handler',[enabled])); await call(false);try {await call(true);return false;}catch{return true;} """, arguments: [:], in: nil, contentWorld: .page) as? Bool - XCTAssertEqual(backUnsupported, true) + XCTAssertEqual(backRequiresFullscreen, true) } func testThemeAppliesSystemBarBackgroundColor() async throws { @@ -145,7 +149,7 @@ final class SystemUITests: BridgeTestCase { XCTAssertTrue(error.contains("windowing mode"), error) XCTAssertNil(AppDelegate.shared?.fullscreenOrientation) XCTAssertNil(controller.fullscreen.requestedOrientation) - XCTAssertEqual(webView.fullscreenState, .inFullscreen) + XCTAssertTrue(controller.fullscreen.isFullscreen) _ = try await webView.callAsyncJavaScript("await acode.require('orientation').unlock()", arguments: [:], in: nil, contentWorld: .page) XCTAssertNil(AppDelegate.shared?.fullscreenOrientation) return diff --git a/platforms/ios/runnerTests/WebFullscreenMenuTests.swift b/platforms/ios/runnerTests/WebFullscreenMenuTests.swift new file mode 100644 index 0000000..57aa213 --- /dev/null +++ b/platforms/ios/runnerTests/WebFullscreenMenuTests.swift @@ -0,0 +1,50 @@ +import XCTest +import WebKit +@testable import runner + +@MainActor +final class WebFullscreenMenuTests: BridgeTestCase { + func testRepeatedBackDismissesMenuAfterFocusMovesOutsideIt() async throws { + let webView = try await editorWebView() + var responder: UIResponder? = webView + while responder != nil, !(responder is WebViewController) { responder = responder?.next } + let controller = try XCTUnwrap(responder as? WebViewController) + _ = try await webView.callAsyncJavaScript(""" + const owner=document.createElement('div');owner.id='back-menu-fixture';document.body.append(owner); + const shadow=owner.attachShadow({mode:'open'}); + shadow.innerHTML=''; + const menu=shadow.querySelector('section'); + owner.addEventListener('keydown',event=>{ + if(event.key==='Escape'&&!menu.hidden){menu.hidden=true;event.preventDefault();} + }); + await owner.requestFullscreen(); + window.fixtureBackCount=0; + await acode.require('fullscreen').setBackHandler(()=>{window.fixtureBackCount++;menu.hidden=false;menu.querySelector('button').focus();}); + """, arguments: [:], in: nil, contentWorld: .page) + do { + for _ in 0..<3 { + controller.fullscreen.back() + try await wait(webView, hidden: false) + _ = try await webView.evaluateJavaScript("document.getElementById('back-menu-fixture').shadowRoot.getElementById('game').focus()") + controller.fullscreen.back() + try await wait(webView, hidden: true) + XCTAssertTrue(controller.fullscreen.isFullscreen) + } + let count = try await webView.evaluateJavaScript("window.fixtureBackCount") as? Int + XCTAssertEqual(count, 3, "Dismissal called Pause again instead of closing the menu") + } catch { + _ = try? await webView.callAsyncJavaScript("await document.exitFullscreen();document.getElementById('back-menu-fixture')?.remove();delete window.fixtureBackCount", arguments: [:], in: nil, contentWorld: .page) + throw error + } + _ = try await webView.callAsyncJavaScript("await document.exitFullscreen();document.getElementById('back-menu-fixture').remove();delete window.fixtureBackCount", arguments: [:], in: nil, contentWorld: .page) + } + + private func wait(_ webView: WKWebView, hidden: Bool) async throws { + for _ in 0..<100 { + let actual = try await webView.evaluateJavaScript("document.getElementById('back-menu-fixture').shadowRoot.querySelector('section').hidden") as? Bool + if actual == hidden { return } + try await Task.sleep(for: .milliseconds(20)) + } + throw NSError(domain: "FullscreenMenuTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Back did not toggle menu visibility"]) + } +} diff --git a/platforms/ios/runnerTests/WebFullscreenTests.swift b/platforms/ios/runnerTests/WebFullscreenTests.swift new file mode 100644 index 0000000..d10a10c --- /dev/null +++ b/platforms/ios/runnerTests/WebFullscreenTests.swift @@ -0,0 +1,165 @@ +import XCTest +import WebKit +@testable import runner + +@MainActor +final class WebFullscreenTests: BridgeTestCase { + func testShadowFullscreenPreservesIframeAndHitTargetsAcrossRepeatedEntryAndExit() async throws { + let webView = try await editorWebView() + let parent = webView.superview + let fixture = AppFiles.shared.cache.appendingPathComponent("fullscreen-touch.html") + try Data("".utf8).write(to: fixture) + defer { try? FileManager.default.removeItem(at: fixture) } + let result = try await webView.callAsyncJavaScript(""" + const host=document.createElement('div');host.style.cssText='transform:translate(40px,80px);width:100px;height:100px;overflow:hidden';document.body.append(host); + const shadow=host.attachShadow({mode:'open'}); + const target=document.createElement('section');shadow.append(target); + const iframe=document.createElement('iframe');iframe.style.cssText='width:100%;height:100%;border:0'; + const ready=new Promise(r=>iframe.onload=r); + iframe.src='acode://localhost/__cache__/fullscreen-touch.html'; + target.append(iframe);await Promise.race([ready,new Promise((resolve,reject)=>setTimeout(()=>reject(Error('Iframe fixture failed to load')),5000))]); + const original=iframe.contentWindow.document;original.body.dataset.retained='yes'; + try { + for(let n=0;n<3;n++) { + await target.requestFullscreen(); + const rect=target.getBoundingClientRect(); + if(document.fullscreenElement!==host||shadow.fullscreenElement!==target)throw Error('Shadow owner was not retargeted'); + if(rect.x!==0||rect.y!==0||rect.width!==innerWidth||rect.height!==innerHeight)throw Error('Fullscreen viewport mismatch: '+JSON.stringify(rect)); + if(iframe.contentWindow.document!==original||original.body.dataset.retained!=='yes')throw Error('Iframe reloaded'); + const frame=iframe.getBoundingClientRect(),button=original.getElementById('touch').getBoundingClientRect(); + const x=button.x+button.width/2,y=button.y+button.height/2; + if(shadow.elementFromPoint(frame.x+x,frame.y+y)!==iframe||original.elementFromPoint(x,y).id!=='touch')throw Error('Touch target shifted'); + await document.exitFullscreen(); + if(document.fullscreenElement||shadow.fullscreenElement||target.hasAttribute('popover'))throw Error('Fullscreen did not exit cleanly'); + } + await target.requestFullscreen(); + host.remove(); + for(let n=0;n<100&&target.hasAttribute('popover');n++)await new Promise(r=>setTimeout(r,20)); + if(target.hasAttribute('popover'))throw Error('Removed owner kept fullscreen'); + return true; + } finally {await document.exitFullscreen();host.remove();} + """, arguments: [:], in: nil, contentWorld: .page) as? Bool + XCTAssertEqual(result, true) + XCTAssertTrue(webView.superview === parent) + let controller = try controller(for: webView) + XCTAssertFalse(controller.fullscreen.isFullscreen) + XCTAssertNil(controller.fullscreen.requestedOrientation) + XCTAssertEqual(webView.fullscreenState, .notInFullscreen) + XCTAssertEqual(webView.frame.width, controller.view.safeAreaLayoutGuide.layoutFrame.width, accuracy: 1) + } + + func testNavigationRestoresNativeFullscreenAndStatusBarPreference() async throws { + let webView = try await editorWebView() + let controller = try controller(for: webView) + let hidden = controller.statusBarHidden + controller.statusBarHidden = true + defer { controller.statusBarHidden = hidden; controller.setThemeType(controller.themeType) } + _ = try await webView.callAsyncJavaScript("await document.body.requestFullscreen();await acode.require('fullscreen').setBackHandler(()=>{});", arguments: [:], in: nil, contentWorld: .page) + XCTAssertTrue(controller.fullscreen.isFullscreen) + XCTAssertTrue(controller.prefersStatusBarHidden) + webView.reload() + try await Task.sleep(for: .milliseconds(200)) + _ = try await editorWebView() + XCTAssertFalse(controller.fullscreen.isFullscreen) + XCTAssertNil(controller.fullscreen.requestedOrientation) + XCTAssertEqual(controller.statusBarHidden, true) + XCTAssertTrue(controller.prefersStatusBarHidden) + _ = try await webView.callAsyncJavaScript("await document.body.requestFullscreen()", arguments: [:], in: nil, contentWorld: .page) + controller.fullscreen.back() + try await wait(webView, until: "!document.fullscreenElement") + } + + func testBackControlsRouteToHandlerAndRestoreDefaultExitAfterRelease() async throws { + let webView = try await editorWebView() + let controller = try controller(for: webView) + let button = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIButton }.first { $0.accessibilityIdentifier == "fullscreen-back" }) + let gesture = try XCTUnwrap(controller.view.gestureRecognizers?.compactMap { $0 as? UIScreenEdgePanGestureRecognizer }.first) + XCTAssertTrue(button.isHidden) + XCTAssertFalse(gesture.isEnabled) + let theme = controller.themeType + defer { controller.setThemeType(theme) } + _ = try await webView.callAsyncJavaScript(""" + window.fullscreenBackCount=0; + await document.body.requestFullscreen(); + await acode.require('fullscreen').setBackHandler(()=>window.fullscreenBackCount++); + """, arguments: [:], in: nil, contentWorld: .page) + controller.view.layoutIfNeeded() + XCTAssertTrue(button.isHidden) + XCTAssertTrue(gesture.isEnabled) + XCTAssertEqual(button.bounds.size, CGSize(width: 44, height: 44)) + XCTAssertEqual(button.frame.minX, controller.view.safeAreaInsets.left + 8, accuracy: 1) + XCTAssertEqual(button.frame.minY, controller.view.safeAreaInsets.top + 8, accuracy: 1) + XCTAssertEqual(button.accessibilityLabel, "Back") + controller.setThemeType("light") + XCTAssertEqual(button.overrideUserInterfaceStyle, .light) + controller.setThemeType("dark") + XCTAssertEqual(button.overrideUserInterfaceStyle, .dark) + button.sendActions(for: .touchUpInside) + try await wait(webView, until: "window.fullscreenBackCount===1") + _ = try await webView.callAsyncJavaScript("await acode.require('orientation').unlock()", arguments: [:], in: nil, contentWorld: .page) + controller.fullscreen.back() + try await wait(webView, until: "window.fullscreenBackCount===2") + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + XCTAssertFalse(button.isEnabled) + XCTAssertFalse(gesture.isEnabled) + controller.fullscreen.back() + let pausedCount = try await webView.evaluateJavaScript("window.fullscreenBackCount") as? Int + XCTAssertEqual(pausedCount, 2) + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + XCTAssertTrue(button.isEnabled) + XCTAssertTrue(gesture.isEnabled) + _ = try await webView.callAsyncJavaScript("await acode.require('fullscreen').setBackHandler(null)", arguments: [:], in: nil, contentWorld: .page) + button.sendActions(for: .touchUpInside) + try await wait(webView, until: "!document.fullscreenElement") + XCTAssertTrue(button.isHidden) + XCTAssertFalse(gesture.isEnabled) + _ = try await webView.callAsyncJavaScript("await document.body.requestFullscreen()", arguments: [:], in: nil, contentWorld: .page) + controller.fullscreen.back() + try await wait(webView, until: "!document.fullscreenElement") + } + + func testBackFailureCannotExitANewerFullscreenOwner() async throws { + let webView = try await editorWebView() + let controller = try controller(for: webView) + _ = try await webView.callAsyncJavaScript(""" + await document.body.requestFullscreen(); + await acode.require('fullscreen').setBackHandler(()=>{throw Error('fixture');}); + """, arguments: [:], in: nil, contentWorld: .page) + controller.fullscreen.back() + try await wait(webView, until: "!document.fullscreenElement") + _ = try await webView.callAsyncJavaScript(""" + await document.body.requestFullscreen(); + await acode.require('fullscreen').setBackHandler(()=>new Promise((resolve,reject)=>window.rejectFullscreenBack=reject)); + """, arguments: [:], in: nil, contentWorld: .page) + controller.fullscreen.back() + try await wait(webView, until: "!!window.rejectFullscreenBack") + let retained = try await webView.callAsyncJavaScript(""" + await document.exitFullscreen(); + const target=document.createElement('div');target.id='new-fullscreen-owner';document.body.append(target); + await target.requestFullscreen(); + window.fullscreenBackCount=0; + await acode.require('fullscreen').setBackHandler(()=>window.fullscreenBackCount++); + window.rejectFullscreenBack(Error('obsolete'));delete window.rejectFullscreenBack; + await new Promise(r=>setTimeout(r,50)); + return document.fullscreenElement===target; + """, arguments: [:], in: nil, contentWorld: .page) as? Bool + XCTAssertEqual(retained, true) + controller.fullscreen.back() + try await wait(webView, until: "window.fullscreenBackCount===1") + _ = try await webView.callAsyncJavaScript("await document.exitFullscreen();document.getElementById('new-fullscreen-owner').remove()", arguments: [:], in: nil, contentWorld: .page) + } + + private func wait(_ webView: WKWebView, until expression: String) async throws { + for _ in 0..<100 { + if (try await webView.evaluateJavaScript(expression)) as? Bool == true { return } + try await Task.sleep(for: .milliseconds(20)) + } + XCTFail("Fullscreen did not reach: " + expression) + } + + private func controller(for webView: WKWebView) throws -> WebViewController { + var responder: UIResponder? = webView + while responder != nil, !(responder is WebViewController) { responder = responder?.next } + return try XCTUnwrap(responder as? WebViewController) + } +} diff --git a/src/lib/fullscreen.js b/src/lib/fullscreen.js index e2d2da1..950e8f5 100644 --- a/src/lib/fullscreen.js +++ b/src/lib/fullscreen.js @@ -1,4 +1,4 @@ -/** Opt-in Android Back delivery for the current browser fullscreen owner. */ +/** Opt-in Back delivery for the current fullscreen owner on Android and iOS. */ let owner = fullscreenElement(); let generation = 0; let registration = null; diff --git a/src/platforms/ios/fullscreen.ts b/src/platforms/ios/fullscreen.ts new file mode 100644 index 0000000..172e61c --- /dev/null +++ b/src/platforms/ios/fullscreen.ts @@ -0,0 +1,194 @@ +// Keep the app's WebView in place: WebKit's browser presentation changes its +// coordinate space and adds browser controls over plugin content. +export default function installFullscreen( + setActive: (active: boolean) => Promise, +) { + let owner: HTMLElement | null = null; + let restore: (() => void) | undefined; + let queue = Promise.resolve(); + const backKeys = new WeakSet(); + const observer = new MutationObserver(() => { + if (owner && !owner.isConnected) void exit(); + }); + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get() { + return retarget(document); + }, + }); + Object.defineProperty(ShadowRoot.prototype, "fullscreenElement", { + configurable: true, + get() { + return retarget(this); + }, + }); + Object.defineProperty(document, "fullscreenEnabled", { + configurable: true, + value: true, + }); + Element.prototype.requestFullscreen = function () { + const target = this; + return schedule(async () => { + if ( + !(target instanceof HTMLElement) || + target.ownerDocument !== document || + !target.isConnected + ) + throw new TypeError("Fullscreen requires a connected app element."); + if (owner === target) return; + if ( + owner || + target.matches(":popover-open") || + target instanceof HTMLDialogElement + ) + throw new TypeError("Another presentation is already active."); + const style = document.createElement("style"); + style.textContent = `[data-acode-fullscreen] { + position: fixed !important; inset: 0 !important; + width: 100% !important; height: 100% !important; + max-width: none !important; max-height: none !important; + margin: 0 !important; padding: 0 !important; border: 0 !important; + box-sizing: border-box !important; + }`; + const root = target.getRootNode(); + (root instanceof ShadowRoot ? root : document.head).append(style); + const popover = target.getAttribute("popover"); + const marker = target.getAttribute("data-acode-fullscreen"); + restore = () => { + target.hidePopover(); + if (popover === null) target.removeAttribute("popover"); + else target.setAttribute("popover", popover); + if (marker === null) target.removeAttribute("data-acode-fullscreen"); + else target.setAttribute("data-acode-fullscreen", marker); + style.remove(); + }; + try { + target.setAttribute("popover", "manual"); + target.setAttribute("data-acode-fullscreen", ""); + target.showPopover(); + await setActive(true); + if (!target.isConnected) + throw new TypeError("Fullscreen element was removed."); + owner = target; + observer.observe(document, { childList: true, subtree: true }); + let ancestor: Node = target; + while (ancestor.getRootNode() instanceof ShadowRoot) { + const tree = ancestor.getRootNode() as ShadowRoot; + observer.observe(tree, { childList: true, subtree: true }); + ancestor = tree.host; + } + target.addEventListener("toggle", toggled); + await resized(); + changed(target); + } catch (error) { + owner = null; + restore?.(); + restore = undefined; + await setActive(false); + throw error; + } + }); + }; + document.exitFullscreen = exit; + document.addEventListener("fullscreenbackbutton", closeMenu, true); + document.addEventListener("keydown", (event) => { + if ( + event.key === "Escape" && + owner && + !event.defaultPrevented && + !backKeys.has(event) + ) { + event.preventDefault(); + void exit(); + } + }); + + function closeMenu(event: Event) { + if (!owner) return; + let menu: Element | undefined; + const trees: (Element | ShadowRoot)[] = [owner]; + for (const tree of trees) { + const elements = + tree instanceof Element + ? [tree, ...tree.querySelectorAll("*")] + : tree.querySelectorAll("*"); + for (const element of elements) { + if (element.shadowRoot) trees.push(element.shadowRoot); + if ( + element.matches('dialog[open], [role="dialog"][aria-modal="true"]') && + !element.hasAttribute("hidden") && + element.checkVisibility({ + visibilityProperty: true, + opacityProperty: true, + }) + ) + menu = element; + } + } + if (!menu) return; + let focused = document.activeElement; + while (focused?.shadowRoot?.activeElement) + focused = focused.shadowRoot.activeElement; + const escape = new KeyboardEvent("keydown", { + key: "Escape", + code: "Escape", + bubbles: true, + composed: true, + cancelable: true, + }); + backKeys.add(escape); + (focused && menu.contains(focused) ? focused : menu).dispatchEvent(escape); + if (escape.defaultPrevented) event.stopImmediatePropagation(); + } + + function schedule(action: () => Promise) { + const result = queue.then(action); + queue = result.catch(() => {}); + return result; + } + function retarget(root: Document | ShadowRoot) { + let target: Element | null = owner; + while (target) { + const tree = target.getRootNode(); + if (tree === root) return target; + if (!(tree instanceof ShadowRoot)) return null; + target = tree.host; + } + return null; + } + function exit() { + return schedule(async () => { + if (!owner) return; + const target = owner; + await setActive(false); + owner = null; + observer.disconnect(); + target.removeEventListener("toggle", toggled); + restore?.(); + restore = undefined; + await resized(); + changed(target); + }); + } + function toggled(event: Event) { + if (owner && (event as ToggleEvent).newState === "closed") void exit(); + } + function changed(target: HTMLElement) { + const event = new Event("fullscreenchange", { + bubbles: true, + composed: true, + }); + if (target.isConnected) target.dispatchEvent(event); + else document.dispatchEvent(event); + } + function resized() { + return new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(() => { + window.dispatchEvent(new Event("resize")); + resolve(); + }), + ), + ); + } +} diff --git a/src/platforms/ios/index.ts b/src/platforms/ios/index.ts index 7322746..360440a 100644 --- a/src/platforms/ios/index.ts +++ b/src/platforms/ios/index.ts @@ -2,6 +2,7 @@ import { fromArrayBuffer } from "../../native/base64"; import type { NativeExec } from "../../native/bridge"; import { createTransport } from "../../native/bridge"; import installAPITransport from "./api"; +import installFullscreen from "./fullscreen"; import proxy from "./proxy"; export interface IOSReply { @@ -45,5 +46,11 @@ export default function setup() { if (handler) handler(success, error, args); else transport.exec(success, error, service, action, args); }; + installFullscreen( + (active) => + new Promise((resolve, reject) => + transport.exec(resolve, reject, "System", "set-fullscreen", [active]), + ), + ); return exec; } diff --git a/tests/unit/iosFullscreen.test.ts b/tests/unit/iosFullscreen.test.ts new file mode 100644 index 0000000..03b2df7 --- /dev/null +++ b/tests/unit/iosFullscreen.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom +import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest"; +import installFullscreen from "../../src/platforms/ios/fullscreen"; + +const native = vi.fn<(active: boolean) => Promise>().mockResolvedValue(undefined); +const descriptors = [ + [document, "fullscreenElement"], [ShadowRoot.prototype, "fullscreenElement"], + [document, "fullscreenEnabled"], [Element.prototype, "requestFullscreen"], + [document, "exitFullscreen"], [HTMLElement.prototype, "showPopover"], + [HTMLElement.prototype, "hidePopover"], +] as const; +const saved = descriptors.map(([target, key]) => Object.getOwnPropertyDescriptor(target, key)); +beforeAll(() => { + HTMLElement.prototype.showPopover = function () {}; + HTMLElement.prototype.hidePopover = function () {}; + installFullscreen(native); +}); +afterEach(async () => { + await document.exitFullscreen(); + document.body.replaceChildren(); + native.mockClear(); +}); +afterAll(() => { + descriptors.forEach(([target, key], index) => { + const descriptor = saved[index]; + if (descriptor) Object.defineProperty(target, key, descriptor); + else Reflect.deleteProperty(target, key); + }); +}); + +test("enters and exits without moving the target or losing plugin style changes", async () => { + const target = document.createElement("div"); + target.style.cssText = "top:12px;overflow:hidden"; + target.setAttribute("popover", "auto"); + document.body.append(target); + const changed = vi.fn(); + document.addEventListener("fullscreenchange", changed); + await target.requestFullscreen(); + expect(document.fullscreenElement).toBe(target); + expect(target.parentElement).toBe(document.body); + expect(target.hasAttribute("data-acode-fullscreen")).toBe(true); + target.style.color = "red"; + await document.exitFullscreen(); + expect(document.fullscreenElement).toBeNull(); + expect(target.getAttribute("popover")).toBe("auto"); + expect(target.hasAttribute("data-acode-fullscreen")).toBe(false); + expect(target.style.top).toBe("12px"); + expect(target.style.color).toBe("red"); + expect(changed).toHaveBeenCalledTimes(2); + expect(native.mock.calls).toEqual([[true], [false]]); + document.removeEventListener("fullscreenchange", changed); +}); + +test("serializes a pending entry and exit and protects a foreign owner", async () => { + const target = document.createElement("div"); + const other = document.createElement("div"); + document.body.append(target, other); + const entering = target.requestFullscreen(); + const exiting = document.exitFullscreen(); + await Promise.all([entering, exiting]); + expect(document.fullscreenElement).toBeNull(); + await target.requestFullscreen(); + await expect(other.requestFullscreen()).rejects.toThrow(/already active/); + expect(document.fullscreenElement).toBe(target); + await target.requestFullscreen(); + expect(native.mock.calls).toEqual([[true], [false], [true]]); +}); + +test("retargets nested shadow owners and exits when an outer shadow ancestor is removed", async () => { + const host = document.createElement("div"); + document.body.append(host); + const outer = host.attachShadow({ mode: "open" }); + const inner = document.createElement("div"); + outer.append(inner); + const shadow = inner.attachShadow({ mode: "open" }); + const target = document.createElement("div"); + shadow.append(target); + await target.requestFullscreen(); + expect(document.fullscreenElement).toBe(host); + expect(outer.fullscreenElement).toBe(inner); + expect(shadow.fullscreenElement).toBe(target); + inner.remove(); + await vi.waitFor(() => expect(target.hasAttribute("data-acode-fullscreen")).toBe(false)); + expect(document.fullscreenElement).toBeNull(); + expect(native).toHaveBeenLastCalledWith(false); +}); + +test("rolls back a failed native entry and rejects disconnected elements", async () => { + const target = document.createElement("div"); + await expect(target.requestFullscreen()).rejects.toThrow(/connected/); + expect(native).not.toHaveBeenCalled(); + document.body.append(target); + native.mockRejectedValueOnce(new Error("App is backgrounded")); + await expect(target.requestFullscreen()).rejects.toThrow(/backgrounded/); + expect(document.fullscreenElement).toBeNull(); + expect(target.hasAttribute("popover")).toBe(false); + expect(target.hasAttribute("data-acode-fullscreen")).toBe(false); + await target.requestFullscreen(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await vi.waitFor(() => expect(document.fullscreenElement).toBeNull()); +}); + +test.each(["menu", "body", "game"])("Back closes a shadow menu when focus is on %s", async (focus) => { + const owner = document.createElement("div"); + document.body.append(owner); + const shadow = owner.attachShadow({ mode: "open" }); + const menu = document.createElement("div"); + menu.setAttribute("role", "dialog"); + menu.setAttribute("aria-modal", "true"); + const resume = document.createElement("button"); + const game = document.createElement("button"); + menu.append(resume); + shadow.append(menu, game); + await owner.requestFullscreen(); + resume.focus(); + if (focus === "body") resume.blur(); + if (focus === "game") game.focus(); + const back = vi.fn(); + document.addEventListener("fullscreenbackbutton", back); + menu.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + menu.hidden = true; + event.preventDefault(); + }); + document.dispatchEvent(new Event("fullscreenbackbutton")); + expect(menu.hidden).toBe(true); + expect(back).not.toHaveBeenCalled(); + expect(document.fullscreenElement).toBe(owner); + document.removeEventListener("fullscreenbackbutton", back); +}); + +test("Back retains callback delivery when a menu does not handle Escape or belongs to another owner", async () => { + const owner = document.createElement("div"); + const menu = document.createElement("div"); + menu.setAttribute("role", "dialog"); + menu.setAttribute("aria-modal", "true"); + const button = document.createElement("button"); + menu.append(button); + owner.append(menu); + document.body.append(owner); + await owner.requestFullscreen(); + button.focus(); + const back = vi.fn(); + document.addEventListener("fullscreenbackbutton", back); + document.dispatchEvent(new Event("fullscreenbackbutton")); + expect(back).toHaveBeenCalledOnce(); + expect(document.fullscreenElement).toBe(owner); + const escape = vi.fn((event: Event) => event.preventDefault()); + menu.addEventListener("keydown", escape); + menu.hidden = true; + document.dispatchEvent(new Event("fullscreenbackbutton")); + expect(back).toHaveBeenCalledTimes(2); + expect(escape).not.toHaveBeenCalled(); + menu.hidden = false; + document.body.append(menu); + button.focus(); + document.dispatchEvent(new Event("fullscreenbackbutton")); + expect(back).toHaveBeenCalledTimes(3); + expect(escape).not.toHaveBeenCalled(); + document.removeEventListener("fullscreenbackbutton", back); +}); From e111543f2a0e307c244534bd7b2e221ea52de013 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Tue, 6 Oct 2026 10:09:47 +0530 Subject: [PATCH 2/2] fix(ios): preserve fullscreen compatibility and Back behavior - Restore iframe fullscreen support and native ownership tracking. - Apply fullscreen CSS and override inline sizing while preserving plugin styles. - Consume Back when dismissing native dialogs to prevent callback fallthrough. - Keep main-app fullscreen edge-to-edge without browser presentation. --- docs/ios-port.md | 10 +- platforms/ios/runner/WebFullscreen.swift | 17 ++- platforms/ios/runner/WebViewController.swift | 1 + platforms/ios/runnerTests/SystemUITests.swift | 2 +- .../ios/runnerTests/WebFullscreenTests.swift | 42 ++++- src/platforms/ios/fullscreen.ts | 47 +++--- src/platforms/ios/fullscreenLayout.ts | 68 +++++++++ src/platforms/ios/fullscreenStyles.ts | 144 ++++++++++++++++++ tests/unit/iosFullscreen.test.ts | 133 ++++++++++++++++ 9 files changed, 438 insertions(+), 26 deletions(-) create mode 100644 src/platforms/ios/fullscreenLayout.ts create mode 100644 src/platforms/ios/fullscreenStyles.ts diff --git a/docs/ios-port.md b/docs/ios-port.md index a2a65b1..d2db785 100644 --- a/docs/ios-port.md +++ b/docs/ios-port.md @@ -480,10 +480,11 @@ click handlers with feedback enabled. Eight focused editor, file-browser and haptic checks pass per edition; the normal-app project check above also used the quick-tools Save button. Physical haptic sensation remains unverified. -The main WebView implements the standard element fullscreen API using a manual +The main WebView implements element fullscreen using a manual popover and edge-to-edge native layout, preserving iframe state and touch -coordinates without WebKit's browser fullscreen presentation. Preview WebViews -retain WebKit fullscreen. The existing `orientation` plugin API applies +coordinates without WebKit's browser fullscreen presentation. Fullscreen styles +apply to the owner and its shadow hosts; inline layout overrides are restored on +exit. Iframe documents and preview WebViews retain WebKit fullscreen. The existing `orientation` plugin API applies portrait/landscape requests only during foreground fullscreen, releases its policy on exit, reload or unlock, and temporarily restores normal rotation while inactive. A completed downward swipe (at least 40 points) starting anywhere across the top @@ -493,7 +494,8 @@ on backgrounding or on exit; tapping restarts its hide timer. A completed left-e swipe (at least 60 points) delivers Back directly. `fullscreen.setBackHandler(callback | null)` receives these actions through `fullscreenbackbutton`; without a handler they exit fullscreen. A visible modal within the fullscreen owner gets the first opportunity -to handle Escape, regardless of focus, so Back closes Astray's open pause menu and +to handle Escape, regardless of focus; native dialogs receive a cancellable close +request that consumes Back. Back closes Astray's open pause menu and resumes the game even after interacting with the menu or revealing the native button. Registration clears on exit or reload, survives orientation unlock, and delivery is disabled while inactive. Games such as Astray can open their pause menu while diff --git a/platforms/ios/runner/WebFullscreen.swift b/platforms/ios/runner/WebFullscreen.swift index 03b5f87..fe26a33 100644 --- a/platforms/ios/runner/WebFullscreen.swift +++ b/platforms/ios/runner/WebFullscreen.swift @@ -5,19 +5,32 @@ import WebKit final class WebFullscreen: NSObject { private weak var controller: WebViewController? private weak var scene: UIWindowScene? + private var observation: NSKeyValueObservation? private var task: Task? private var pending: Callback? private var requestID = UUID() private var foreground = true private var controls: FullscreenBackControls! private var hasBackHandler = false - var isFullscreen: Bool { controller?.isFullscreenLayout == true } + var isFullscreen: Bool { + let state = controller?.webView.fullscreenState + return controller?.isFullscreenLayout == true || state == .inFullscreen || state == .enteringFullscreen + } private(set) var requestedOrientation: UIInterfaceOrientationMask? init(controller: WebViewController) { self.controller = controller super.init() controls = FullscreenBackControls(controller: controller) { [weak self] in self?.back() } + observation = controller.webView.observe(\.fullscreenState, options: [.new]) { [weak self] webView, _ in + MainActor.assumeIsolated { + guard let self, self.controller?.isFullscreenLayout != true else { return } + if webView.fullscreenState == .notInFullscreen || webView.fullscreenState == .exitingFullscreen { + self.hasBackHandler = false + self.reset() + } + } + } NotificationCenter.default.addObserver(self, selector: #selector(pause), name: UIApplication.willResignActiveNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(resume), name: UIApplication.didBecomeActiveNotification, object: nil) } @@ -38,7 +51,7 @@ final class WebFullscreen: NSObject { } func updateControls() { - controls.update(active: isFullscreen, enabled: foreground, theme: controller?.themeType ?? "dark") + controls.update(active: controller?.isFullscreenLayout == true, enabled: foreground, theme: controller?.themeType ?? "dark") } func back() { diff --git a/platforms/ios/runner/WebViewController.swift b/platforms/ios/runner/WebViewController.swift index 4f73540..6fcb6a7 100644 --- a/platforms/ios/runner/WebViewController.swift +++ b/platforms/ios/runner/WebViewController.swift @@ -26,6 +26,7 @@ final class WebViewController: UIViewController { contentController.add(WeakScriptMessageHandler(bridge), name: "exec") config.userContentController = contentController config.preferences.javaScriptCanOpenWindowsAutomatically = true + config.preferences.isElementFullscreenEnabled = true config.allowsInlineMediaPlayback = true webView = AppWebView(frame: .zero, configuration: config) diff --git a/platforms/ios/runnerTests/SystemUITests.swift b/platforms/ios/runnerTests/SystemUITests.swift index a3e1e07..d38b400 100644 --- a/platforms/ios/runnerTests/SystemUITests.swift +++ b/platforms/ios/runnerTests/SystemUITests.swift @@ -62,7 +62,7 @@ final class SystemUITests: BridgeTestCase { func testFullscreenOrientationRequiresSessionAndRestoresPolicyOnExit() async throws { let webView = try await editorWebView() let scene = try XCTUnwrap(webView.window?.windowScene) - XCTAssertFalse(webView.configuration.preferences.isElementFullscreenEnabled) + XCTAssertTrue(webView.configuration.preferences.isElementFullscreenEnabled) var responder: UIResponder? = webView while responder != nil, !(responder is WebViewController) { responder = responder?.next } let controller = try XCTUnwrap(responder as? WebViewController) diff --git a/platforms/ios/runnerTests/WebFullscreenTests.swift b/platforms/ios/runnerTests/WebFullscreenTests.swift index d10a10c..18c7597 100644 --- a/platforms/ios/runnerTests/WebFullscreenTests.swift +++ b/platforms/ios/runnerTests/WebFullscreenTests.swift @@ -13,23 +13,37 @@ final class WebFullscreenTests: BridgeTestCase { let result = try await webView.callAsyncJavaScript(""" const host=document.createElement('div');host.style.cssText='transform:translate(40px,80px);width:100px;height:100px;overflow:hidden';document.body.append(host); const shadow=host.attachShadow({mode:'open'}); - const target=document.createElement('section');shadow.append(target); - const iframe=document.createElement('iframe');iframe.style.cssText='width:100%;height:100%;border:0'; + const style=document.createElement('style'); + style.textContent=':host(:fullscreen){color:rgb(0,255,0)} section:fullscreen{background:rgb(0,255,0)} section:not(:fullscreen){background:rgb(255,0,0)}'; + const target=document.createElement('section'); + target.style.cssText='position:absolute!important;left:30px!important;width:80px!important;height:70px!important;margin:12px;padding:9px;border:3px solid red;transform:translateX(5px)'; + shadow.append(style,target); + const iframe=document.createElement('iframe');iframe.allowFullscreen=true;iframe.style.cssText='width:100%;height:100%;border:0'; const ready=new Promise(r=>iframe.onload=r); iframe.src='acode://localhost/__cache__/fullscreen-touch.html'; target.append(iframe);await Promise.race([ready,new Promise((resolve,reject)=>setTimeout(()=>reject(Error('Iframe fixture failed to load')),5000))]); const original=iframe.contentWindow.document;original.body.dataset.retained='yes'; + if(!original.fullscreenEnabled||typeof original.body.requestFullscreen!=='function')throw Error('Iframe fullscreen API is unavailable'); try { for(let n=0;n<3;n++) { await target.requestFullscreen(); const rect=target.getBoundingClientRect(); if(document.fullscreenElement!==host||shadow.fullscreenElement!==target)throw Error('Shadow owner was not retargeted'); if(rect.x!==0||rect.y!==0||rect.width!==innerWidth||rect.height!==innerHeight)throw Error('Fullscreen viewport mismatch: '+JSON.stringify(rect)); + if(getComputedStyle(target).backgroundColor!=='rgb(0, 255, 0)'||getComputedStyle(host).color!=='rgb(0, 255, 0)')throw Error('Shadow fullscreen CSS did not apply'); + style.sheet.insertRule('section:fullscreen{background:rgb(0,0,255)}',style.sheet.cssRules.length); + if(getComputedStyle(target).backgroundColor!=='rgb(0, 0, 255)')throw Error('Dynamic fullscreen CSS did not apply'); + style.sheet.deleteRule(style.sheet.cssRules.length-1); if(iframe.contentWindow.document!==original||original.body.dataset.retained!=='yes')throw Error('Iframe reloaded'); const frame=iframe.getBoundingClientRect(),button=original.getElementById('touch').getBoundingClientRect(); const x=button.x+button.width/2,y=button.y+button.height/2; if(shadow.elementFromPoint(frame.x+x,frame.y+y)!==iframe||original.elementFromPoint(x,y).id!=='touch')throw Error('Touch target shifted'); + target.style.color='purple';target.style.setProperty('width','110px','important'); + await new Promise(r=>requestAnimationFrame(r)); + if(target.style.width!=='100%')throw Error('Plugin layout change escaped fullscreen'); await document.exitFullscreen(); + if(target.style.width!=='110px'||target.style.height!=='70px'||target.style.left!=='30px'||target.style.marginTop!=='12px'||target.style.paddingTop!=='9px'||target.style.borderTopWidth!=='3px'||target.style.color!=='purple')throw Error('Plugin styles were lost on exit'); + if(getComputedStyle(target).backgroundColor!=='rgb(255, 0, 0)'||host.hasAttribute('data-acode-fullscreen'))throw Error('Fullscreen CSS remained active on exit'); if(document.fullscreenElement||shadow.fullscreenElement||target.hasAttribute('popover'))throw Error('Fullscreen did not exit cleanly'); } await target.requestFullscreen(); @@ -149,6 +163,30 @@ final class WebFullscreenTests: BridgeTestCase { _ = try await webView.callAsyncJavaScript("await document.exitFullscreen();document.getElementById('new-fullscreen-owner').remove()", arguments: [:], in: nil, contentWorld: .page) } + func testBackConsumesNativeDialogDismissalAndCancelledClose() async throws { + let webView = try await editorWebView() + let result = try await webView.callAsyncJavaScript(""" + const owner=document.createElement('div'),dialog=document.createElement('dialog'); + owner.append(dialog);document.body.append(owner); + let calls=0,cancels=0,prevent=false; + dialog.addEventListener('cancel',event=>{cancels++;if(prevent)event.preventDefault();}); + try { + await owner.requestFullscreen(); + await acode.require('fullscreen').setBackHandler(()=>calls++); + for(const blocked of [false,true]) { + prevent=blocked;dialog.showModal(); + document.dispatchEvent(new Event('fullscreenbackbutton')); + await new Promise(r=>setTimeout(r,50)); + if(dialog.open!==blocked||calls!==0||document.fullscreenElement!==owner)throw Error('Dialog Back fell through to the game callback'); + dialog.close(); + } + if(cancels!==2)throw Error('Dialog cancel was delivered twice'); + return true; + } finally {dialog.close();await document.exitFullscreen();owner.remove();} + """, arguments: [:], in: nil, contentWorld: .page) as? Bool + XCTAssertEqual(result, true) + } + private func wait(_ webView: WKWebView, until expression: String) async throws { for _ in 0..<100 { if (try await webView.evaluateJavaScript(expression)) as? Bool == true { return } diff --git a/src/platforms/ios/fullscreen.ts b/src/platforms/ios/fullscreen.ts index 172e61c..bcb98a8 100644 --- a/src/platforms/ios/fullscreen.ts +++ b/src/platforms/ios/fullscreen.ts @@ -1,3 +1,6 @@ +import fullscreenLayout from "./fullscreenLayout"; +import fullscreenStyles from "./fullscreenStyles"; + // Keep the app's WebView in place: WebKit's browser presentation changes its // coordinate space and adds browser controls over plugin content. export default function installFullscreen( @@ -7,19 +10,30 @@ export default function installFullscreen( let restore: (() => void) | undefined; let queue = Promise.resolve(); const backKeys = new WeakSet(); + const nativeElement = Object.getOwnPropertyDescriptor( + Document.prototype, + "fullscreenElement", + )?.get; + const nativeShadowElement = Object.getOwnPropertyDescriptor( + ShadowRoot.prototype, + "fullscreenElement", + )?.get; + const nativeExit = document.exitFullscreen; const observer = new MutationObserver(() => { if (owner && !owner.isConnected) void exit(); }); Object.defineProperty(document, "fullscreenElement", { configurable: true, get() { - return retarget(document); + return owner + ? retarget(document) + : (nativeElement?.call(document) ?? null); }, }); Object.defineProperty(ShadowRoot.prototype, "fullscreenElement", { configurable: true, get() { - return retarget(this); + return owner ? retarget(this) : (nativeShadowElement?.call(this) ?? null); }, }); Object.defineProperty(document, "fullscreenEnabled", { @@ -38,33 +52,24 @@ export default function installFullscreen( if (owner === target) return; if ( owner || + nativeElement?.call(document) || target.matches(":popover-open") || target instanceof HTMLDialogElement ) throw new TypeError("Another presentation is already active."); - const style = document.createElement("style"); - style.textContent = `[data-acode-fullscreen] { - position: fixed !important; inset: 0 !important; - width: 100% !important; height: 100% !important; - max-width: none !important; max-height: none !important; - margin: 0 !important; padding: 0 !important; border: 0 !important; - box-sizing: border-box !important; - }`; - const root = target.getRootNode(); - (root instanceof ShadowRoot ? root : document.head).append(style); const popover = target.getAttribute("popover"); - const marker = target.getAttribute("data-acode-fullscreen"); + const restoreLayout = fullscreenLayout(target); + let restoreStyles = () => {}; restore = () => { target.hidePopover(); if (popover === null) target.removeAttribute("popover"); else target.setAttribute("popover", popover); - if (marker === null) target.removeAttribute("data-acode-fullscreen"); - else target.setAttribute("data-acode-fullscreen", marker); - style.remove(); + restoreLayout(); + restoreStyles(); }; try { + restoreStyles = fullscreenStyles(target); target.setAttribute("popover", "manual"); - target.setAttribute("data-acode-fullscreen", ""); target.showPopover(); await setActive(true); if (!target.isConnected) @@ -126,6 +131,13 @@ export default function installFullscreen( } } if (!menu) return; + if (menu instanceof HTMLDialogElement) { + event.stopImmediatePropagation(); + if (typeof menu.requestClose === "function") menu.requestClose(); + else if (menu.dispatchEvent(new Event("cancel", { cancelable: true }))) + menu.close(); + return; + } let focused = document.activeElement; while (focused?.shadowRoot?.activeElement) focused = focused.shadowRoot.activeElement; @@ -158,6 +170,7 @@ export default function installFullscreen( } function exit() { return schedule(async () => { + if (nativeElement?.call(document)) await nativeExit.call(document); if (!owner) return; const target = owner; await setActive(false); diff --git a/src/platforms/ios/fullscreenLayout.ts b/src/platforms/ios/fullscreenLayout.ts new file mode 100644 index 0000000..4bea20f --- /dev/null +++ b/src/platforms/ios/fullscreenLayout.ts @@ -0,0 +1,68 @@ +const marker = "data-acode-fullscreen"; +const layout = { + position: "fixed", + top: "0px", + right: "0px", + bottom: "0px", + left: "0px", + width: "100%", + height: "100%", + "min-width": "0px", + "min-height": "0px", + "max-width": "none", + "max-height": "none", + "box-sizing": "border-box", + transform: "none", + ...Object.fromEntries( + ["top", "right", "bottom", "left"].flatMap((side) => [ + [`margin-${side}`, "0px"], + [`padding-${side}`, "0px"], + [`border-${side}-width`, "0px"], + ]), + ), +}; + +export default function fullscreenLayout(target: HTMLElement) { + const saved = new Map(); + const markers = new Map(); + let ancestor: Element = target; + while (true) { + markers.set(ancestor, ancestor.getAttribute(marker)); + ancestor.setAttribute(marker, ""); + const root = ancestor.getRootNode(); + if (!(root instanceof ShadowRoot)) break; + ancestor = root.host; + } + const observer = new MutationObserver(apply); + apply(); + observer.observe(target, { attributes: true, attributeFilter: ["style"] }); + return () => { + observer.disconnect(); + for (const [property, previous] of saved) { + if ( + target.style.getPropertyValue(property) !== + layout[property as keyof typeof layout] || + target.style.getPropertyPriority(property) !== "important" + ) + continue; + if (previous.value) + target.style.setProperty(property, previous.value, previous.priority); + else target.style.removeProperty(property); + } + for (const [element, value] of markers) { + if (value === null) element.removeAttribute(marker); + else element.setAttribute(marker, value); + } + }; + + function apply() { + for (const [property, value] of Object.entries(layout)) { + const current = target.style.getPropertyValue(property); + const priority = target.style.getPropertyPriority(property); + if (saved.has(property) && current === value && priority === "important") + continue; + saved.set(property, { value: current, priority }); + target.style.setProperty(property, value, "important"); + } + } +} diff --git a/src/platforms/ios/fullscreenStyles.ts b/src/platforms/ios/fullscreenStyles.ts new file mode 100644 index 0000000..4ee18b4 --- /dev/null +++ b/src/platforms/ios/fullscreenStyles.ts @@ -0,0 +1,144 @@ +const fullscreenSelector = ":is(:fullscreen, [data-acode-fullscreen])"; + +export default function fullscreenStyles(target: HTMLElement) { + const roots = styleRoots(target); + const observer = new MutationObserver((records) => { + if ( + records.some( + (record) => + record.target instanceof HTMLStyleElement || + record.target.parentElement instanceof HTMLStyleElement || + [...record.addedNodes].some( + (node) => + node instanceof Element && + (node.matches("style, link[rel=stylesheet]") || + node.querySelector("style, link[rel=stylesheet]")), + ), + ) + ) + refresh(); + }); + const insertRule = CSSStyleSheet.prototype.insertRule; + const insertGroupRule = CSSGroupingRule.prototype.insertRule; + const replaceSync = CSSStyleSheet.prototype.replaceSync; + const replace = CSSStyleSheet.prototype.replace; + CSSStyleSheet.prototype.insertRule = function (rule, index) { + const inserted = insertRule.call(this, rule, index); + adapt(this.cssRules); + return inserted; + }; + CSSGroupingRule.prototype.insertRule = function (rule, index) { + const inserted = insertGroupRule.call(this, rule, index); + adapt(this.cssRules); + return inserted; + }; + CSSStyleSheet.prototype.replaceSync = function (text) { + replaceSync.call(this, text); + adapt(this.cssRules); + }; + CSSStyleSheet.prototype.replace = async function (text) { + await replace.call(this, text); + adapt(this.cssRules); + return this; + }; + for (const root of roots) { + observer.observe(root, { + childList: true, + characterData: true, + subtree: true, + }); + root.addEventListener("load", refresh, true); + } + try { + refresh(); + } catch (error) { + dispose(); + throw error; + } + return dispose; + + function dispose() { + observer.disconnect(); + for (const root of roots) root.removeEventListener("load", refresh, true); + CSSStyleSheet.prototype.insertRule = insertRule; + CSSGroupingRule.prototype.insertRule = insertGroupRule; + CSSStyleSheet.prototype.replaceSync = replaceSync; + CSSStyleSheet.prototype.replace = replace; + } + + function refresh() { + for (const root of roots) { + for (const sheet of [...root.styleSheets, ...root.adoptedStyleSheets]) { + adapt(readRules(sheet)); + } + } + } +} + +function styleRoots(target: HTMLElement) { + const roots = new Set([document]); + let ancestor: Element = target; + while (true) { + if (ancestor.shadowRoot) roots.add(ancestor.shadowRoot); + const root = ancestor.getRootNode(); + if (!(root instanceof ShadowRoot)) break; + roots.add(root); + ancestor = root.host; + } + return roots; +} + +function readRules(sheet: CSSStyleSheet): Iterable { + try { + return sheet.cssRules; + } catch (error) { + if (error instanceof DOMException && error.name === "SecurityError") + return []; + throw error; + } +} + +function adapt(rules: Iterable) { + for (const rule of rules) { + if (rule instanceof CSSStyleRule) { + const selector = adaptSelector(rule.selectorText); + if (selector !== rule.selectorText) rule.selectorText = selector; + } + if ("cssRules" in rule) adapt((rule as CSSGroupingRule).cssRules); + if (rule.type === CSSRule.IMPORT_RULE && (rule as CSSImportRule).styleSheet) + adapt(readRules((rule as CSSImportRule).styleSheet!)); + } +} + +function adaptSelector(selector: string) { + let result = "", + quote = "", + brackets = 0; + for (let index = 0; index < selector.length; index++) { + const char = selector[index]; + if (char === "\\") { + result += selector.slice(index, index + 2); + index++; + continue; + } + if (quote) { + if (char === quote) quote = ""; + } else if (char === '"' || char === "'") quote = char; + else if (char === "[") brackets++; + else if (char === "]") brackets--; + else if (!brackets && selector.startsWith(fullscreenSelector, index)) { + result += fullscreenSelector; + index += fullscreenSelector.length - 1; + continue; + } else if ( + !brackets && + /^:fullscreen(?![\w-])/i.test(selector.slice(index)) + ) { + result += fullscreenSelector; + index += ":fullscreen".length - 1; + continue; + } + result += char; + } + return result; +} diff --git a/tests/unit/iosFullscreen.test.ts b/tests/unit/iosFullscreen.test.ts index 03b2df7..bb7f78a 100644 --- a/tests/unit/iosFullscreen.test.ts +++ b/tests/unit/iosFullscreen.test.ts @@ -3,14 +3,34 @@ import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest"; import installFullscreen from "../../src/platforms/ios/fullscreen"; const native = vi.fn<(active: boolean) => Promise>().mockResolvedValue(undefined); +let nativeOwner: Element | null = null; +const nativeRequest = vi.fn(function (this: Element) { nativeOwner = this; return Promise.resolve(); }); +const nativeExit = vi.fn(async () => { nativeOwner = null; }); const descriptors = [ [document, "fullscreenElement"], [ShadowRoot.prototype, "fullscreenElement"], [document, "fullscreenEnabled"], [Element.prototype, "requestFullscreen"], [document, "exitFullscreen"], [HTMLElement.prototype, "showPopover"], [HTMLElement.prototype, "hidePopover"], + [ShadowRoot.prototype, "styleSheets"], + [CSSStyleRule.prototype, "selectorText"], + [Document.prototype, "fullscreenElement"], ] as const; const saved = descriptors.map(([target, key]) => Object.getOwnPropertyDescriptor(target, key)); beforeAll(() => { + Object.defineProperty(Document.prototype, "fullscreenElement", { configurable: true, get: () => nativeOwner }); + Element.prototype.requestFullscreen = nativeRequest; + document.exitFullscreen = nativeExit; + const selectors = new WeakMap(); + const selector = Object.getOwnPropertyDescriptor(CSSStyleRule.prototype, "selectorText")!.get!; + Object.defineProperty(CSSStyleRule.prototype, "selectorText", { + configurable: true, + get() { return selectors.get(this) ?? selector.call(this); }, + set(value: string) { selectors.set(this, value); }, + }); + Object.defineProperty(ShadowRoot.prototype, "styleSheets", { + configurable: true, + get() { return [...this.querySelectorAll("style")].map((style: HTMLStyleElement) => style.sheet).filter(Boolean); }, + }); HTMLElement.prototype.showPopover = function () {}; HTMLElement.prototype.hidePopover = function () {}; installFullscreen(native); @@ -18,7 +38,120 @@ beforeAll(() => { afterEach(async () => { await document.exitFullscreen(); document.body.replaceChildren(); + document.head.querySelectorAll("[data-fullscreen-test]").forEach(style => style.remove()); native.mockClear(); + nativeRequest.mockClear(); + nativeExit.mockClear(); +}); + +test("retains native iframe ownership and delegates exit without changing the manual layout", async () => { + const frame = document.createElement("iframe"); + document.body.append(frame); + nativeOwner = frame; + expect(document.fullscreenElement).toBe(frame); + const target = document.createElement("div"); + document.body.append(target); + await expect(target.requestFullscreen()).rejects.toThrow(/already active/); + await document.exitFullscreen(); + expect(nativeExit).toHaveBeenCalledOnce(); + expect(native).not.toHaveBeenCalled(); + expect(document.fullscreenElement).toBeNull(); +}); + +test("keeps manual fullscreen and exit working with unreadable cross-origin stylesheets", async () => { + const target = document.createElement("div"); + document.body.append(target); + const adopted = document.adoptedStyleSheets; + const foreignSheet = new CSSStyleSheet(); + Object.defineProperty(foreignSheet, "cssRules", { get() { throw new DOMException("Cross-origin stylesheet", "SecurityError"); } }); + document.adoptedStyleSheets = [foreignSheet]; + try { + await target.requestFullscreen(); + expect(document.fullscreenElement).toBe(target); + expect(target.hasAttribute("data-acode-fullscreen")).toBe(true); + expect(target.style.width).toBe("100%"); + expect(native).toHaveBeenCalledWith(true); + await document.exitFullscreen(); + expect(target.hasAttribute("data-acode-fullscreen")).toBe(false); + expect(target.style.width).toBe(""); + expect(document.fullscreenElement).toBeNull(); + expect(native).toHaveBeenLastCalledWith(false); + expect(nativeRequest).not.toHaveBeenCalled(); + expect(nativeExit).not.toHaveBeenCalled(); + } finally { document.adoptedStyleSheets = adopted; } +}); + +test("forces inline important layout and restores styles including changes made while fullscreen", async () => { + const target = document.createElement("div"); + target.style.cssText = "position:absolute!important;left:30px!important;width:80px!important;height:70px!important;margin:12px;padding:9px;border:3px solid red;transform:translateX(5px)"; + document.body.append(target); + await target.requestFullscreen(); + expect(target.style.width).toBe("100%"); + expect(target.style.left).toBe("0px"); + expect(target.style.marginTop).toBe("0px"); + expect(target.style.getPropertyPriority("width")).toBe("important"); + target.style.color = "blue"; + target.style.setProperty("width", "110px", "important"); + await vi.waitFor(() => expect(target.style.width).toBe("100%")); + await document.exitFullscreen(); + expect(target.style.width).toBe("110px"); + expect(target.style.height).toBe("70px"); + expect(target.style.left).toBe("30px"); + expect(target.style.marginTop).toBe("12px"); + expect(target.style.paddingTop).toBe("9px"); + expect(target.style.borderTopWidth).toBe("3px"); + expect(target.style.transform).toBe("translateX(5px)"); + expect(target.style.color).toBe("blue"); +}); + +test("adapts fullscreen CSS in place without changing attribute values or dynamic declarations", async () => { + const style = document.createElement("style"); + style.dataset.fullscreenTest = ""; + style.textContent = 'div:fullscreen > button { color:red } [data-label=":fullscreen"] { color:blue } @media (min-width:0px) { div:not(:fullscreen) { opacity:0.5 } }'; + document.head.append(style); + const target = document.createElement("div"); + document.body.append(target); + await target.requestFullscreen(); + const sheet = style.sheet!; + const rule = sheet.cssRules[0] as CSSStyleRule; + expect(rule.selectorText).toBe("div:is(:fullscreen, [data-acode-fullscreen]) > button"); + expect((sheet.cssRules[1] as CSSStyleRule).selectorText).toBe('[data-label=":fullscreen"]'); + const group = sheet.cssRules[2] as CSSGroupingRule; + group.insertRule("div:fullscreen { opacity:1 }", 1); + expect((group.cssRules[1] as CSSStyleRule).selectorText).toContain("[data-acode-fullscreen]"); + const adopted = new CSSStyleSheet(); + adopted.replaceSync("div:fullscreen { color:teal }"); + expect((adopted.cssRules[0] as CSSStyleRule).selectorText).toContain("[data-acode-fullscreen]"); + await adopted.replace("div:fullscreen { color:cyan }"); + expect((adopted.cssRules[0] as CSSStyleRule).selectorText).toContain("[data-acode-fullscreen]"); + rule.style.color = "green"; + sheet.insertRule("div:fullscreen { background:purple }", 3); + expect((sheet.cssRules[3] as CSSStyleRule).selectorText).toContain("[data-acode-fullscreen]"); + style.textContent = "div:fullscreen { color:orange }"; + await vi.waitFor(() => expect((style.sheet!.cssRules[0] as CSSStyleRule).selectorText).toContain("[data-acode-fullscreen]")); + await document.exitFullscreen(); + expect(rule.style.color).toBe("green"); + expect(target.hasAttribute("data-acode-fullscreen")).toBe(false); +}); + +test.each([false, true])("Back consumes a native dialog close request, including cancellation: %s", async (prevented) => { + const target = document.createElement("div"); + const dialog = document.createElement("dialog"); + target.append(dialog); + document.body.append(target); + await target.requestFullscreen(); + dialog.showModal(); + const cancel = vi.fn((event: Event) => { if (prevented) event.preventDefault(); }); + dialog.addEventListener("cancel", cancel); + const back = vi.fn(); + document.addEventListener("fullscreenbackbutton", back); + try { + document.dispatchEvent(new Event("fullscreenbackbutton")); + expect(cancel).toHaveBeenCalledOnce(); + expect(dialog.open).toBe(prevented); + expect(back).not.toHaveBeenCalled(); + expect(document.fullscreenElement).toBe(target); + } finally { document.removeEventListener("fullscreenbackbutton", back); dialog.close(); } }); afterAll(() => { descriptors.forEach(([target, key], index) => {