craft-native 0.0.293 → 0.0.296
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/dist/android/src/index.d.ts +24 -0
- package/dist/android/src/index.js +31 -0
- package/dist/android/templates/CraftNativeShell.kt.template +32 -1
- package/dist/android/templates/MainActivityNative.kt.template +1 -1
- package/dist/api/app.d.ts +2 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +24 -0
- package/dist/ios/src/index.js +31 -0
- package/dist/ios/templates/CraftApp.swift +153 -3
- package/dist/ios/templates/CraftHybrid.swift +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +60 -1
- package/dist/ios/templates/CraftWatchApp.swift.template +4 -0
- package/dist/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -45,6 +45,10 @@ export interface CraftAndroidConfig {
|
|
|
45
45
|
renderer?: 'web' | 'native';
|
|
46
46
|
/** "system" follows the device; light/dark pin the generated host. */
|
|
47
47
|
appearance?: 'light' | 'dark' | 'system';
|
|
48
|
+
/** Static native tab-bar styling applied before the page's own tabBar.set call. */
|
|
49
|
+
tabBar?: CraftTabBarConfig;
|
|
50
|
+
/** Runs from the generated project root before Gradle compiles the app. */
|
|
51
|
+
postGenerate?: CraftPostGenerateHook;
|
|
48
52
|
version?: string;
|
|
49
53
|
versionCode?: number;
|
|
50
54
|
darkMode?: boolean;
|
|
@@ -176,6 +180,19 @@ export interface CraftAndroidConfig {
|
|
|
176
180
|
sessions?: boolean;
|
|
177
181
|
};
|
|
178
182
|
}
|
|
183
|
+
export interface CraftTabBarConfig {
|
|
184
|
+
selection?: {
|
|
185
|
+
/** The selected tab indicator's corner radius in dp; omitted keeps the default. */
|
|
186
|
+
cornerRadius?: number;
|
|
187
|
+
/** The selected tab indicator fill as #RRGGBB. */
|
|
188
|
+
color?: string;
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** A declared command Craft runs after the native project has been generated. */
|
|
192
|
+
export interface CraftPostGenerateHook {
|
|
193
|
+
command: string;
|
|
194
|
+
args?: string[];
|
|
195
|
+
}
|
|
179
196
|
/** The shared health data types (the iOS builder's HealthKit names). */
|
|
180
197
|
export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat' | 'bloodGlucose' | 'insulinDelivery';
|
|
181
198
|
/**
|
|
@@ -227,6 +244,13 @@ export interface BuildOptions {
|
|
|
227
244
|
/** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */
|
|
228
245
|
runtimeDir?: string | null;
|
|
229
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Run the app's declared generator hook without invoking a shell.
|
|
249
|
+
*
|
|
250
|
+
* A hook gets the generated project as its cwd and explicit metadata so a
|
|
251
|
+
* patch can target the right platform and phase without guessing from paths.
|
|
252
|
+
*/
|
|
253
|
+
export declare function runPostGenerateHook(hook: CraftPostGenerateHook | undefined, output: string, phase?: 'build'): void;
|
|
230
254
|
export interface OpenOptions {
|
|
231
255
|
output: string;
|
|
232
256
|
}
|
|
@@ -507,6 +507,30 @@ function healthConnectPermissions(config) {
|
|
|
507
507
|
permissions.add("android.permission.health.READ_HEALTH_DATA_HISTORY");
|
|
508
508
|
return [...permissions];
|
|
509
509
|
}
|
|
510
|
+
function runPostGenerateHook(hook, output, phase = "build") {
|
|
511
|
+
if (!hook)
|
|
512
|
+
return;
|
|
513
|
+
if (typeof hook.command !== "string" || !hook.command.trim())
|
|
514
|
+
throw new Error("Android postGenerate.command must be a non-empty executable");
|
|
515
|
+
if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some((argument) => typeof argument !== "string")))
|
|
516
|
+
throw new Error("Android postGenerate.args must be an array of strings");
|
|
517
|
+
const command = [hook.command, ...hook.args ?? []];
|
|
518
|
+
let result;
|
|
519
|
+
try {
|
|
520
|
+
result = Bun.spawnSync(command, {
|
|
521
|
+
cwd: output,
|
|
522
|
+
env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: "android", CRAFT_GENERATE_PHASE: phase },
|
|
523
|
+
stdout: "inherit",
|
|
524
|
+
stderr: "pipe"
|
|
525
|
+
});
|
|
526
|
+
} catch (error) {
|
|
527
|
+
throw new Error(`Android postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error });
|
|
528
|
+
}
|
|
529
|
+
if (result.exitCode !== 0) {
|
|
530
|
+
const stderr = result.stderr?.toString().trim();
|
|
531
|
+
throw new Error(`Android postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
510
534
|
var DEFAULT_CONFIG = {
|
|
511
535
|
renderer: "web",
|
|
512
536
|
version: "1.0.0",
|
|
@@ -634,6 +658,11 @@ function validateAndroidConfig(config) {
|
|
|
634
658
|
if (config.appearance !== undefined && !["light", "dark", "system"].includes(config.appearance)) {
|
|
635
659
|
throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`);
|
|
636
660
|
}
|
|
661
|
+
const selection = config.tabBar?.selection;
|
|
662
|
+
if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
|
|
663
|
+
throw new Error("Android tabBar.selection.cornerRadius must be a finite non-negative number");
|
|
664
|
+
if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
|
|
665
|
+
throw new Error("Android tabBar.selection.color must be a #RRGGBB color");
|
|
637
666
|
const packageSegments = config.packageName.split(".");
|
|
638
667
|
if (packageSegments.length < 2 || packageSegments.some((segment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment) || KOTLIN_KEYWORDS.has(segment))) {
|
|
639
668
|
throw new Error(`Invalid Android package name: ${config.packageName}`);
|
|
@@ -1398,6 +1427,7 @@ async function build(options) {
|
|
|
1398
1427
|
console.log(` Synced: ${htmlPath} \u2192 assets/`);
|
|
1399
1428
|
}
|
|
1400
1429
|
syncNativeFonts(config.nativeFonts, output);
|
|
1430
|
+
runPostGenerateHook(config.postGenerate, output);
|
|
1401
1431
|
if (!compile)
|
|
1402
1432
|
return;
|
|
1403
1433
|
const gradlewPath = join3(output, "gradlew");
|
|
@@ -1486,6 +1516,7 @@ export {
|
|
|
1486
1516
|
resolveRuntimeDir,
|
|
1487
1517
|
run,
|
|
1488
1518
|
runKotlinFixture,
|
|
1519
|
+
runPostGenerateHook,
|
|
1489
1520
|
syncAndroidWebAssets,
|
|
1490
1521
|
syncNativeFonts
|
|
1491
1522
|
};
|
|
@@ -86,6 +86,11 @@ internal data class CraftNativeTab(
|
|
|
86
86
|
val selectedSymbol: String? = null, val selectedIconTint: String? = null, val selectedIconPulse: Boolean? = null,
|
|
87
87
|
)
|
|
88
88
|
|
|
89
|
+
internal data class CraftNativeTabBarAppearance(
|
|
90
|
+
val selectionColor: String? = null,
|
|
91
|
+
val selectionCornerRadius: Float? = null,
|
|
92
|
+
)
|
|
93
|
+
|
|
89
94
|
internal data class CraftTabIconAppearance(val symbol: String, val tint: Int?, val pulse: Boolean)
|
|
90
95
|
|
|
91
96
|
/** The app's paths (`nativeScreens`), its tabs and its site. */
|
|
@@ -122,6 +127,16 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
122
127
|
}
|
|
123
128
|
}
|
|
124
129
|
|
|
130
|
+
val tabBarAppearance: CraftNativeTabBarAppearance = config.optJSONObject("tabBar")
|
|
131
|
+
?.optJSONObject("selection")
|
|
132
|
+
?.let { selection ->
|
|
133
|
+
CraftNativeTabBarAppearance(
|
|
134
|
+
selection.optString("color").takeIf { it.isNotBlank() },
|
|
135
|
+
(selection.opt("cornerRadius") as? Number)?.toFloat()?.takeIf { it.isFinite() && it >= 0 },
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
?: CraftNativeTabBarAppearance()
|
|
139
|
+
|
|
125
140
|
/** The app's site (`devServerURL`): its origin answers paths no screen draws. */
|
|
126
141
|
val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse)
|
|
127
142
|
val siteOrigin: String? = site?.let { uri ->
|
|
@@ -755,7 +770,11 @@ internal object CraftNativePrelude {
|
|
|
755
770
|
* `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons,
|
|
756
771
|
* tabAccessory, tabBarHidden); the tabs come from the app's `tabs`.
|
|
757
772
|
*/
|
|
758
|
-
internal class CraftNativeChrome(
|
|
773
|
+
internal class CraftNativeChrome(
|
|
774
|
+
context: Context,
|
|
775
|
+
private val tabs: List<CraftNativeTab>,
|
|
776
|
+
private val tabBarAppearance: CraftNativeTabBarAppearance = CraftNativeTabBarAppearance(),
|
|
777
|
+
) : LinearLayout(context) {
|
|
759
778
|
val content = FrameLayout(context)
|
|
760
779
|
private val bar = LinearLayout(context)
|
|
761
780
|
private val backButton = ImageView(context)
|
|
@@ -1099,6 +1118,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
1099
1118
|
accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
|
|
1100
1119
|
tabViews.forEachIndexed { index, (icon, label) ->
|
|
1101
1120
|
tabBar.getChildAt(index).isSelected = index == selectedTab
|
|
1121
|
+
tabBar.getChildAt(index).background = tabCellBackground(index, dark)
|
|
1102
1122
|
val color = if (index == selectedTab) accent else muted
|
|
1103
1123
|
val id = tabs[index].id
|
|
1104
1124
|
val number = tabIconText[id]
|
|
@@ -1188,6 +1208,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
1188
1208
|
|
|
1189
1209
|
private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
|
|
1190
1210
|
|
|
1211
|
+
private fun tabCellBackground(index: Int, dark: Boolean): android.graphics.drawable.Drawable? {
|
|
1212
|
+
if (index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
|
|
1213
|
+
val color = tabBarAppearance.selectionColor?.let { runCatching { Color.parseColor(it) }.getOrNull() }
|
|
1214
|
+
?: if (dark) Color.argb(42, 255, 255, 255) else Color.argb(23, 0, 0, 0)
|
|
1215
|
+
val radius = tabBarAppearance.selectionCornerRadius?.let { (it * density).toInt().toFloat() } ?: dp(32).toFloat()
|
|
1216
|
+
return GradientDrawable().apply {
|
|
1217
|
+
setColor(color)
|
|
1218
|
+
cornerRadius = radius
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1191
1222
|
private fun rippleBackground(): android.graphics.drawable.Drawable? {
|
|
1192
1223
|
val value = TypedValue()
|
|
1193
1224
|
return if (context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, value, true)) {
|
|
@@ -4198,7 +4198,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4198
4198
|
}
|
|
4199
4199
|
|
|
4200
4200
|
private fun installChrome() {
|
|
4201
|
-
val bars = CraftNativeChrome(this, routes.tabs)
|
|
4201
|
+
val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance)
|
|
4202
4202
|
bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
|
|
4203
4203
|
bars.onBack = { onBackPressedDispatcherCompat() }
|
|
4204
4204
|
bars.onAccessory = { path -> navigateOpen(path) }
|
package/dist/api/app.d.ts
CHANGED
|
@@ -361,11 +361,11 @@ declare class AppManager {
|
|
|
361
361
|
*/
|
|
362
362
|
isShortcutRegistered(accelerator: string): Promise<boolean>;
|
|
363
363
|
/**
|
|
364
|
-
* Set app appearance
|
|
364
|
+
* Set the app appearance on native hosts.
|
|
365
365
|
*/
|
|
366
366
|
setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void>;
|
|
367
367
|
/**
|
|
368
|
-
* Get current appearance
|
|
368
|
+
* Get the current effective Light/Dark appearance.
|
|
369
369
|
*/
|
|
370
370
|
getAppearance(): Promise<'light' | 'dark'>;
|
|
371
371
|
/**
|
package/dist/cli.js
CHANGED
|
@@ -2945,7 +2945,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
2945
2945
|
`);
|
|
2946
2946
|
}
|
|
2947
2947
|
// package.json
|
|
2948
|
-
var version = "0.0.
|
|
2948
|
+
var version = "0.0.296";
|
|
2949
2949
|
|
|
2950
2950
|
// bin/cli.ts
|
|
2951
2951
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -9,6 +9,19 @@ export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], outpu
|
|
|
9
9
|
/** This package's version, which the app names in its user agent (`Craft/<version>`). */
|
|
10
10
|
export declare function resolveCraftVersion(startDir?: string): string;
|
|
11
11
|
export type CraftAppearance = 'light' | 'dark' | 'system';
|
|
12
|
+
/** A declared command Craft runs after the native project has been generated. */
|
|
13
|
+
export interface CraftPostGenerateHook {
|
|
14
|
+
command: string;
|
|
15
|
+
args?: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface CraftTabBarConfig {
|
|
18
|
+
selection?: {
|
|
19
|
+
/** The selected tab indicator's corner radius in points; omitted keeps a capsule. */
|
|
20
|
+
cornerRadius?: number;
|
|
21
|
+
/** The selected tab indicator fill as #RRGGBB. */
|
|
22
|
+
color?: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
12
25
|
export interface CraftConfig {
|
|
13
26
|
appShortcuts?: CraftAppShortcut[];
|
|
14
27
|
/** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
|
|
@@ -29,6 +42,10 @@ export interface CraftConfig {
|
|
|
29
42
|
* and `dark` pin one. Unset keeps the older `darkMode` switch.
|
|
30
43
|
*/
|
|
31
44
|
appearance?: CraftAppearance;
|
|
45
|
+
/** Static native tab-bar styling applied before the page's own tabBar.set call. */
|
|
46
|
+
tabBar?: CraftTabBarConfig;
|
|
47
|
+
/** Runs from the generated project root after `craft ios build` writes it. */
|
|
48
|
+
postGenerate?: CraftPostGenerateHook;
|
|
32
49
|
backgroundColor?: string;
|
|
33
50
|
/** The launch and webview background while the phone is in Dark Mode. */
|
|
34
51
|
backgroundColorDark?: string;
|
|
@@ -409,6 +426,13 @@ export interface BuildOptions {
|
|
|
409
426
|
*/
|
|
410
427
|
runtimeDir?: string | null;
|
|
411
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* Run the app's declared generator hook without invoking a shell.
|
|
431
|
+
*
|
|
432
|
+
* A hook gets the generated project as its cwd and explicit metadata so a
|
|
433
|
+
* patch can target the right platform and phase without guessing from paths.
|
|
434
|
+
*/
|
|
435
|
+
export declare function runPostGenerateHook(hook: CraftPostGenerateHook | undefined, output: string, phase?: 'build'): void;
|
|
412
436
|
export interface OpenOptions {
|
|
413
437
|
output: string;
|
|
414
438
|
}
|
package/dist/ios/src/index.js
CHANGED
|
@@ -512,6 +512,30 @@ function installNativeBundle(source, output) {
|
|
|
512
512
|
function hasInstalledNativeBundle(output) {
|
|
513
513
|
return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
|
|
514
514
|
}
|
|
515
|
+
function runPostGenerateHook(hook, output, phase = "build") {
|
|
516
|
+
if (!hook)
|
|
517
|
+
return;
|
|
518
|
+
if (typeof hook.command !== "string" || !hook.command.trim())
|
|
519
|
+
throw new Error("iOS postGenerate.command must be a non-empty executable");
|
|
520
|
+
if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some((argument) => typeof argument !== "string")))
|
|
521
|
+
throw new Error("iOS postGenerate.args must be an array of strings");
|
|
522
|
+
const command = [hook.command, ...hook.args ?? []];
|
|
523
|
+
let result;
|
|
524
|
+
try {
|
|
525
|
+
result = Bun.spawnSync(command, {
|
|
526
|
+
cwd: output,
|
|
527
|
+
env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: "ios", CRAFT_GENERATE_PHASE: phase },
|
|
528
|
+
stdout: "inherit",
|
|
529
|
+
stderr: "pipe"
|
|
530
|
+
});
|
|
531
|
+
} catch (error) {
|
|
532
|
+
throw new Error(`iOS postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error });
|
|
533
|
+
}
|
|
534
|
+
if (result.exitCode !== 0) {
|
|
535
|
+
const stderr = result.stderr?.toString().trim();
|
|
536
|
+
throw new Error(`iOS postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
515
539
|
var DEFAULT_CONFIG = {
|
|
516
540
|
renderer: "web",
|
|
517
541
|
version: "1.0.0",
|
|
@@ -1230,6 +1254,11 @@ async function init(options) {
|
|
|
1230
1254
|
if (config.renderer !== "web" && config.renderer !== "native") {
|
|
1231
1255
|
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
1232
1256
|
}
|
|
1257
|
+
const selection = config.tabBar?.selection;
|
|
1258
|
+
if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
|
|
1259
|
+
throw new Error("iOS tabBar.selection.cornerRadius must be a finite non-negative number");
|
|
1260
|
+
if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
|
|
1261
|
+
throw new Error("iOS tabBar.selection.color must be a #RRGGBB color");
|
|
1233
1262
|
if (config.enableBackgroundLocation)
|
|
1234
1263
|
config.enableGeolocation = true;
|
|
1235
1264
|
const problems = hybridConfigProblems(config);
|
|
@@ -1425,6 +1454,7 @@ async function build(options) {
|
|
|
1425
1454
|
cause: error
|
|
1426
1455
|
});
|
|
1427
1456
|
}
|
|
1457
|
+
runPostGenerateHook(config.postGenerate, output);
|
|
1428
1458
|
console.log("");
|
|
1429
1459
|
}
|
|
1430
1460
|
async function open(options) {
|
|
@@ -1590,6 +1620,7 @@ export {
|
|
|
1590
1620
|
resolveRuntimeDir,
|
|
1591
1621
|
restoreShell,
|
|
1592
1622
|
run,
|
|
1623
|
+
runPostGenerateHook,
|
|
1593
1624
|
serializeSimulatorSteps,
|
|
1594
1625
|
showSimulator,
|
|
1595
1626
|
syncNativeFonts,
|
|
@@ -102,6 +102,96 @@ enum CraftPageAppearance {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/// The app-wide UIKit appearance override. SwiftUI's `preferredColorScheme`
|
|
106
|
+
/// only controls the SwiftUI environment; native screen controllers need the
|
|
107
|
+
/// UIKit override as well so their trait collection, controls and navigation
|
|
108
|
+
/// chrome resolve the same way. A runtime choice is kept here so screens
|
|
109
|
+
/// created after the choice inherit it instead of reverting to the bundle
|
|
110
|
+
/// config.
|
|
111
|
+
enum CraftInterfaceStyleController {
|
|
112
|
+
/// The app's bundled choice is the default for newly-created screens.
|
|
113
|
+
/// A runtime choice is separate: it is the only value that should
|
|
114
|
+
/// override a screen's own generated config.
|
|
115
|
+
private static var configured: UIUserInterfaceStyle = .unspecified
|
|
116
|
+
private static var runtimeSelection: UIUserInterfaceStyle?
|
|
117
|
+
|
|
118
|
+
static func configuredStyle(for config: CraftConfig) -> UIUserInterfaceStyle {
|
|
119
|
+
switch config.appearance {
|
|
120
|
+
case "system": return .unspecified
|
|
121
|
+
case "light": return .light
|
|
122
|
+
case "dark": return .dark
|
|
123
|
+
default: return config.darkMode ? .dark : .light
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static func configure(_ config: CraftConfig) {
|
|
128
|
+
configured = configuredStyle(for: config)
|
|
129
|
+
runtimeSelection = nil
|
|
130
|
+
applyToWindows()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static func style(for config: CraftConfig) -> UIUserInterfaceStyle {
|
|
134
|
+
runtimeSelection ?? configuredStyle(for: config)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
static func colorScheme(for config: CraftConfig) -> ColorScheme? {
|
|
138
|
+
switch style(for: config) {
|
|
139
|
+
case .light: return .light
|
|
140
|
+
case .dark: return .dark
|
|
141
|
+
default: return nil
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// Returns false rather than silently accepting a typo from JavaScript.
|
|
146
|
+
@discardableResult
|
|
147
|
+
static func set(_ value: String) -> Bool {
|
|
148
|
+
let style: UIUserInterfaceStyle
|
|
149
|
+
switch value {
|
|
150
|
+
case "system": style = .unspecified
|
|
151
|
+
case "light": style = .light
|
|
152
|
+
case "dark": style = .dark
|
|
153
|
+
default: return false
|
|
154
|
+
}
|
|
155
|
+
runtimeSelection = style
|
|
156
|
+
applyToWindows()
|
|
157
|
+
NotificationCenter.default.post(name: .craftColorSchemeChanged, object: nil)
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static func currentName() -> String {
|
|
162
|
+
let requested = runtimeSelection ?? UIApplication.shared.connectedScenes
|
|
163
|
+
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
|
|
164
|
+
.first ?? .unspecified
|
|
165
|
+
let style = requested == .unspecified
|
|
166
|
+
? (UIApplication.shared.connectedScenes
|
|
167
|
+
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
|
|
168
|
+
.first { $0 != .unspecified } ?? UITraitCollection.current.userInterfaceStyle)
|
|
169
|
+
: requested
|
|
170
|
+
return style == .dark ? "dark" : "light"
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
static func apply(to controller: UIViewController, config: CraftConfig) {
|
|
174
|
+
controller.overrideUserInterfaceStyle = style(for: config)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private static func applyToWindows() {
|
|
178
|
+
let style = runtimeSelection ?? configured
|
|
179
|
+
for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
|
|
180
|
+
for window in scene.windows {
|
|
181
|
+
window.overrideUserInterfaceStyle = style
|
|
182
|
+
apply(style, to: window.rootViewController)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private static func apply(_ style: UIUserInterfaceStyle, to controller: UIViewController?) {
|
|
188
|
+
guard let controller else { return }
|
|
189
|
+
controller.overrideUserInterfaceStyle = style
|
|
190
|
+
controller.children.forEach { apply(style, to: $0) }
|
|
191
|
+
if let presented = controller.presentedViewController { apply(style, to: presented) }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
105
195
|
/// Tells the shell when SwiftUI's colour scheme changes: the one appearance
|
|
106
196
|
/// setting with no notification of its own.
|
|
107
197
|
struct CraftColorSchemeObserver: View {
|
|
@@ -195,6 +285,7 @@ struct CraftRecoveryBudget {
|
|
|
195
285
|
final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
|
196
286
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
|
197
287
|
let config = CraftConfig.bundled()
|
|
288
|
+
CraftInterfaceStyleController.configure(config)
|
|
198
289
|
// First, so a crash anywhere in the rest of launch is reported too.
|
|
199
290
|
CraftCrashReporter.shared.start(config)
|
|
200
291
|
UNUserNotificationCenter.current().delegate = self
|
|
@@ -606,7 +697,7 @@ struct CraftApp: App {
|
|
|
606
697
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
607
698
|
}
|
|
608
699
|
}
|
|
609
|
-
.preferredColorScheme(appState.
|
|
700
|
+
.preferredColorScheme(appState.appearanceScheme)
|
|
610
701
|
.environmentObject(appState)
|
|
611
702
|
.onOpenURL { url in
|
|
612
703
|
// Handle deep links and universal links
|
|
@@ -689,6 +780,10 @@ final class CraftChrome: ObservableObject {
|
|
|
689
780
|
/// The selected tab highlight's corner radius. Nil preserves the default
|
|
690
781
|
/// capsule, while a finite non-negative value opts into a rounded rect.
|
|
691
782
|
private(set) var tabSelectionCornerRadius: CGFloat?
|
|
783
|
+
/// The configured static shape, retained when the page updates only its
|
|
784
|
+
/// tabs or selected item at runtime.
|
|
785
|
+
private var configuredTabSelectionCornerRadius: CGFloat?
|
|
786
|
+
private(set) var tabSelectionColor: UIColor?
|
|
692
787
|
|
|
693
788
|
weak var webView: WKWebView?
|
|
694
789
|
/// What the tab bar covers at the bottom of the screen, in points.
|
|
@@ -723,9 +818,13 @@ final class CraftChrome: ObservableObject {
|
|
|
723
818
|
/// The tab bar from `tabs` in craft.config.json, there before the page
|
|
724
819
|
/// loads; the page's own `tabBar` message replaces it later. `splash`
|
|
725
820
|
/// false when the first screen is native and draws itself.
|
|
726
|
-
func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
|
|
821
|
+
func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool, appearance: CraftConfig.TabBar? = nil) {
|
|
727
822
|
splashVisible = splash
|
|
728
823
|
tabSwipeEnabled = true
|
|
824
|
+
configuredTabSelectionCornerRadius = appearance?.selection?.cornerRadius
|
|
825
|
+
.flatMap { $0.isFinite && $0 >= 0 ? CGFloat($0) : nil }
|
|
826
|
+
tabSelectionCornerRadius = configuredTabSelectionCornerRadius
|
|
827
|
+
tabSelectionColor = appearance?.selection?.color.flatMap { UIColor(hex: $0) }
|
|
729
828
|
guard !configured.isEmpty else { return }
|
|
730
829
|
tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil, selectedSymbol: Tab.normalizedSelectedSymbol($0.selectedSymbol), selectedIconTint: $0.selectedIconTint, selectedIconPulse: $0.selectedIconPulse) }
|
|
731
830
|
self.selected = selected ?? configured.first?.id
|
|
@@ -856,6 +955,7 @@ final class CraftChrome: ObservableObject {
|
|
|
856
955
|
tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
|
|
857
956
|
}
|
|
858
957
|
if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled }
|
|
958
|
+
if !body.keys.contains("selection") { tabSelectionCornerRadius = configuredTabSelectionCornerRadius }
|
|
859
959
|
withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen }
|
|
860
960
|
publishLayout()
|
|
861
961
|
case "selectTab":
|
|
@@ -1312,6 +1412,7 @@ private struct CraftTabSelectionShape: Shape {
|
|
|
1312
1412
|
}
|
|
1313
1413
|
|
|
1314
1414
|
private struct CraftTabSelectionIndicator: View {
|
|
1415
|
+
@ObservedObject private var chrome = CraftChrome.shared
|
|
1315
1416
|
@ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
|
|
1316
1417
|
let selected: Int?
|
|
1317
1418
|
let count: Int
|
|
@@ -1323,7 +1424,7 @@ private struct CraftTabSelectionIndicator: View {
|
|
|
1323
1424
|
let width = geometry.size.width / CGFloat(max(1, count))
|
|
1324
1425
|
CraftTabSelectionShape(cornerRadius: cornerRadius)
|
|
1325
1426
|
.frame(width: width, height: geometry.size.height)
|
|
1326
|
-
.foregroundStyle(Color.primary.opacity(0.09))
|
|
1427
|
+
.foregroundStyle(chrome.tabSelectionColor.map { Color($0) } ?? Color.primary.opacity(0.09))
|
|
1327
1428
|
.offset(x: position * width)
|
|
1328
1429
|
}
|
|
1329
1430
|
}
|
|
@@ -1557,6 +1658,8 @@ class DeepLinkManager {
|
|
|
1557
1658
|
// MARK: - App State
|
|
1558
1659
|
class AppState: ObservableObject {
|
|
1559
1660
|
@Published var config: CraftConfig
|
|
1661
|
+
@Published private(set) var appearanceScheme: ColorScheme?
|
|
1662
|
+
private var appearanceObserver: NSObjectProtocol?
|
|
1560
1663
|
|
|
1561
1664
|
init() {
|
|
1562
1665
|
if let configURL = Bundle.main.url(forResource: "craft.config", withExtension: "json"),
|
|
@@ -1565,11 +1668,26 @@ class AppState: ObservableObject {
|
|
|
1565
1668
|
} else {
|
|
1566
1669
|
self.config = CraftConfig()
|
|
1567
1670
|
}
|
|
1671
|
+
self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: config)
|
|
1672
|
+
appearanceObserver = NotificationCenter.default.addObserver(
|
|
1673
|
+
forName: .craftColorSchemeChanged,
|
|
1674
|
+
object: nil,
|
|
1675
|
+
queue: .main,
|
|
1676
|
+
) { [weak self] _ in
|
|
1677
|
+
guard let self else { return }
|
|
1678
|
+
self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: self.config)
|
|
1679
|
+
}
|
|
1568
1680
|
// Before the first frame: the hybrid start screen, and the tab bar
|
|
1569
1681
|
// from the config, are decided here, if the app delegate has not
|
|
1570
1682
|
// already (it does at launch; the first call decides).
|
|
1571
1683
|
CraftHybrid.shared.configure(config)
|
|
1572
1684
|
}
|
|
1685
|
+
|
|
1686
|
+
deinit {
|
|
1687
|
+
if let appearanceObserver {
|
|
1688
|
+
NotificationCenter.default.removeObserver(appearanceObserver)
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1573
1691
|
}
|
|
1574
1692
|
|
|
1575
1693
|
extension CraftConfig {
|
|
@@ -1706,6 +1824,9 @@ struct CraftConfig: Codable {
|
|
|
1706
1824
|
var nativeScreens: [String: String]? = nil
|
|
1707
1825
|
/// The tab bar to show before the page describes its own.
|
|
1708
1826
|
var tabs: [Tab]? = nil
|
|
1827
|
+
/// Static styling for the selected tab indicator; the page may still
|
|
1828
|
+
/// override its shape through `tabBar.set({ selection })`.
|
|
1829
|
+
var tabBar: TabBar? = nil
|
|
1709
1830
|
/// Hybrid: web-storage keys the page's own code writes, mirrored into the
|
|
1710
1831
|
/// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
|
|
1711
1832
|
var shareStorage: [String: String]? = nil
|
|
@@ -1733,6 +1854,15 @@ struct CraftConfig: Codable {
|
|
|
1733
1854
|
var selectedIconPulse: Bool? = nil
|
|
1734
1855
|
}
|
|
1735
1856
|
|
|
1857
|
+
struct TabBar: Codable {
|
|
1858
|
+
var selection: TabSelection? = nil
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
struct TabSelection: Codable {
|
|
1862
|
+
var cornerRadius: Double? = nil
|
|
1863
|
+
var color: String? = nil
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1736
1866
|
struct BackgroundRefresh: Codable {
|
|
1737
1867
|
var enabled: Bool = false
|
|
1738
1868
|
/// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset.
|
|
@@ -2535,6 +2665,18 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2535
2665
|
resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
|
|
2536
2666
|
case "statusBarSetStyle":
|
|
2537
2667
|
resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default"))
|
|
2668
|
+
case "setAppearance":
|
|
2669
|
+
guard let appearance = body["appearance"] as? String else {
|
|
2670
|
+
rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
|
|
2671
|
+
return
|
|
2672
|
+
}
|
|
2673
|
+
guard CraftInterfaceStyleController.set(appearance) else {
|
|
2674
|
+
rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
|
|
2675
|
+
return
|
|
2676
|
+
}
|
|
2677
|
+
resolveCallback(callbackId, result: true)
|
|
2678
|
+
case "getAppearance":
|
|
2679
|
+
resolveCallback(callbackId, result: CraftInterfaceStyleController.currentName())
|
|
2538
2680
|
case "chromeSetUnderPageColor":
|
|
2539
2681
|
// What shows past the page's edges when it rubber-bands.
|
|
2540
2682
|
if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) {
|
|
@@ -5596,6 +5738,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5596
5738
|
getInfo: function() { return craft.getDeviceInfo(); },
|
|
5597
5739
|
getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
|
|
5598
5740
|
};
|
|
5741
|
+
craft.app = Object.assign(craft.app || {}, {
|
|
5742
|
+
setAppearance: function(appearance) {
|
|
5743
|
+
return craft._invoke('setAppearance', {appearance: appearance});
|
|
5744
|
+
},
|
|
5745
|
+
getAppearance: function() {
|
|
5746
|
+
return craft._invoke('getAppearance', {});
|
|
5747
|
+
}
|
|
5748
|
+
});
|
|
5599
5749
|
// Feedback, as on Android and the web, where there is no
|
|
5600
5750
|
// motor to fire: an app that left enableHaptics off gets
|
|
5601
5751
|
// nothing played and a settled promise, so `await
|
|
@@ -380,7 +380,7 @@ final class CraftHybrid: NSObject {
|
|
|
380
380
|
}
|
|
381
381
|
startsNative = isEnabled && routes.match(startPath) != nil
|
|
382
382
|
// The splash waits for a page; an all-native app's first screen draws itself.
|
|
383
|
-
CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative)
|
|
383
|
+
CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative, appearance: config.tabBar)
|
|
384
384
|
|
|
385
385
|
let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 }
|
|
386
386
|
appScriptRoute = script != nil && Self.hasBundle && (isEnabled || !hasWeb) ? script : nil
|
|
@@ -2983,7 +2983,10 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2983
2983
|
// The screen reports onLayout from its own layout pass, which a
|
|
2984
2984
|
// change deep in a scroll view's content never runs: a view that
|
|
2985
2985
|
// went from 240 to 180 wide kept saying 240.
|
|
2986
|
-
if moved {
|
|
2986
|
+
if moved {
|
|
2987
|
+
craftScreenController?.scheduleLayoutEvents()
|
|
2988
|
+
craftScreenController?.scheduleKeyboardInsets()
|
|
2989
|
+
}
|
|
2987
2990
|
}
|
|
2988
2991
|
for (child, frame) in result.frames {
|
|
2989
2992
|
var viewportFrame = frame
|
|
@@ -5112,6 +5115,11 @@ private final class CraftNativeTextView: UITextView {
|
|
|
5112
5115
|
isScrollEnabled = overflows
|
|
5113
5116
|
if overflows, isFirstResponder { scrollRangeToVisible(selectedRange) }
|
|
5114
5117
|
}
|
|
5118
|
+
// Showing all its text, it has nothing to scroll. An offset left from
|
|
5119
|
+
// UIKit keeping the caret in view while the view was a layout short
|
|
5120
|
+
// of its text moved every line down inside it, and the caret of a
|
|
5121
|
+
// growing comment sat below the part of the screen it was kept in.
|
|
5122
|
+
if !isScrollEnabled, contentOffset != .zero { contentOffset = .zero }
|
|
5115
5123
|
let inset = textContainerInset
|
|
5116
5124
|
let padding = textContainer.lineFragmentPadding
|
|
5117
5125
|
placeholderLabel.frame = CGRect(
|
|
@@ -5492,6 +5500,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5492
5500
|
private var keyboardSafeAreaInset: CGFloat = 0
|
|
5493
5501
|
/// Where the keyboard is on the screen while it is up, in screen coordinates.
|
|
5494
5502
|
private var keyboardScreenFrame: CGRect?
|
|
5503
|
+
private var keyboardInsetsScheduled = false
|
|
5495
5504
|
/// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
|
|
5496
5505
|
/// app's native screens: told of the first frame, of a first frame that
|
|
5497
5506
|
/// could not be drawn, and of `craft.navigation.open(path)`.
|
|
@@ -5536,6 +5545,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5536
5545
|
|
|
5537
5546
|
override func viewDidLoad() {
|
|
5538
5547
|
super.viewDidLoad()
|
|
5548
|
+
CraftInterfaceStyleController.apply(to: self, config: config)
|
|
5539
5549
|
navigationItem.title = routeName ?? config.appName
|
|
5540
5550
|
view.backgroundColor = config.resolvedBackgroundColor
|
|
5541
5551
|
rootStack.axis = .vertical
|
|
@@ -5802,6 +5812,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5802
5812
|
positionModals()
|
|
5803
5813
|
emitLayoutEvents()
|
|
5804
5814
|
sendViewportIfChanged()
|
|
5815
|
+
scheduleKeyboardInsets()
|
|
5805
5816
|
}
|
|
5806
5817
|
|
|
5807
5818
|
/// The width the bundle last heard, so a layout pass at the same size says nothing.
|
|
@@ -6240,6 +6251,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6240
6251
|
let secureGet: @convention(block) (String) -> String? = { key in
|
|
6241
6252
|
config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
|
|
6242
6253
|
}
|
|
6254
|
+
let setAppearance: @convention(block) (String) -> Bool = { value in
|
|
6255
|
+
if Thread.isMainThread { return CraftInterfaceStyleController.set(value) }
|
|
6256
|
+
var accepted = false
|
|
6257
|
+
DispatchQueue.main.sync { accepted = CraftInterfaceStyleController.set(value) }
|
|
6258
|
+
return accepted
|
|
6259
|
+
}
|
|
6260
|
+
let getAppearance: @convention(block) () -> String = {
|
|
6261
|
+
if Thread.isMainThread { return CraftInterfaceStyleController.currentName() }
|
|
6262
|
+
var value = "light"
|
|
6263
|
+
DispatchQueue.main.sync { value = CraftInterfaceStyleController.currentName() }
|
|
6264
|
+
return value
|
|
6265
|
+
}
|
|
6243
6266
|
let setOptions: @convention(block) (String) -> Void = { [weak self] json in
|
|
6244
6267
|
guard let data = json.data(using: .utf8),
|
|
6245
6268
|
let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
@@ -6252,6 +6275,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6252
6275
|
jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
|
|
6253
6276
|
jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
|
|
6254
6277
|
jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
|
|
6278
|
+
jsContext.setObject(setAppearance, forKeyedSubscript: "craftNativeSetAppearance" as NSString)
|
|
6279
|
+
jsContext.setObject(getAppearance, forKeyedSubscript: "craftNativeGetAppearance" as NSString)
|
|
6255
6280
|
jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
|
|
6256
6281
|
// Older native bundles call the page-shaped `craft.dialog` API
|
|
6257
6282
|
// directly. Keep that API backed by the same capability protocol as
|
|
@@ -6494,6 +6519,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6494
6519
|
return value === undefined ? null : value;
|
|
6495
6520
|
}
|
|
6496
6521
|
});
|
|
6522
|
+
keep('app', {
|
|
6523
|
+
setAppearance: function(value) {
|
|
6524
|
+
value = String(value);
|
|
6525
|
+
if (value !== 'light' && value !== 'dark' && value !== 'system') {
|
|
6526
|
+
return Promise.reject(new TypeError('craft.app.setAppearance needs light, dark or system'));
|
|
6527
|
+
}
|
|
6528
|
+
if (!craftNativeSetAppearance(value)) {
|
|
6529
|
+
return Promise.reject(new Error('craft.app.setAppearance was rejected'));
|
|
6530
|
+
}
|
|
6531
|
+
return Promise.resolve();
|
|
6532
|
+
},
|
|
6533
|
+
getAppearance: function() { return Promise.resolve(craftNativeGetAppearance()); }
|
|
6534
|
+
});
|
|
6497
6535
|
// The signed-in account (CraftSession): the token under
|
|
6498
6536
|
// `sessionTokenKey`, and each change of it, which the host
|
|
6499
6537
|
// reports through `__craftSessionChanged` after the Keychain
|
|
@@ -7148,6 +7186,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7148
7186
|
}
|
|
7149
7187
|
}
|
|
7150
7188
|
|
|
7189
|
+
/// A layout moved a view while the keyboard is up: once that pass is
|
|
7190
|
+
/// over, each scroll view's keyboard inset is measured again where the
|
|
7191
|
+
/// scroll view now ends. Measured only when the keyboard moved, a form
|
|
7192
|
+
/// that grew down under the keyboard (a footer below it shrank, a
|
|
7193
|
+
/// banner above it went away) kept the inset of where it used to end,
|
|
7194
|
+
/// and its last lines stayed under the keyboard.
|
|
7195
|
+
fileprivate func scheduleKeyboardInsets() {
|
|
7196
|
+
guard keyboardScreenFrame != nil, !keyboardInsetsScheduled else { return }
|
|
7197
|
+
keyboardInsetsScheduled = true
|
|
7198
|
+
DispatchQueue.main.async { [weak self] in
|
|
7199
|
+
guard let self else { return }
|
|
7200
|
+
self.keyboardInsetsScheduled = false
|
|
7201
|
+
if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
|
|
7202
|
+
}
|
|
7203
|
+
}
|
|
7204
|
+
|
|
7151
7205
|
/// The focused field, scrolled into the part of its scroll view the
|
|
7152
7206
|
/// keyboard leaves, with a little room around it.
|
|
7153
7207
|
func scrollFocusedInputIntoView(animated: Bool) {
|
|
@@ -7159,6 +7213,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7159
7213
|
/// Internal for XCTest.
|
|
7160
7214
|
func scrollIntoView(_ input: UIView, animated: Bool) {
|
|
7161
7215
|
guard let scroll = enclosingVerticalScroll(of: input) else { return }
|
|
7216
|
+
// Measured against the keyboard where the scroll view is now.
|
|
7217
|
+
if keyboardScreenFrame != nil { insetScrollViewsForKeyboard() }
|
|
7162
7218
|
var rect = input.convert(input.bounds, to: scroll).insetBy(dx: 0, dy: -16)
|
|
7163
7219
|
// A field taller than the part the keyboard leaves (a long comment
|
|
7164
7220
|
// with no max height) shows its caret rather than its top: scrolled
|
|
@@ -9951,6 +10007,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9951
10007
|
textView.invalidateIntrinsicContentSize()
|
|
9952
10008
|
CraftNativeFlexLayout.invalidateAncestors(of: textView)
|
|
9953
10009
|
self.view.layoutIfNeeded()
|
|
10010
|
+
// That layout can move the scroll view: its keyboard inset is
|
|
10011
|
+
// measured where it now ends before the caret is.
|
|
10012
|
+
if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
|
|
9954
10013
|
guard let scroll = self.enclosingVerticalScroll(of: textView),
|
|
9955
10014
|
let rect = self.caretRect(of: textView, in: scroll) else { return }
|
|
9956
10015
|
let visible = scroll.bounds.inset(by: scroll.adjustedContentInset)
|
|
@@ -117,6 +117,10 @@ final class CraftWatchSession: NSObject, ObservableObject {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
private func apply(_ payload: [String: Any]) {
|
|
120
|
+
if let incomingVersion = payload["stateVersion"] as? Int {
|
|
121
|
+
guard incomingVersion >= stateVersion else { return }
|
|
122
|
+
stateVersion = incomingVersion
|
|
123
|
+
}
|
|
120
124
|
if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
|
|
121
125
|
plannedSession = plan
|
|
122
126
|
status = "Plan ready"
|
package/dist/types.d.ts
CHANGED
|
@@ -569,6 +569,17 @@ export interface AppConfig {
|
|
|
569
569
|
* Craft System Tray API (available as window.craft.tray in WebView)
|
|
570
570
|
*/
|
|
571
571
|
export interface CraftTrayAPI {
|
|
572
|
+
/** Read the rendered macOS status item, including its decoded image. */
|
|
573
|
+
getState(): Promise<{
|
|
574
|
+
title: string;
|
|
575
|
+
tooltip: string;
|
|
576
|
+
visible: boolean;
|
|
577
|
+
hasIcon: boolean;
|
|
578
|
+
template: boolean;
|
|
579
|
+
imageWidth: number;
|
|
580
|
+
imageHeight: number;
|
|
581
|
+
imagePosition: number;
|
|
582
|
+
}>;
|
|
572
583
|
/** SF Symbol name, image path, or base64 image data URL. */
|
|
573
584
|
setIcon(icon: string): Promise<void>;
|
|
574
585
|
/**
|
|
@@ -649,6 +660,10 @@ export interface CraftAppAPI {
|
|
|
649
660
|
* Get application information
|
|
650
661
|
*/
|
|
651
662
|
getInfo(): Promise<AppInfo>;
|
|
663
|
+
/** Pin every native surface to Light, Dark, or the system setting. */
|
|
664
|
+
setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void>;
|
|
665
|
+
/** Read the app's effective Light/Dark appearance. */
|
|
666
|
+
getAppearance(): Promise<'light' | 'dark'>;
|
|
652
667
|
}
|
|
653
668
|
/**
|
|
654
669
|
* Tray click event details
|