diff --git a/docs/ios-port.md b/docs/ios-port.md index 72dc66c..d2db785 100644 --- a/docs/ios-port.md +++ b/docs/ios-port.md @@ -480,13 +480,28 @@ 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 element fullscreen using a manual +popover and edge-to-edge native layout, preserving iframe state and touch +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 +(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; 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 +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..fe26a33 100644 --- a/platforms/ios/runner/WebFullscreen.swift +++ b/platforms/ios/runner/WebFullscreen.swift @@ -10,20 +10,58 @@ final class WebFullscreen: NSObject { private var pending: Callback? private var requestID = UUID() private var foreground = true + private var controls: FullscreenBackControls! + private var hasBackHandler = false + 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 { - if webView.fullscreenState == .notInFullscreen || webView.fullscreenState == .exitingFullscreen { self?.reset() } + 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) } + 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: controller?.isFullscreenLayout == true, 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 +87,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..6fcb6a7 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() { @@ -23,8 +26,8 @@ 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) webView.navigationDelegate = self @@ -37,7 +40,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 +47,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 +89,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 +142,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 +158,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..d38b400 100644 --- a/platforms/ios/runnerTests/SystemUITests.swift +++ b/platforms/ios/runnerTests/SystemUITests.swift @@ -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..18c7597 --- /dev/null +++ b/platforms/ios/runnerTests/WebFullscreenTests.swift @@ -0,0 +1,203 @@ +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 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(); + 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) + } + + 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 } + 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..bcb98a8 --- /dev/null +++ b/src/platforms/ios/fullscreen.ts @@ -0,0 +1,207 @@ +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( + setActive: (active: boolean) => Promise, +) { + let owner: HTMLElement | null = null; + 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 owner + ? retarget(document) + : (nativeElement?.call(document) ?? null); + }, + }); + Object.defineProperty(ShadowRoot.prototype, "fullscreenElement", { + configurable: true, + get() { + return owner ? retarget(this) : (nativeShadowElement?.call(this) ?? null); + }, + }); + 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 || + nativeElement?.call(document) || + target.matches(":popover-open") || + target instanceof HTMLDialogElement + ) + throw new TypeError("Another presentation is already active."); + const popover = target.getAttribute("popover"); + const restoreLayout = fullscreenLayout(target); + let restoreStyles = () => {}; + restore = () => { + target.hidePopover(); + if (popover === null) target.removeAttribute("popover"); + else target.setAttribute("popover", popover); + restoreLayout(); + restoreStyles(); + }; + try { + restoreStyles = fullscreenStyles(target); + target.setAttribute("popover", "manual"); + 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; + 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; + 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 (nativeElement?.call(document)) await nativeExit.call(document); + 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/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/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..bb7f78a --- /dev/null +++ b/tests/unit/iosFullscreen.test.ts @@ -0,0 +1,294 @@ +// @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); +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); +}); +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) => { + 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); +});