@erkamyaman/capacitor-foldable 8.3.0 → 8.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/android/src/main/kotlin/io/github/erkamyaman/foldable/DisplayModes.kt +22 -2
- package/android/src/main/kotlin/io/github/erkamyaman/foldable/FoldablePlugin.kt +26 -4
- package/ionic-tabs-ios26.css +9 -0
- package/ionic-tabs.css +28 -2
- package/package.json +1 -1
- package/skills/capacitor-foldable/SKILL.md +3 -2
- package/skills/capacitor-foldable/references/android.md +4 -2
- package/skills/capacitor-foldable/references/api.md +3 -1
- package/skills/capacitor-foldable/references/css-layout.md +18 -0
- package/skills/capacitor-foldable/references/ionic.md +4 -0
- package/skills/capacitor-foldable/references/testing.md +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ const { state, hingeOrientation } = await Foldable.getFoldState();
|
|
|
37
37
|
|
|
38
38
|
| Platform | Status | Minimum |
|
|
39
39
|
| -------- | ------ | ------- |
|
|
40
|
-
| Android | Supported | API 24, `compileSdk`
|
|
40
|
+
| Android | Supported | API 24, `compileSdk` 36 |
|
|
41
41
|
| iOS | Size classes everywhere; fold, hinge and bar placement on iPhone Duo with Capacitor 8.5+, Xcode 27.1+ and iOS 27.1+ | iOS 15 |
|
|
42
42
|
| Web | Stub, returns `flat` | |
|
|
43
43
|
|
|
@@ -53,7 +53,7 @@ Web views already resize with the window, so a responsive layout that pads with
|
|
|
53
53
|
- **Where iPhone Duo puts native bars**, so your HTML tab bar can follow. For Ionic's `ion-tabs` there's a ready-made stylesheet, plus a fix for the tab bar disappearing when you fold.
|
|
54
54
|
- **The outer display on Android foldables**: rear display and dual screen.
|
|
55
55
|
|
|
56
|
-
Google's [adaptive app quality guidelines](https://developer.android.com/develop/adaptive-apps/quality-guidelines/adaptive-app-quality)
|
|
56
|
+
Google's [adaptive app quality guidelines](https://developer.android.com/develop/adaptive-apps/quality-guidelines/adaptive-app-quality) make posture support a Tier 1 check and size-class-driven layouts a Tier 2 one, and Apple's [iPhone Duo guidance](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo) asks for layouts that adapt to the fold rather than to a device. This plugin reports what both are about.
|
|
57
57
|
|
|
58
58
|
## Installation
|
|
59
59
|
|
|
@@ -127,11 +127,11 @@ Using Ionic's `ion-tabs`? `import '@erkamyaman/capacitor-foldable/ionic-tabs.css
|
|
|
127
127
|
|
|
128
128
|
## Store review
|
|
129
129
|
|
|
130
|
-
The plugin is built
|
|
130
|
+
The plugin is built to give store review nothing to object to.
|
|
131
131
|
|
|
132
|
-
**iOS.** Public APIs only, UIKit and Capacitor, with no dynamic lookup or private symbols. It touches none of Apple's [required reason APIs](https://developer.apple.com/documentation/bundleresources/
|
|
132
|
+
**iOS.** Public APIs only, UIKit and Capacitor, with no dynamic lookup or private symbols. It touches none of Apple's [required reason APIs](https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api), collects nothing and tracks nobody, and it ships a `PrivacyInfo.xcprivacy` saying exactly that, which Xcode folds into your app's privacy report. Nothing it uses needs a purpose string in `Info.plist`. The iOS 27.1 code is behind a compile-time SDK check, so any Xcode that Capacitor 8 supports builds fine.
|
|
133
133
|
|
|
134
|
-
**Android.** The plugin
|
|
134
|
+
**Android.** The plugin adds no permissions and no `uses-feature` to your app, so it changes nothing about which devices can install it. Its own manifest is empty; Jetpack WindowManager merges two optional `<uses-library>` entries, which are how it binds to the OEM window extensions and filter nothing. The hinge angle comes from `TYPE_HINGE_ANGLE`, which needs no permission, and rear display and dual screen go through `WindowAreaController`, also permission free. `minSdk` is 24, the same as Capacitor's own default, and `targetSdk` follows your project.
|
|
135
135
|
|
|
136
136
|
**Data safety and privacy labels.** Everything stays on the device: the plugin has no network code and stores nothing. There is no entry for it to add to Play's Data safety form or to your App Store privacy label.
|
|
137
137
|
|
|
@@ -3,6 +3,7 @@ package io.github.erkamyaman.foldable
|
|
|
3
3
|
import android.app.Activity
|
|
4
4
|
import android.content.Context
|
|
5
5
|
import android.view.View
|
|
6
|
+
import android.webkit.WebView
|
|
6
7
|
import androidx.core.content.ContextCompat
|
|
7
8
|
import androidx.window.area.WindowAreaCapability
|
|
8
9
|
import androidx.window.area.WindowAreaController
|
|
@@ -31,6 +32,7 @@ class DisplayModes(private val activity: Activity) {
|
|
|
31
32
|
private var rearDisplayArea: WindowAreaInfo? = null
|
|
32
33
|
private var rearDisplaySession: WindowAreaSession? = null
|
|
33
34
|
private var dualScreenSession: WindowAreaSessionPresenter? = null
|
|
35
|
+
private var dualScreenContent: WebView? = null
|
|
34
36
|
|
|
35
37
|
fun statuses(): Flow<DisplayModeStatuses> =
|
|
36
38
|
controller.windowAreaInfos
|
|
@@ -72,7 +74,7 @@ class DisplayModes(private val activity: Activity) {
|
|
|
72
74
|
|
|
73
75
|
fun startDualScreen(createContent: (Context) -> View, onStarted: () -> Unit, onEnded: (Throwable?) -> Unit) {
|
|
74
76
|
dualScreenSession?.let { session ->
|
|
75
|
-
session
|
|
77
|
+
replaceContent(session, createContent)
|
|
76
78
|
onStarted()
|
|
77
79
|
return
|
|
78
80
|
}
|
|
@@ -86,11 +88,12 @@ class DisplayModes(private val activity: Activity) {
|
|
|
86
88
|
object : WindowAreaPresentationSessionCallback {
|
|
87
89
|
override fun onSessionStarted(session: WindowAreaSessionPresenter) {
|
|
88
90
|
dualScreenSession = session
|
|
89
|
-
session
|
|
91
|
+
replaceContent(session, createContent)
|
|
90
92
|
onStarted()
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
override fun onSessionEnded(t: Throwable?) {
|
|
96
|
+
destroyContent()
|
|
94
97
|
dualScreenSession = null
|
|
95
98
|
onEnded(t)
|
|
96
99
|
}
|
|
@@ -101,10 +104,27 @@ class DisplayModes(private val activity: Activity) {
|
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
fun stopDualScreen() {
|
|
107
|
+
destroyContent()
|
|
104
108
|
dualScreenSession?.close()
|
|
105
109
|
dualScreenSession = null
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
/** A web view left alive keeps its timers and connections running. */
|
|
113
|
+
private fun replaceContent(session: WindowAreaSessionPresenter, createContent: (Context) -> View) {
|
|
114
|
+
destroyContent()
|
|
115
|
+
val content = createContent(session.context)
|
|
116
|
+
dualScreenContent = content as? WebView
|
|
117
|
+
session.setContentView(content)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private fun destroyContent() {
|
|
121
|
+
dualScreenContent?.apply {
|
|
122
|
+
loadUrl("about:blank")
|
|
123
|
+
destroy()
|
|
124
|
+
}
|
|
125
|
+
dualScreenContent = null
|
|
126
|
+
}
|
|
127
|
+
|
|
108
128
|
private fun statusOf(area: WindowAreaInfo?, operation: WindowAreaCapability.Operation): String =
|
|
109
129
|
when (area?.getCapability(operation)?.status) {
|
|
110
130
|
WindowAreaCapability.Status.WINDOW_AREA_STATUS_ACTIVE -> "active"
|
|
@@ -216,13 +216,15 @@ class FoldablePlugin : Plugin() {
|
|
|
216
216
|
@PluginMethod
|
|
217
217
|
fun startDualScreen(call: PluginCall) {
|
|
218
218
|
val url = call.getString("url") ?: return call.reject("url is required.")
|
|
219
|
+
val target = resolveUrl(url)
|
|
220
|
+
?: return call.reject("url must be a page in this app, or a data: URL you built yourself.")
|
|
219
221
|
val displayModes = this.displayModes ?: return call.unavailable("Dual-screen mode needs an activity.")
|
|
220
222
|
|
|
221
223
|
scope.launch {
|
|
222
224
|
when (lastDisplayModes?.dualScreen) {
|
|
223
225
|
"available", "active" -> {
|
|
224
226
|
val settle = Settle(call, "Dual-screen mode")
|
|
225
|
-
displayModes.startDualScreen({ context -> dualScreenWebView(context,
|
|
227
|
+
displayModes.startDualScreen({ context -> dualScreenWebView(context, target) }, settle::started, settle::ended)
|
|
226
228
|
}
|
|
227
229
|
else -> call.unavailable("Dual-screen mode is not available on this device right now.")
|
|
228
230
|
}
|
|
@@ -294,12 +296,32 @@ class FoldablePlugin : Plugin() {
|
|
|
294
296
|
webViewClient = object : WebViewClient() {
|
|
295
297
|
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? =
|
|
296
298
|
bridge.localServer.shouldInterceptRequest(request)
|
|
299
|
+
|
|
300
|
+
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
|
|
301
|
+
!isAppUrl(request.url.toString())
|
|
297
302
|
}
|
|
298
|
-
loadUrl(
|
|
303
|
+
loadUrl(url)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The page on the other display shares the app's web view profile, so it is
|
|
308
|
+
* kept to the app's own origin: anything else is refused rather than loaded.
|
|
309
|
+
*/
|
|
310
|
+
private fun resolveUrl(url: String): String? {
|
|
311
|
+
if (url.startsWith("//")) return null
|
|
312
|
+
|
|
313
|
+
val scheme = Uri.parse(url).scheme
|
|
314
|
+
if (scheme == null) return URL(URL(bridge.appUrl), url).toString().takeIf(::isAppUrl)
|
|
315
|
+
if (!scheme.equals("http", true) && !scheme.equals("https", true) && scheme != "data") return null
|
|
316
|
+
|
|
317
|
+
return url.takeIf { scheme == "data" || isAppUrl(it) }
|
|
299
318
|
}
|
|
300
319
|
|
|
301
|
-
private fun
|
|
302
|
-
|
|
320
|
+
private fun isAppUrl(url: String): Boolean {
|
|
321
|
+
val app = Uri.parse(bridge.appUrl)
|
|
322
|
+
val target = Uri.parse(url)
|
|
323
|
+
return target.scheme == app.scheme && target.host == app.host && target.port == app.port
|
|
324
|
+
}
|
|
303
325
|
|
|
304
326
|
private inner class Settle(private val call: PluginCall, private val mode: String) {
|
|
305
327
|
private var settled = false
|
package/ionic-tabs-ios26.css
CHANGED
|
@@ -52,3 +52,12 @@ html.ios:not(.vertical-bars-leading):not(.vertical-bars-trailing) ion-tab-button
|
|
|
52
52
|
background: rgba(255, 255, 255, 0.1);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more), (forced-colors: active) {
|
|
57
|
+
html.ios ion-tab-bar {
|
|
58
|
+
background: Canvas;
|
|
59
|
+
-webkit-backdrop-filter: none;
|
|
60
|
+
backdrop-filter: none;
|
|
61
|
+
border: 1px solid CanvasText;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/ionic-tabs.css
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
bottom: var(--vertical-tab-bar-bottom, 24px);
|
|
5
5
|
z-index: 10;
|
|
6
6
|
flex-direction: column;
|
|
7
|
-
width:
|
|
7
|
+
width: 52px;
|
|
8
8
|
height: auto;
|
|
9
9
|
padding: 3px 2px;
|
|
10
10
|
border: none;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
background: rgba(255, 255, 255, 0.78);
|
|
14
14
|
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
|
15
15
|
backdrop-filter: blur(12px) saturate(180%);
|
|
16
|
+
--color-selected: #0071e3;
|
|
16
17
|
box-shadow:
|
|
17
18
|
inset 0 0 0 0.5px rgba(0, 0, 0, 0.2),
|
|
18
19
|
inset 0 0 8px rgba(0, 0, 0, 0.04),
|
|
@@ -37,14 +38,27 @@
|
|
|
37
38
|
--padding-end: 0;
|
|
38
39
|
flex: 0 0 auto;
|
|
39
40
|
width: 100%;
|
|
41
|
+
min-width: 44px;
|
|
40
42
|
max-width: none;
|
|
41
43
|
height: 54px;
|
|
44
|
+
min-height: 48px;
|
|
42
45
|
border-radius: 22px;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/* Hidden to the eye, still there for VoiceOver and TalkBack: `display: none`
|
|
49
|
+
would take the tab's name out of the accessibility tree. */
|
|
45
50
|
.vertical-bars-leading ion-tab-button ion-label,
|
|
46
51
|
.vertical-bars-trailing ion-tab-button ion-label {
|
|
47
|
-
|
|
52
|
+
position: absolute;
|
|
53
|
+
width: 1px;
|
|
54
|
+
height: 1px;
|
|
55
|
+
margin: -1px;
|
|
56
|
+
padding: 0;
|
|
57
|
+
border: 0;
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
clip: rect(0 0 0 0);
|
|
60
|
+
clip-path: inset(50%);
|
|
61
|
+
white-space: nowrap;
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
.vertical-bars-leading ion-tab-button.tab-selected,
|
|
@@ -85,3 +99,15 @@ ion-tab-bar[hidden] {
|
|
|
85
99
|
background: rgba(255, 255, 255, 0.1);
|
|
86
100
|
}
|
|
87
101
|
}
|
|
102
|
+
|
|
103
|
+
/* Reduced transparency, higher contrast, forced colours: drop the blur and
|
|
104
|
+
give the pill an opaque background so the icons stay legible. */
|
|
105
|
+
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more), (forced-colors: active) {
|
|
106
|
+
.vertical-bars-leading ion-tab-bar,
|
|
107
|
+
.vertical-bars-trailing ion-tab-bar {
|
|
108
|
+
background: Canvas;
|
|
109
|
+
-webkit-backdrop-filter: none;
|
|
110
|
+
backdrop-filter: none;
|
|
111
|
+
border: 1px solid CanvasText;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ metadata:
|
|
|
6
6
|
author: erkamyaman
|
|
7
7
|
version: '0.1'
|
|
8
8
|
status: beta
|
|
9
|
+
checked: 2026-09, against Xcode 27.1, Capacitor 8.5, Play target API 36
|
|
9
10
|
---
|
|
10
11
|
|
|
11
12
|
# Capacitor Foldable
|
|
@@ -21,7 +22,7 @@ npm install @erkamyaman/capacitor-foldable
|
|
|
21
22
|
npx cap sync
|
|
22
23
|
```
|
|
23
24
|
|
|
24
|
-
Capacitor 8 uses the current major. Capacitor 7 apps install `@erkamyaman/capacitor-foldable@7
|
|
25
|
+
Capacitor 8 uses the current major. **If you are on Capacitor 7 and building with Xcode 27, upgrade to 8.5 or later before anything else**: those apps crash at launch, because iOS 27 needs the scene lifecycle that only 8.5 adopted, and a crash on launch is an automatic App Review rejection. Capacitor 7 apps can install `@erkamyaman/capacitor-foldable@7` and build with Xcode 26, which reports the fold on Android and size classes everywhere but has no iPhone Duo support. That route closes when Apple requires the iOS 27 SDK in April 2027. The 7.x line also predates the `--fold-*` variables (8.1.0) and the Ionic tab stylesheets, so the CSS below needs Capacitor 8.
|
|
25
26
|
|
|
26
27
|
## Start here: CSS before JavaScript
|
|
27
28
|
|
|
@@ -84,4 +85,4 @@ npm run build # or the app's build
|
|
|
84
85
|
npx cap sync
|
|
85
86
|
```
|
|
86
87
|
|
|
87
|
-
Then run it open flat, half open and closed, and fold it shut and open again, which suspends and resumes the web view on iOS. On iPhone Duo the posture picks the display, so that covers both screens; on Android the outer display needs rear display mode. [testing.md](references/testing.md) has the commands.
|
|
88
|
+
Then run it open flat, half open and closed, and fold it shut and open again, which suspends and resumes the web view on iOS. Do one pass at the largest system text size and one with VoiceOver or TalkBack on, since a layout that splits at the crease is exactly where reading order and target sizes go wrong. On iPhone Duo the posture picks the display, so that covers both screens; on Android the outer display needs rear display mode. [testing.md](references/testing.md) has the commands.
|
|
@@ -33,9 +33,11 @@ await Foldable.startDualScreen({ url: 'cover.html' });
|
|
|
33
33
|
await Foldable.stopDualScreen();
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
The
|
|
36
|
+
**Load only content you control.** The plugin refuses anything that is not a page of your own app or a `data:` URL, and keeps the second web view on your origin once it is loaded, because that view shares the app's cookies and storage. Never pass a URL that came from a server, a deep link, a push payload or a user.
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
A `data:` URL is the easier route for dynamic content and is what the plugin's own example uses, but note two things. It gets an **opaque origin**, so `localStorage` and friends throw there and it cannot read the app's storage: put what the page needs into the document. And `encodeURIComponent` is URL encoding, not HTML escaping, so interpolating a name or a message into `data:text/html,...` is an injection into a page that has JavaScript on. Escape it for HTML first, or ship a static page and pass values in the fragment for its own script to insert with `textContent`.
|
|
39
|
+
|
|
40
|
+
Treat it as a presentation surface: a plain web view with no Capacitor bridge, so no plugin API inside it. Calling `startDualScreen` again replaces the page rather than messaging it.
|
|
39
41
|
|
|
40
42
|
Both modes share one window area behind the scenes, and both stop when the activity goes away.
|
|
41
43
|
|
|
@@ -107,6 +107,8 @@ await Foldable.stopDualScreen();
|
|
|
107
107
|
|
|
108
108
|
`startRearDisplay()` shows a system confirmation first, and `startDualScreen()` rejects outright without a `url`. Both accept `available` or `active`.
|
|
109
109
|
|
|
110
|
-
`displayModeChange` reports both modes together. Relative URLs resolve against the app's own URL, so `cover.html` has to be a page you ship in the web build, which for a bundler means adding it as an extra entry point. A `data:` URL works too, and is the easier route for dynamic content.
|
|
110
|
+
`displayModeChange` reports both modes together. Relative URLs resolve against the app's own URL, so `cover.html` has to be a page you ship in the web build, which for a bundler means adding it as an extra entry point. A `data:` URL works too, and is the easier route for dynamic content.
|
|
111
|
+
|
|
112
|
+
The page must be content you wrote: the plugin rejects any other origin, and `encodeURIComponent` is URL encoding rather than HTML escaping, so escape anything you interpolate into a `data:text/html` document. See [android.md](android.md). That page runs in its own web view with no access to Capacitor plugins, and calling `startDualScreen` again replaces it.
|
|
111
113
|
|
|
112
114
|
Android only; both read as `unsupported` on iOS and web, where the `start` methods reject. See [android.md](android.md).
|
|
@@ -118,6 +118,24 @@ const handle = await Foldable.addListener('foldStateChange', apply);
|
|
|
118
118
|
|
|
119
119
|
`animation-play-state` is not inherited and only affects elements that carry a CSS animation, so put it on the animated element itself. For a chart or anything driven by JavaScript, listen to `foldingChange` and stop redrawing instead. The class appears as the hinge starts moving and goes half a second after it stops, and only on devices that report a hinge angle: an Android foldable without a hinge sensor never gets it.
|
|
120
120
|
|
|
121
|
+
## Accessibility
|
|
122
|
+
|
|
123
|
+
The crease is exactly where reading order and target sizes go wrong, so four rules:
|
|
124
|
+
|
|
125
|
+
- **Keep source order equal to visual order.** The `nth-child` pinning above works only while it is. Re-order the markup rather than moving things visually, or a screen reader reads the pages in the wrong sequence.
|
|
126
|
+
- **Name each page and let the keyboard reach it.** Both panes scroll independently, so give them `role="region"` with an `aria-label`, and `tabindex="0"` on the scroll container.
|
|
127
|
+
- **Use `rem` for your own gutters and insets**, not pixels. Android scales web view text with the system font size while iOS does not unless you opt in, so fixed pixel padding drifts in one direction and never moves in the other. Grid items pinned to a fixed track also need `min-width: 0`, or long words push them into the crease.
|
|
128
|
+
- **Targets stay at least 44 pt, or 48 dp on Android**, which matters most for anything sitting beside the fold.
|
|
129
|
+
|
|
130
|
+
`prefers-reduced-motion` is a separate concern from `.folding`: the class is a device signal that comes and goes with the hinge, so honour the media query independently, and use `animation: none` there rather than pausing on an arbitrary frame.
|
|
131
|
+
|
|
132
|
+
Right to left needs its own arm, because the fold variables are physical viewport coordinates while `:first-child` is not:
|
|
133
|
+
|
|
134
|
+
```css
|
|
135
|
+
[dir='rtl'] .fold-vertical .reader .page:first-child { left: calc(var(--fold-left) + var(--fold-width)); right: 0; width: auto; }
|
|
136
|
+
[dir='rtl'] .fold-vertical .reader .page:last-child { left: 0; width: var(--fold-left); }
|
|
137
|
+
```
|
|
138
|
+
|
|
121
139
|
## What not to do
|
|
122
140
|
|
|
123
141
|
- Splitting at `50%`. The coordinates are relative to the web view, and on iPhone Duo the band has width.
|
|
@@ -45,6 +45,10 @@ readonly mode = signal<'game' | 'meter'>('game');
|
|
|
45
45
|
|
|
46
46
|
If you do route, `provideIonicAngular({ animated: false })` removes the slide, but be aware it disables every Ionic animation app-wide, including modals and ripples.
|
|
47
47
|
|
|
48
|
+
## Apple's rules for the side bar
|
|
49
|
+
|
|
50
|
+
The vertical bar has an order: primary navigation at the top, then prominent actions. Apple also asks you to prefer a symbol over text there, since anything with a label stays in a horizontal bar. The stylesheet hides the labels visually for that reason but keeps them for VoiceOver, so leave them in your markup.
|
|
51
|
+
|
|
48
52
|
## Styling gotcha
|
|
49
53
|
|
|
50
54
|
`ion-tab-bar` ships `contain: strict`, which brings size containment with it. The moment you let either dimension come from its contents, it lays out as if empty and collapses: height in the side pill, width in the iOS 26 variant. Both shipped stylesheets set `contain: none` for that reason.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## iPhone Duo simulator
|
|
4
4
|
|
|
5
|
-
Needs the iOS 27.1 runtime, which the device type refuses to go below, and Xcode 27.1 to build against the 27.1 SDK. Build against the 27.0 SDK and the app fills more of the inner display but still leaves gaps; build against anything older and it runs in compatibility mode inside a black border with no fold reported.
|
|
5
|
+
Needs the iOS 27.1 runtime, which the device type refuses to go below, and Xcode 27.1 to build against the 27.1 SDK. Build against the 27.0 SDK and the app fills more of the inner display but still leaves gaps; build against anything older and it runs in compatibility mode inside a black border with no fold reported. In Xcode 27 the simulator lives inside **DeviceHub.app**, not `Simulator.app`.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
open "$(dirname "$(xcode-select -p)")/Applications/DeviceHub.app"
|