craft-native 0.0.442 → 0.0.446
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 +23 -0
- package/dist/android/src/index.d.ts +36 -0
- package/dist/android/src/index.js +65 -1
- package/dist/android/templates/AndroidManifest.xml.template +2 -1
- package/dist/android/templates/MainActivity.kt.template +3 -0
- package/dist/android/templates/MainActivityNative.kt.template +3 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +79 -15
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +79 -15
- package/dist/ios/src/index.d.ts +13 -1
- package/dist/ios/src/index.js +21 -7
- package/dist/ios/templates/CraftNativeScreen.swift +222 -9
- package/dist/ios/templates/testing/DriverTests.swift.template +19 -3
- package/dist/macos/templates/CraftMacRenderer.swift +22 -9
- package/dist/macos/templates/CraftMacRuntime.swift +38 -12
- package/dist/updater/index.d.ts +25 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -339,6 +339,29 @@ if (await updater.checkForUpdates()) {
|
|
|
339
339
|
}
|
|
340
340
|
```
|
|
341
341
|
|
|
342
|
+
### Background update lifecycle
|
|
343
|
+
|
|
344
|
+
Start and stop background checks through Craft rather than an app-owned timer:
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
updater.startAutoCheck({
|
|
348
|
+
initialDelay: 60_000,
|
|
349
|
+
enabled: async () => (await readNativePreferences()).automaticUpdates,
|
|
350
|
+
autoDownload: true,
|
|
351
|
+
})
|
|
352
|
+
// On native backend shutdown:
|
|
353
|
+
updater.stopAutoCheck()
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
`checkInterval` on the updater config controls cadence. `enabled` is re-read
|
|
357
|
+
at most a minute later (`policyInterval`), and again before a background download.
|
|
358
|
+
The host owns preference storage; Craft creates no parallel database. Starts are
|
|
359
|
+
idempotent, concurrent manifest checks and downloads share one operation, and a
|
|
360
|
+
verified download stays ready until the user chooses installation. Stopping also
|
|
361
|
+
invalidates policy reads already in flight. Downloads already started may finish.
|
|
362
|
+
`autoInstall` defaults to false; keep it false when installation requires a user
|
|
363
|
+
choice. Timers are unreferenced so they cannot keep a backend process alive.
|
|
364
|
+
|
|
342
365
|
### macOS trust
|
|
343
366
|
|
|
344
367
|
The manifest's SHA-256 proves the download matches what the manifest asked for. It
|
|
@@ -122,6 +122,18 @@ export interface CraftAndroidConfig {
|
|
|
122
122
|
}>;
|
|
123
123
|
trustedOrigins?: string[];
|
|
124
124
|
appIconPath?: string;
|
|
125
|
+
/**
|
|
126
|
+
* The launch splash: a directory laid out as Android resources, holding
|
|
127
|
+
* `drawable-<density>/splash_icon.png` (Android 12's splash icon, 288dp
|
|
128
|
+
* with its content inside the 192dp circle) and `splash_logo.png` (the
|
|
129
|
+
* logo the window background centres before Android 12), with Dark Mode
|
|
130
|
+
* ones in `drawable-night-<density>`. ts-images' `generateSplashScreens`
|
|
131
|
+
* writes exactly this (`android.resources`). Both sit on `backgroundColor`
|
|
132
|
+
* (`backgroundColorDark` at night), the colour the first frame continues.
|
|
133
|
+
* Without it, Android 12 shows the launcher icon and older versions the
|
|
134
|
+
* background colour.
|
|
135
|
+
*/
|
|
136
|
+
splashResources?: string;
|
|
125
137
|
/**
|
|
126
138
|
* The small icon in the status bar and on every notification the app
|
|
127
139
|
* shows, push or local: an Android vector drawable (`.xml`) or a `.png`,
|
|
@@ -268,6 +280,30 @@ export interface RunOptions {
|
|
|
268
280
|
device?: string;
|
|
269
281
|
output: string;
|
|
270
282
|
}
|
|
283
|
+
/** A splash file, by its place under `res/` (`drawable-night-xxhdpi/splash_icon.png`). */
|
|
284
|
+
export interface AndroidSplashFile {
|
|
285
|
+
folder: string;
|
|
286
|
+
name: 'splash_icon.png' | 'splash_logo.png';
|
|
287
|
+
path: string;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The splash files in a resources directory: `splash_icon.png` and
|
|
291
|
+
* `splash_logo.png` in each `drawable[-night][-<density>]` folder. A directory
|
|
292
|
+
* with neither is a mistake, not an app without a splash.
|
|
293
|
+
*/
|
|
294
|
+
export declare function androidSplashFiles(resources: string): AndroidSplashFile[];
|
|
295
|
+
/**
|
|
296
|
+
* The launch theme the activity starts in, and the theme it swaps to before
|
|
297
|
+
* its first frame. Android 12 and later draw their splash from
|
|
298
|
+
* `windowSplashScreen*`; earlier versions draw the window background, here
|
|
299
|
+
* the logo centred on the background colour. Either way the colour is the
|
|
300
|
+
* app's own `background`, so nothing flashes when the first frame replaces it.
|
|
301
|
+
*/
|
|
302
|
+
export declare function androidLaunchThemes(splash: Pick<AndroidSplashFile, 'name'>[]): {
|
|
303
|
+
base: string;
|
|
304
|
+
v31: string;
|
|
305
|
+
background?: string;
|
|
306
|
+
};
|
|
271
307
|
export declare function syncAndroidWebAssets(source: string, output: string): void;
|
|
272
308
|
export declare function renderAndroidPermissions(config: CraftAndroidConfig): string;
|
|
273
309
|
export declare function renderAndroidDeepLinks(config: CraftAndroidConfig): string;
|
|
@@ -757,6 +757,49 @@ function requireRegularFile(path, label) {
|
|
|
757
757
|
if (!statSync(path).isFile())
|
|
758
758
|
throw new Error(`${label} must be a file: ${path}`);
|
|
759
759
|
}
|
|
760
|
+
function androidSplashFiles(resources) {
|
|
761
|
+
if (!existsSync(resources) || !statSync(resources).isDirectory())
|
|
762
|
+
throw new Error(`Splash resources must be a directory: ${resources}`);
|
|
763
|
+
const files = [];
|
|
764
|
+
for (const folder of readdirSync(resources).sort()) {
|
|
765
|
+
if (!/^drawable(?:-night)?(?:-(?:l|m|tv|h|xh|xxh|xxxh)dpi)?$/.test(folder))
|
|
766
|
+
continue;
|
|
767
|
+
for (const name of ["splash_icon.png", "splash_logo.png"]) {
|
|
768
|
+
const path = join3(resources, folder, name);
|
|
769
|
+
if (existsSync(path) && statSync(path).isFile())
|
|
770
|
+
files.push({ folder, name, path });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (!files.length)
|
|
774
|
+
throw new Error(`Splash resources hold no drawable*/splash_icon.png or splash_logo.png: ${resources}`);
|
|
775
|
+
return files;
|
|
776
|
+
}
|
|
777
|
+
function androidLaunchThemes(splash) {
|
|
778
|
+
const icon = splash.some((file) => file.name === "splash_icon.png");
|
|
779
|
+
const logo = splash.some((file) => file.name === "splash_logo.png");
|
|
780
|
+
const base = logo ? ` <style name="Theme.CraftApp.Launch" parent="Theme.CraftApp">
|
|
781
|
+
<item name="android:windowBackground">@drawable/craft_launch_background</item>
|
|
782
|
+
</style>
|
|
783
|
+
` : ` <style name="Theme.CraftApp.Launch" parent="Theme.CraftApp" />
|
|
784
|
+
`;
|
|
785
|
+
const v31 = `<?xml version="1.0" encoding="utf-8"?>
|
|
786
|
+
<resources>
|
|
787
|
+
<style name="Theme.CraftApp.Launch" parent="Theme.CraftApp">
|
|
788
|
+
<item name="android:windowSplashScreenBackground">@color/background</item>${icon ? `
|
|
789
|
+
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>` : ""}
|
|
790
|
+
</style>
|
|
791
|
+
</resources>
|
|
792
|
+
`;
|
|
793
|
+
const background = logo ? `<?xml version="1.0" encoding="utf-8"?>
|
|
794
|
+
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:opacity="opaque">
|
|
795
|
+
<item android:drawable="@color/background" />
|
|
796
|
+
<item>
|
|
797
|
+
<bitmap android:gravity="center" android:src="@drawable/splash_logo" />
|
|
798
|
+
</item>
|
|
799
|
+
</layer-list>
|
|
800
|
+
` : undefined;
|
|
801
|
+
return { base, v31, background };
|
|
802
|
+
}
|
|
760
803
|
function containsPath(parent, candidate) {
|
|
761
804
|
const relativePath = relative(parent, candidate);
|
|
762
805
|
return relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute2(relativePath);
|
|
@@ -964,6 +1007,7 @@ function writeAndroidConfig(output, config) {
|
|
|
964
1007
|
writeFileSync(join3(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
965
1008
|
const runtimeConfig = { ...config };
|
|
966
1009
|
delete runtimeConfig.appIconPath;
|
|
1010
|
+
delete runtimeConfig.splashResources;
|
|
967
1011
|
delete runtimeConfig.notificationIconPath;
|
|
968
1012
|
delete runtimeConfig.googleServicesFile;
|
|
969
1013
|
writeFileSync(join3(output, "app/src/main/assets/craft.config.json"), JSON.stringify(runtimeConfig, null, 2));
|
|
@@ -1266,6 +1310,7 @@ async function init(options) {
|
|
|
1266
1310
|
if (config.appIconPath && ![".gif", ".jpg", ".png", ".webp"].includes(appIconExtension ?? "")) {
|
|
1267
1311
|
throw new Error(`Unsupported Android app icon format: ${appIconExtension || "(none)"}`);
|
|
1268
1312
|
}
|
|
1313
|
+
const splash = config.splashResources ? androidSplashFiles(config.splashResources) : [];
|
|
1269
1314
|
if (config.notificationIconPath)
|
|
1270
1315
|
requireRegularFile(config.notificationIconPath, "Notification icon");
|
|
1271
1316
|
const notificationIconExtension = config.notificationIconPath ? extname(config.notificationIconPath).toLowerCase() : undefined;
|
|
@@ -1475,6 +1520,17 @@ zipStorePath=wrapper/dists
|
|
|
1475
1520
|
</vector>
|
|
1476
1521
|
`);
|
|
1477
1522
|
}
|
|
1523
|
+
const resDir = join3(output, "app/src/main/res");
|
|
1524
|
+
for (const folder of existsSync(resDir) ? readdirSync(resDir) : []) {
|
|
1525
|
+
if (folder.startsWith("drawable"))
|
|
1526
|
+
for (const name of ["splash_icon.png", "splash_logo.png"])
|
|
1527
|
+
rmSync2(join3(resDir, folder, name), { force: true });
|
|
1528
|
+
}
|
|
1529
|
+
for (const file of splash) {
|
|
1530
|
+
mkdirSync2(join3(resDir, file.folder), { recursive: true });
|
|
1531
|
+
cpSync(file.path, join3(resDir, file.folder, file.name));
|
|
1532
|
+
}
|
|
1533
|
+
const launch = androidLaunchThemes(splash);
|
|
1478
1534
|
const themesXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
1479
1535
|
<resources>
|
|
1480
1536
|
<style name="Theme.CraftApp" parent="Theme.Material3.DayNight.NoActionBar">
|
|
@@ -1482,9 +1538,15 @@ zipStorePath=wrapper/dists
|
|
|
1482
1538
|
<item name="android:navigationBarColor">@color/primary</item>
|
|
1483
1539
|
<item name="android:windowBackground">@color/background</item>
|
|
1484
1540
|
</style>
|
|
1485
|
-
</resources>
|
|
1541
|
+
${launch.base}</resources>
|
|
1486
1542
|
`;
|
|
1487
1543
|
writeFileSync(join3(output, "app/src/main/res/values/themes.xml"), themesXml);
|
|
1544
|
+
mkdirSync2(join3(output, "app/src/main/res/values-v31"), { recursive: true });
|
|
1545
|
+
writeFileSync(join3(output, "app/src/main/res/values-v31/themes.xml"), launch.v31);
|
|
1546
|
+
if (launch.background)
|
|
1547
|
+
writeFileSync(join3(output, "app/src/main/res/drawable/craft_launch_background.xml"), launch.background);
|
|
1548
|
+
else
|
|
1549
|
+
rmSync2(join3(output, "app/src/main/res/drawable/craft_launch_background.xml"), { force: true });
|
|
1488
1550
|
const activityMainXml = config.renderer === "native" ? `<?xml version="1.0" encoding="utf-8"?>
|
|
1489
1551
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
1490
1552
|
android:id="@+id/native_root"
|
|
@@ -1708,6 +1770,8 @@ async function run(options) {
|
|
|
1708
1770
|
export {
|
|
1709
1771
|
ANDROID_MIN_SDK,
|
|
1710
1772
|
HEALTH_CONNECT_PERMISSIONS,
|
|
1773
|
+
androidLaunchThemes,
|
|
1774
|
+
androidSplashFiles,
|
|
1711
1775
|
build,
|
|
1712
1776
|
compressNativeSources,
|
|
1713
1777
|
createAndroidEmulator,
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
android:windowSoftInputMode="adjustResize"
|
|
46
46
|
android:supportsPictureInPicture="true"
|
|
47
47
|
android:resizeableActivity="true"
|
|
48
|
-
android:launchMode="singleTask"
|
|
48
|
+
android:launchMode="singleTask"
|
|
49
|
+
android:theme="@style/Theme.CraftApp.Launch">
|
|
49
50
|
<intent-filter>
|
|
50
51
|
<action android:name="android.intent.action.MAIN" />
|
|
51
52
|
<category android:name="android.intent.category.LAUNCHER" />
|
|
@@ -76,6 +76,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
76
76
|
|
|
77
77
|
@SuppressLint("SetJavaScriptEnabled")
|
|
78
78
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
79
|
+
// The activity starts in the launch theme (the splash); its own theme
|
|
80
|
+
// takes over before the first frame, on the same background colour.
|
|
81
|
+
setTheme(R.style.Theme_CraftApp)
|
|
79
82
|
applyConfiguredAppearance()
|
|
80
83
|
super.onCreate(savedInstanceState)
|
|
81
84
|
|
|
@@ -4738,6 +4738,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4738
4738
|
}
|
|
4739
4739
|
|
|
4740
4740
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
4741
|
+
// The activity starts in the launch theme (the splash); its own theme
|
|
4742
|
+
// takes over before the first frame, on the same background colour.
|
|
4743
|
+
setTheme(R.style.Theme_CraftApp)
|
|
4741
4744
|
applyConfiguredAppearance()
|
|
4742
4745
|
super.onCreate(savedInstanceState)
|
|
4743
4746
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
package/dist/cli.js
CHANGED
|
@@ -3228,7 +3228,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3228
3228
|
`);
|
|
3229
3229
|
}
|
|
3230
3230
|
// package.json
|
|
3231
|
-
var version = "0.0.
|
|
3231
|
+
var version = "0.0.446";
|
|
3232
3232
|
|
|
3233
3233
|
// bin/cli.ts
|
|
3234
3234
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
|
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
376
376
|
`);
|
|
377
377
|
}
|
|
378
378
|
// package.json
|
|
379
|
-
var version = "0.0.
|
|
379
|
+
var version = "0.0.446";
|
|
380
380
|
|
|
381
381
|
// src/sdk-version.ts
|
|
382
382
|
var SDK_VERSION = version;
|
|
@@ -11156,7 +11156,12 @@ class AutoUpdater extends import_events3.EventEmitter {
|
|
|
11156
11156
|
config;
|
|
11157
11157
|
updateInfo = null;
|
|
11158
11158
|
downloadPath = null;
|
|
11159
|
+
verifiedDownloadPath = null;
|
|
11159
11160
|
checkTimer = null;
|
|
11161
|
+
autoCheckGeneration = 0;
|
|
11162
|
+
autoCheckRunning = false;
|
|
11163
|
+
checking = null;
|
|
11164
|
+
downloading = null;
|
|
11160
11165
|
cachedEtag = null;
|
|
11161
11166
|
cachedLastModified = null;
|
|
11162
11167
|
cachedManifest = null;
|
|
@@ -11193,21 +11198,63 @@ class AutoUpdater extends import_events3.EventEmitter {
|
|
|
11193
11198
|
...config
|
|
11194
11199
|
};
|
|
11195
11200
|
}
|
|
11196
|
-
startAutoCheck() {
|
|
11197
|
-
this.
|
|
11198
|
-
|
|
11199
|
-
|
|
11200
|
-
|
|
11201
|
-
|
|
11202
|
-
|
|
11201
|
+
startAutoCheck(options = {}) {
|
|
11202
|
+
if (this.autoCheckRunning)
|
|
11203
|
+
return;
|
|
11204
|
+
const initialDelay = options.initialDelay ?? 0;
|
|
11205
|
+
const policyInterval = options.policyInterval ?? 60000;
|
|
11206
|
+
const interval = this.config.checkInterval ?? 3600000;
|
|
11207
|
+
if (!Number.isFinite(initialDelay) || initialDelay < 0 || !Number.isFinite(policyInterval) || policyInterval <= 0 || !Number.isFinite(interval) || interval < 0)
|
|
11208
|
+
throw new Error("Updater: automatic check intervals must be finite and non-negative, with a positive policyInterval");
|
|
11209
|
+
this.autoCheckRunning = true;
|
|
11210
|
+
const generation = ++this.autoCheckGeneration;
|
|
11211
|
+
const active = () => this.autoCheckRunning && generation === this.autoCheckGeneration;
|
|
11212
|
+
const enabled = async () => active() && (await options.enabled?.() ?? true) && active();
|
|
11213
|
+
let nextCheck = Date.now() + initialDelay;
|
|
11214
|
+
const schedule = (delay) => {
|
|
11215
|
+
if (!active())
|
|
11216
|
+
return;
|
|
11217
|
+
this.checkTimer = setTimeout(() => {
|
|
11218
|
+
tick();
|
|
11219
|
+
}, delay);
|
|
11220
|
+
this.checkTimer.unref?.();
|
|
11221
|
+
};
|
|
11222
|
+
const tick = async () => {
|
|
11223
|
+
try {
|
|
11224
|
+
if (await enabled() && Date.now() >= nextCheck && !(this.verifiedDownloadPath && import_fs4.existsSync(this.verifiedDownloadPath)) && !this.checking && !this.downloading) {
|
|
11225
|
+
nextCheck = interval === 0 ? Infinity : Date.now() + interval;
|
|
11226
|
+
const update = await this.checkForUpdates({ autoDownload: false });
|
|
11227
|
+
if (update && !this.lastError && (options.autoDownload ?? this.config.autoDownload) && await enabled())
|
|
11228
|
+
await this.downloadUpdate();
|
|
11229
|
+
}
|
|
11230
|
+
} catch (error) {
|
|
11231
|
+
if (active())
|
|
11232
|
+
this.emitError(error);
|
|
11233
|
+
} finally {
|
|
11234
|
+
if (active() && (interval !== 0 || nextCheck !== Infinity))
|
|
11235
|
+
schedule(Math.min(policyInterval, nextCheck > Date.now() ? nextCheck - Date.now() : policyInterval));
|
|
11236
|
+
}
|
|
11237
|
+
};
|
|
11238
|
+
schedule(initialDelay);
|
|
11203
11239
|
}
|
|
11204
11240
|
stopAutoCheck() {
|
|
11205
|
-
|
|
11206
|
-
|
|
11207
|
-
|
|
11241
|
+
this.autoCheckRunning = false;
|
|
11242
|
+
this.autoCheckGeneration++;
|
|
11243
|
+
if (this.checkTimer)
|
|
11244
|
+
clearTimeout(this.checkTimer);
|
|
11245
|
+
this.checkTimer = null;
|
|
11246
|
+
}
|
|
11247
|
+
checkForUpdates(options = {}) {
|
|
11248
|
+
if (!this.checking && this.downloading)
|
|
11249
|
+
return this.downloading.then(() => this.checkForUpdates(options));
|
|
11250
|
+
if (!this.checking) {
|
|
11251
|
+
this.checking = this.performCheck(options.autoDownload ?? this.config.autoDownload ?? true).finally(() => {
|
|
11252
|
+
this.checking = null;
|
|
11253
|
+
});
|
|
11208
11254
|
}
|
|
11255
|
+
return this.checking;
|
|
11209
11256
|
}
|
|
11210
|
-
async
|
|
11257
|
+
async performCheck(autoDownload) {
|
|
11211
11258
|
this.emit("checking-for-update");
|
|
11212
11259
|
this.lastError = null;
|
|
11213
11260
|
try {
|
|
@@ -11223,7 +11270,7 @@ class AutoUpdater extends import_events3.EventEmitter {
|
|
|
11223
11270
|
if (this.isNewerVersion(this.cachedManifest.version, this.config.currentVersion)) {
|
|
11224
11271
|
this.updateInfo = this.cachedManifest;
|
|
11225
11272
|
this.emit("update-available", this.cachedManifest);
|
|
11226
|
-
if (
|
|
11273
|
+
if (autoDownload) {
|
|
11227
11274
|
await this.downloadUpdate();
|
|
11228
11275
|
}
|
|
11229
11276
|
return this.cachedManifest;
|
|
@@ -11243,9 +11290,13 @@ class AutoUpdater extends import_events3.EventEmitter {
|
|
|
11243
11290
|
if (updateInfo.minVersion && this.isNewerVersion(updateInfo.minVersion, this.config.currentVersion)) {
|
|
11244
11291
|
console.log(`Update requires minimum version ${updateInfo.minVersion}`);
|
|
11245
11292
|
}
|
|
11293
|
+
if (this.updateInfo?.version !== updateInfo.version) {
|
|
11294
|
+
this.downloadPath = null;
|
|
11295
|
+
this.verifiedDownloadPath = null;
|
|
11296
|
+
}
|
|
11246
11297
|
this.updateInfo = updateInfo;
|
|
11247
11298
|
this.emit("update-available", updateInfo);
|
|
11248
|
-
if (
|
|
11299
|
+
if (autoDownload) {
|
|
11249
11300
|
await this.downloadUpdate();
|
|
11250
11301
|
}
|
|
11251
11302
|
return updateInfo;
|
|
@@ -11258,7 +11309,19 @@ class AutoUpdater extends import_events3.EventEmitter {
|
|
|
11258
11309
|
return null;
|
|
11259
11310
|
}
|
|
11260
11311
|
}
|
|
11261
|
-
|
|
11312
|
+
downloadUpdate() {
|
|
11313
|
+
if (this.downloading)
|
|
11314
|
+
return this.downloading;
|
|
11315
|
+
if (this.verifiedDownloadPath && import_fs4.existsSync(this.verifiedDownloadPath))
|
|
11316
|
+
return Promise.resolve(this.verifiedDownloadPath);
|
|
11317
|
+
if (!this.downloading) {
|
|
11318
|
+
this.downloading = this.performDownload().finally(() => {
|
|
11319
|
+
this.downloading = null;
|
|
11320
|
+
});
|
|
11321
|
+
}
|
|
11322
|
+
return this.downloading;
|
|
11323
|
+
}
|
|
11324
|
+
async performDownload() {
|
|
11262
11325
|
if (!this.updateInfo) {
|
|
11263
11326
|
throw new Error("No update available");
|
|
11264
11327
|
}
|
|
@@ -11366,6 +11429,7 @@ class AutoUpdater extends import_events3.EventEmitter {
|
|
|
11366
11429
|
throw new Error("Download verification failed: invalid signature");
|
|
11367
11430
|
}
|
|
11368
11431
|
}
|
|
11432
|
+
this.verifiedDownloadPath = this.downloadPath;
|
|
11369
11433
|
this.emit("update-downloaded", {
|
|
11370
11434
|
path: this.downloadPath,
|
|
11371
11435
|
version: this.updateInfo.version,
|
package/dist/index.d.cts
CHANGED
|
@@ -36,7 +36,7 @@ export * from './styles/index.js';
|
|
|
36
36
|
export * from './optimizations/index.js';
|
|
37
37
|
export * as updater from './updater/index.js';
|
|
38
38
|
export { AutoUpdater, generateUpdateManifest } from './updater/index.js';
|
|
39
|
-
export type { UpdateInfo as UpdateManifest, UpdateProgress, UpdaterConfig, UpdaterEvent, PlatformUpdate, } from './updater/index.js';
|
|
39
|
+
export type { AutoCheckOptions, CheckForUpdatesOptions, UpdateInfo as UpdateManifest, UpdateProgress, UpdaterConfig, UpdaterEvent, PlatformUpdate, } from './updater/index.js';
|
|
40
40
|
export { canReplaceBundle, clearQuarantine, dittoBundle, extractBundle, extractBundleFromDmg, extractBundleFromZip, readBundleIdentity, swapBundle, verifyBundleTrust, } from './updater/index.js';
|
|
41
41
|
export type { BundleIdentity, BundleTrustFailure, BundleTrustPolicy, BundleTrustResult, StagedBundle, SwapResult, } from './updater/index.js';
|
|
42
42
|
export declare class CraftApp {
|
package/dist/index.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export * from './styles/index.js';
|
|
|
36
36
|
export * from './optimizations/index.js';
|
|
37
37
|
export * as updater from './updater/index.js';
|
|
38
38
|
export { AutoUpdater, generateUpdateManifest } from './updater/index.js';
|
|
39
|
-
export type { UpdateInfo as UpdateManifest, UpdateProgress, UpdaterConfig, UpdaterEvent, PlatformUpdate, } from './updater/index.js';
|
|
39
|
+
export type { AutoCheckOptions, CheckForUpdatesOptions, UpdateInfo as UpdateManifest, UpdateProgress, UpdaterConfig, UpdaterEvent, PlatformUpdate, } from './updater/index.js';
|
|
40
40
|
export { canReplaceBundle, clearQuarantine, dittoBundle, extractBundle, extractBundleFromDmg, extractBundleFromZip, readBundleIdentity, swapBundle, verifyBundleTrust, } from './updater/index.js';
|
|
41
41
|
export type { BundleIdentity, BundleTrustFailure, BundleTrustPolicy, BundleTrustResult, StagedBundle, SwapResult, } from './updater/index.js';
|
|
42
42
|
export declare class CraftApp {
|
package/dist/index.js
CHANGED
|
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
117
117
|
`);
|
|
118
118
|
}
|
|
119
119
|
// package.json
|
|
120
|
-
var version = "0.0.
|
|
120
|
+
var version = "0.0.446";
|
|
121
121
|
|
|
122
122
|
// src/sdk-version.ts
|
|
123
123
|
var SDK_VERSION = version;
|
|
@@ -10912,7 +10912,12 @@ class AutoUpdater extends EventEmitter3 {
|
|
|
10912
10912
|
config;
|
|
10913
10913
|
updateInfo = null;
|
|
10914
10914
|
downloadPath = null;
|
|
10915
|
+
verifiedDownloadPath = null;
|
|
10915
10916
|
checkTimer = null;
|
|
10917
|
+
autoCheckGeneration = 0;
|
|
10918
|
+
autoCheckRunning = false;
|
|
10919
|
+
checking = null;
|
|
10920
|
+
downloading = null;
|
|
10916
10921
|
cachedEtag = null;
|
|
10917
10922
|
cachedLastModified = null;
|
|
10918
10923
|
cachedManifest = null;
|
|
@@ -10949,21 +10954,63 @@ class AutoUpdater extends EventEmitter3 {
|
|
|
10949
10954
|
...config
|
|
10950
10955
|
};
|
|
10951
10956
|
}
|
|
10952
|
-
startAutoCheck() {
|
|
10953
|
-
this.
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
|
|
10957
|
-
|
|
10958
|
-
|
|
10957
|
+
startAutoCheck(options = {}) {
|
|
10958
|
+
if (this.autoCheckRunning)
|
|
10959
|
+
return;
|
|
10960
|
+
const initialDelay = options.initialDelay ?? 0;
|
|
10961
|
+
const policyInterval = options.policyInterval ?? 60000;
|
|
10962
|
+
const interval = this.config.checkInterval ?? 3600000;
|
|
10963
|
+
if (!Number.isFinite(initialDelay) || initialDelay < 0 || !Number.isFinite(policyInterval) || policyInterval <= 0 || !Number.isFinite(interval) || interval < 0)
|
|
10964
|
+
throw new Error("Updater: automatic check intervals must be finite and non-negative, with a positive policyInterval");
|
|
10965
|
+
this.autoCheckRunning = true;
|
|
10966
|
+
const generation = ++this.autoCheckGeneration;
|
|
10967
|
+
const active = () => this.autoCheckRunning && generation === this.autoCheckGeneration;
|
|
10968
|
+
const enabled = async () => active() && (await options.enabled?.() ?? true) && active();
|
|
10969
|
+
let nextCheck = Date.now() + initialDelay;
|
|
10970
|
+
const schedule = (delay) => {
|
|
10971
|
+
if (!active())
|
|
10972
|
+
return;
|
|
10973
|
+
this.checkTimer = setTimeout(() => {
|
|
10974
|
+
tick();
|
|
10975
|
+
}, delay);
|
|
10976
|
+
this.checkTimer.unref?.();
|
|
10977
|
+
};
|
|
10978
|
+
const tick = async () => {
|
|
10979
|
+
try {
|
|
10980
|
+
if (await enabled() && Date.now() >= nextCheck && !(this.verifiedDownloadPath && existsSync5(this.verifiedDownloadPath)) && !this.checking && !this.downloading) {
|
|
10981
|
+
nextCheck = interval === 0 ? Infinity : Date.now() + interval;
|
|
10982
|
+
const update = await this.checkForUpdates({ autoDownload: false });
|
|
10983
|
+
if (update && !this.lastError && (options.autoDownload ?? this.config.autoDownload) && await enabled())
|
|
10984
|
+
await this.downloadUpdate();
|
|
10985
|
+
}
|
|
10986
|
+
} catch (error) {
|
|
10987
|
+
if (active())
|
|
10988
|
+
this.emitError(error);
|
|
10989
|
+
} finally {
|
|
10990
|
+
if (active() && (interval !== 0 || nextCheck !== Infinity))
|
|
10991
|
+
schedule(Math.min(policyInterval, nextCheck > Date.now() ? nextCheck - Date.now() : policyInterval));
|
|
10992
|
+
}
|
|
10993
|
+
};
|
|
10994
|
+
schedule(initialDelay);
|
|
10959
10995
|
}
|
|
10960
10996
|
stopAutoCheck() {
|
|
10961
|
-
|
|
10962
|
-
|
|
10963
|
-
|
|
10997
|
+
this.autoCheckRunning = false;
|
|
10998
|
+
this.autoCheckGeneration++;
|
|
10999
|
+
if (this.checkTimer)
|
|
11000
|
+
clearTimeout(this.checkTimer);
|
|
11001
|
+
this.checkTimer = null;
|
|
11002
|
+
}
|
|
11003
|
+
checkForUpdates(options = {}) {
|
|
11004
|
+
if (!this.checking && this.downloading)
|
|
11005
|
+
return this.downloading.then(() => this.checkForUpdates(options));
|
|
11006
|
+
if (!this.checking) {
|
|
11007
|
+
this.checking = this.performCheck(options.autoDownload ?? this.config.autoDownload ?? true).finally(() => {
|
|
11008
|
+
this.checking = null;
|
|
11009
|
+
});
|
|
10964
11010
|
}
|
|
11011
|
+
return this.checking;
|
|
10965
11012
|
}
|
|
10966
|
-
async
|
|
11013
|
+
async performCheck(autoDownload) {
|
|
10967
11014
|
this.emit("checking-for-update");
|
|
10968
11015
|
this.lastError = null;
|
|
10969
11016
|
try {
|
|
@@ -10979,7 +11026,7 @@ class AutoUpdater extends EventEmitter3 {
|
|
|
10979
11026
|
if (this.isNewerVersion(this.cachedManifest.version, this.config.currentVersion)) {
|
|
10980
11027
|
this.updateInfo = this.cachedManifest;
|
|
10981
11028
|
this.emit("update-available", this.cachedManifest);
|
|
10982
|
-
if (
|
|
11029
|
+
if (autoDownload) {
|
|
10983
11030
|
await this.downloadUpdate();
|
|
10984
11031
|
}
|
|
10985
11032
|
return this.cachedManifest;
|
|
@@ -10999,9 +11046,13 @@ class AutoUpdater extends EventEmitter3 {
|
|
|
10999
11046
|
if (updateInfo.minVersion && this.isNewerVersion(updateInfo.minVersion, this.config.currentVersion)) {
|
|
11000
11047
|
console.log(`Update requires minimum version ${updateInfo.minVersion}`);
|
|
11001
11048
|
}
|
|
11049
|
+
if (this.updateInfo?.version !== updateInfo.version) {
|
|
11050
|
+
this.downloadPath = null;
|
|
11051
|
+
this.verifiedDownloadPath = null;
|
|
11052
|
+
}
|
|
11002
11053
|
this.updateInfo = updateInfo;
|
|
11003
11054
|
this.emit("update-available", updateInfo);
|
|
11004
|
-
if (
|
|
11055
|
+
if (autoDownload) {
|
|
11005
11056
|
await this.downloadUpdate();
|
|
11006
11057
|
}
|
|
11007
11058
|
return updateInfo;
|
|
@@ -11014,7 +11065,19 @@ class AutoUpdater extends EventEmitter3 {
|
|
|
11014
11065
|
return null;
|
|
11015
11066
|
}
|
|
11016
11067
|
}
|
|
11017
|
-
|
|
11068
|
+
downloadUpdate() {
|
|
11069
|
+
if (this.downloading)
|
|
11070
|
+
return this.downloading;
|
|
11071
|
+
if (this.verifiedDownloadPath && existsSync5(this.verifiedDownloadPath))
|
|
11072
|
+
return Promise.resolve(this.verifiedDownloadPath);
|
|
11073
|
+
if (!this.downloading) {
|
|
11074
|
+
this.downloading = this.performDownload().finally(() => {
|
|
11075
|
+
this.downloading = null;
|
|
11076
|
+
});
|
|
11077
|
+
}
|
|
11078
|
+
return this.downloading;
|
|
11079
|
+
}
|
|
11080
|
+
async performDownload() {
|
|
11018
11081
|
if (!this.updateInfo) {
|
|
11019
11082
|
throw new Error("No update available");
|
|
11020
11083
|
}
|
|
@@ -11122,6 +11185,7 @@ class AutoUpdater extends EventEmitter3 {
|
|
|
11122
11185
|
throw new Error("Download verification failed: invalid signature");
|
|
11123
11186
|
}
|
|
11124
11187
|
}
|
|
11188
|
+
this.verifiedDownloadPath = this.downloadPath;
|
|
11125
11189
|
this.emit("update-downloaded", {
|
|
11126
11190
|
path: this.downloadPath,
|
|
11127
11191
|
version: this.updateInfo.version,
|
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -208,7 +208,9 @@ export interface CraftConfig {
|
|
|
208
208
|
* screen until the page says it is ready (`splash.hide()`), so the app goes
|
|
209
209
|
* from launch to content without a blank web view in between. PNG, PDF or
|
|
210
210
|
* SVG, drawn at its own size in points: give an SVG the width and height it
|
|
211
|
-
* should have on screen.
|
|
211
|
+
* should have on screen. A PNG with `@2x` and `@3x` files beside it
|
|
212
|
+
* (`LaunchLogo.png`, `LaunchLogo@2x.png`, `LaunchLogo@3x.png`, as ts-images'
|
|
213
|
+
* `generateSplashScreens` writes them) is sharp on every screen.
|
|
212
214
|
*/
|
|
213
215
|
splashImagePath?: string;
|
|
214
216
|
/** The splash logo in Dark Mode, on `backgroundColorDark`. The light one if unset. */
|
|
@@ -532,6 +534,16 @@ export declare function renderPrivacyManifest(config: CraftConfig): string;
|
|
|
532
534
|
export declare function pngHasAlpha(bytes: Uint8Array): boolean;
|
|
533
535
|
/** The asset the launch screen and the splash both draw, so one hands over to the other unseen. */
|
|
534
536
|
export declare const LAUNCH_LOGO = "LaunchLogo";
|
|
537
|
+
/**
|
|
538
|
+
* A bitmap splash's `@2x` and `@3x` files beside it (`LaunchLogo.png`,
|
|
539
|
+
* `LaunchLogo@2x.png`, `LaunchLogo@3x.png`, as ts-images'
|
|
540
|
+
* `generateSplashScreens` writes them), by scale. A bitmap without them is
|
|
541
|
+
* one image, drawn at its pixel size in points.
|
|
542
|
+
*/
|
|
543
|
+
export declare function splashScales(source: string): Array<{
|
|
544
|
+
scale: 1 | 2 | 3;
|
|
545
|
+
path: string;
|
|
546
|
+
}>;
|
|
535
547
|
/** The launch screen: the background colour, and the splash logo on it when there is one. */
|
|
536
548
|
export declare function renderLaunchScreen(config: Pick<CraftConfig, 'splashImagePath'>): string;
|
|
537
549
|
/** Near-black or near-white, whichever reads on `background` (WCAG relative luminance). */
|
package/dist/ios/src/index.js
CHANGED
|
@@ -1107,6 +1107,14 @@ function renderAssetCatalog(output, config) {
|
|
|
1107
1107
|
renderLaunchLogo(catalog, config);
|
|
1108
1108
|
}
|
|
1109
1109
|
var LAUNCH_LOGO = "LaunchLogo";
|
|
1110
|
+
function splashScales(source) {
|
|
1111
|
+
const extension = extname(source);
|
|
1112
|
+
if (!/^\.(?:png|jpe?g)$/i.test(extension))
|
|
1113
|
+
return [{ scale: 1, path: source }];
|
|
1114
|
+
const stem = source.slice(0, -extension.length);
|
|
1115
|
+
const scales = [2, 3].map((scale) => ({ scale, path: `${stem}@${scale}x${extension}` })).filter((item) => existsSync2(item.path));
|
|
1116
|
+
return scales.length ? [{ scale: 1, path: source }, ...scales] : [{ scale: 1, path: source }];
|
|
1117
|
+
}
|
|
1110
1118
|
function renderLaunchLogo(catalog, config) {
|
|
1111
1119
|
const imageset = join4(catalog, `${LAUNCH_LOGO}.imageset`);
|
|
1112
1120
|
rmSync3(imageset, { recursive: true, force: true });
|
|
@@ -1123,13 +1131,18 @@ function renderLaunchLogo(catalog, config) {
|
|
|
1123
1131
|
const extension = source.split(".").pop().toLowerCase();
|
|
1124
1132
|
if (extension === "svg" || extension === "pdf")
|
|
1125
1133
|
vector = true;
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
filename
|
|
1131
|
-
|
|
1132
|
-
|
|
1134
|
+
const files = splashScales(source);
|
|
1135
|
+
const scaled = files.length > 1;
|
|
1136
|
+
for (const { scale, path } of files) {
|
|
1137
|
+
const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}${scale === 1 ? "" : `@${scale}x`}.${extension}`;
|
|
1138
|
+
cpSync(path, join4(imageset, filename));
|
|
1139
|
+
images.push({
|
|
1140
|
+
...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
|
|
1141
|
+
filename,
|
|
1142
|
+
idiom: "universal",
|
|
1143
|
+
...scaled ? { scale: `${scale}x` } : {}
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1133
1146
|
}
|
|
1134
1147
|
writeFileSync3(join4(imageset, "Contents.json"), `${JSON.stringify({
|
|
1135
1148
|
images,
|
|
@@ -1841,6 +1854,7 @@ export {
|
|
|
1841
1854
|
runPostGenerateHook,
|
|
1842
1855
|
serializeSimulatorSteps,
|
|
1843
1856
|
showSimulator,
|
|
1857
|
+
splashScales,
|
|
1844
1858
|
syncNativeFonts,
|
|
1845
1859
|
syncWebAssets,
|
|
1846
1860
|
usesNativeBundle
|
|
@@ -5524,6 +5524,22 @@ final class CraftNativeSolidSwitch: UIControl {
|
|
|
5524
5524
|
private let naturalSize = UISwitch().intrinsicContentSize
|
|
5525
5525
|
private var touchOrigin: CGPoint?
|
|
5526
5526
|
private var dragPosition: CGFloat?
|
|
5527
|
+
/// Where the thumb is *drawn*, which is where it rests except while a
|
|
5528
|
+
/// change travels there. `thumbRect` keeps reporting the resting position,
|
|
5529
|
+
/// so hit-testing, the drag maths and tests all read one instant,
|
|
5530
|
+
/// authoritative value and only the drawing lags behind it.
|
|
5531
|
+
private var drawnPosition: CGFloat = 0
|
|
5532
|
+
private var travel: CADisplayLink?
|
|
5533
|
+
private var travelFrom: CGFloat = 0
|
|
5534
|
+
private var travelStart: CFTimeInterval = 0
|
|
5535
|
+
/// Drawing can settle independently of immediate value changes and taps.
|
|
5536
|
+
private static let travelDuration: CFTimeInterval = 0.38
|
|
5537
|
+
/// Thumb stretch while held, from 0 (released) to 1 (pressed).
|
|
5538
|
+
private var pressAmount: CGFloat = 0
|
|
5539
|
+
private var pressTarget: CGFloat = 0
|
|
5540
|
+
private var pressFrom: CGFloat = 0
|
|
5541
|
+
private var pressStart: CFTimeInterval = 0
|
|
5542
|
+
private static let pressDuration: CFTimeInterval = 0.14
|
|
5527
5543
|
override var intrinsicContentSize: CGSize { naturalSize }
|
|
5528
5544
|
override var isEnabled: Bool {
|
|
5529
5545
|
didSet {
|
|
@@ -5533,6 +5549,14 @@ final class CraftNativeSolidSwitch: UIControl {
|
|
|
5533
5549
|
}
|
|
5534
5550
|
}
|
|
5535
5551
|
override var canBecomeFirstResponder: Bool { isEnabled }
|
|
5552
|
+
override var isHidden: Bool {
|
|
5553
|
+
didSet { if isHidden { settleMotion(cancelTracking: true) } }
|
|
5554
|
+
}
|
|
5555
|
+
override var semanticContentAttribute: UISemanticContentAttribute {
|
|
5556
|
+
didSet {
|
|
5557
|
+
if oldValue != semanticContentAttribute { settleMotion(cancelTracking: true) }
|
|
5558
|
+
}
|
|
5559
|
+
}
|
|
5536
5560
|
|
|
5537
5561
|
override init(frame: CGRect) {
|
|
5538
5562
|
super.init(frame: frame)
|
|
@@ -5544,11 +5568,111 @@ final class CraftNativeSolidSwitch: UIControl {
|
|
|
5544
5568
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
5545
5569
|
|
|
5546
5570
|
func setOn(_ value: Bool) {
|
|
5571
|
+
let previous = visualPosition
|
|
5572
|
+
let changed = isOn != value
|
|
5547
5573
|
isOn = value
|
|
5548
5574
|
refreshAccessibility()
|
|
5575
|
+
guard changed else { setNeedsDisplay(); return }
|
|
5576
|
+
drawnPosition = previous
|
|
5577
|
+
moveThumb()
|
|
5578
|
+
}
|
|
5579
|
+
|
|
5580
|
+
private var canAnimate: Bool {
|
|
5581
|
+
guard isEnabled, !UIAccessibility.isReduceMotionEnabled, let window,
|
|
5582
|
+
convert(bounds, to: window).intersects(window.bounds) else { return false }
|
|
5583
|
+
var ancestor: UIView? = self
|
|
5584
|
+
while let view = ancestor {
|
|
5585
|
+
if view.isHidden || view.alpha <= 0 { return false }
|
|
5586
|
+
ancestor = view.superview
|
|
5587
|
+
}
|
|
5588
|
+
return true
|
|
5589
|
+
}
|
|
5590
|
+
|
|
5591
|
+
private func settleMotion(cancelTracking: Bool = false) {
|
|
5592
|
+
travel?.invalidate()
|
|
5593
|
+
travel = nil
|
|
5594
|
+
drawnPosition = restingPosition
|
|
5595
|
+
pressAmount = 0
|
|
5596
|
+
pressTarget = 0
|
|
5597
|
+
if cancelTracking {
|
|
5598
|
+
touchOrigin = nil
|
|
5599
|
+
dragPosition = nil
|
|
5600
|
+
}
|
|
5601
|
+
setNeedsDisplay()
|
|
5602
|
+
}
|
|
5603
|
+
|
|
5604
|
+
/// Only drawing travels; value, accessibility and hit geometry change at once.
|
|
5605
|
+
private func moveThumb() {
|
|
5606
|
+
travel?.invalidate()
|
|
5607
|
+
travel = nil
|
|
5608
|
+
let target = restingPosition
|
|
5609
|
+
travelFrom = dragPosition ?? drawnPosition
|
|
5610
|
+
guard canAnimate else { settleMotion(); return }
|
|
5611
|
+
travelStart = CACurrentMediaTime()
|
|
5612
|
+
guard travelFrom != target || pressAmount != pressTarget else {
|
|
5613
|
+
drawnPosition = target
|
|
5614
|
+
setNeedsDisplay()
|
|
5615
|
+
return
|
|
5616
|
+
}
|
|
5617
|
+
startLink()
|
|
5618
|
+
}
|
|
5619
|
+
|
|
5620
|
+
/// Stretch the thumb toward where it is going, or let it relax back.
|
|
5621
|
+
private func press(_ amount: CGFloat) {
|
|
5622
|
+
guard pressTarget != amount else { return }
|
|
5623
|
+
pressFrom = pressAmount
|
|
5624
|
+
pressTarget = amount
|
|
5625
|
+
pressStart = CACurrentMediaTime()
|
|
5626
|
+
guard canAnimate else { settleMotion(); return }
|
|
5627
|
+
if travel == nil {
|
|
5628
|
+
drawnPosition = restingPosition
|
|
5629
|
+
travelFrom = restingPosition
|
|
5630
|
+
travelStart = CACurrentMediaTime()
|
|
5631
|
+
}
|
|
5632
|
+
startLink()
|
|
5633
|
+
}
|
|
5634
|
+
|
|
5635
|
+
private func startLink() {
|
|
5636
|
+
guard travel == nil else { return }
|
|
5637
|
+
let link = CADisplayLink(target: self, selector: #selector(stepThumb))
|
|
5638
|
+
link.add(to: .main, forMode: .common)
|
|
5639
|
+
travel = link
|
|
5640
|
+
}
|
|
5641
|
+
|
|
5642
|
+
@objc private func stepThumb() {
|
|
5643
|
+
guard canAnimate else { settleMotion(); return }
|
|
5644
|
+
let now = CACurrentMediaTime()
|
|
5645
|
+
let target = restingPosition
|
|
5646
|
+
let position = min(1, max(0, (now - travelStart) / Self.travelDuration))
|
|
5647
|
+
drawnPosition = travelFrom + (target - travelFrom) * CGFloat(Self.spring(position))
|
|
5648
|
+
let pressed = min(1, max(0, (now - pressStart) / Self.pressDuration))
|
|
5649
|
+
pressAmount = pressFrom + (pressTarget - pressFrom) * CGFloat(pressed * pressed * (3 - 2 * pressed))
|
|
5549
5650
|
setNeedsDisplay()
|
|
5651
|
+
guard position >= 1, pressed >= 1 else { return }
|
|
5652
|
+
drawnPosition = target
|
|
5653
|
+
pressAmount = pressTarget
|
|
5654
|
+
travel?.invalidate()
|
|
5655
|
+
travel = nil
|
|
5550
5656
|
}
|
|
5551
5657
|
|
|
5658
|
+
/// A small spring overshoot, applied only to opaque drawing geometry.
|
|
5659
|
+
private static func spring(_ fraction: Double) -> Double {
|
|
5660
|
+
guard fraction < 1 else { return 1 }
|
|
5661
|
+
let damping = 0.68, frequency = 15.0
|
|
5662
|
+
let damped = frequency * (1 - damping * damping).squareRoot()
|
|
5663
|
+
let decay = exp(-damping * frequency * fraction)
|
|
5664
|
+
return 1 - decay * (cos(damped * fraction) + (damping * frequency / damped) * sin(damped * fraction))
|
|
5665
|
+
}
|
|
5666
|
+
|
|
5667
|
+
/// A display link retains this control, so a switch that leaves the screen
|
|
5668
|
+
/// mid-travel would hold itself alive and keep drawing frames nobody sees.
|
|
5669
|
+
override func willMove(toWindow newWindow: UIWindow?) {
|
|
5670
|
+
super.willMove(toWindow: newWindow)
|
|
5671
|
+
guard newWindow == nil else { return }
|
|
5672
|
+
settleMotion(cancelTracking: true)
|
|
5673
|
+
}
|
|
5674
|
+
deinit { travel?.invalidate() }
|
|
5675
|
+
|
|
5552
5676
|
func refreshAccessibility() {
|
|
5553
5677
|
accessibilityTraits = .button
|
|
5554
5678
|
if #available(iOS 17.0, *) { accessibilityTraits.insert(.toggleButton) }
|
|
@@ -5561,21 +5685,99 @@ final class CraftNativeSolidSwitch: UIControl {
|
|
|
5561
5685
|
let size = CGSize(width: min(bounds.width, naturalSize.width), height: min(bounds.height, naturalSize.height))
|
|
5562
5686
|
return CGRect(x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2, width: size.width, height: size.height)
|
|
5563
5687
|
}
|
|
5564
|
-
var
|
|
5688
|
+
private var restingPosition: CGFloat {
|
|
5689
|
+
(isOn == (effectiveUserInterfaceLayoutDirection != .rightToLeft)) ? 1 : 0
|
|
5690
|
+
}
|
|
5691
|
+
private var visualPosition: CGFloat {
|
|
5692
|
+
dragPosition ?? (travel == nil ? restingPosition : drawnPosition)
|
|
5693
|
+
}
|
|
5694
|
+
/// Where the thumb belongs now. A finger on it wins, then the state.
|
|
5695
|
+
var thumbRect: CGRect { thumbRect(at: dragPosition ?? restingPosition) }
|
|
5696
|
+
/// Where this frame draws it: the same, except part-way through a travel,
|
|
5697
|
+
/// and stretched toward its destination while a finger holds it down.
|
|
5698
|
+
private var drawnThumbRect: CGRect {
|
|
5699
|
+
let position = visualPosition
|
|
5700
|
+
let rect = thumbRect(at: position)
|
|
5701
|
+
guard pressAmount > 0 else { return rect }
|
|
5702
|
+
let track = trackRect.insetBy(dx: 2, dy: 2)
|
|
5703
|
+
let stretch = rect.width * 0.18 * pressAmount
|
|
5704
|
+
// It grows toward the end it is heading for, so the edge nearest the
|
|
5705
|
+
// finger stays put and the far edge reaches.
|
|
5706
|
+
let x = position < 0.5 ? rect.minX : rect.minX - stretch
|
|
5707
|
+
return CGRect(x: min(max(x, track.minX), track.maxX - rect.width - stretch),
|
|
5708
|
+
y: rect.minY, width: rect.width + stretch, height: rect.height)
|
|
5709
|
+
}
|
|
5710
|
+
private func thumbRect(at position: CGFloat) -> CGRect {
|
|
5565
5711
|
let track = trackRect.insetBy(dx: 2, dy: 2)
|
|
5566
|
-
let onRight = effectiveUserInterfaceLayoutDirection != .rightToLeft
|
|
5567
|
-
let position = dragPosition ?? ((isOn == onRight) ? 1 : 0)
|
|
5568
5712
|
return CGRect(x: track.minX + (track.width - track.height) * position, y: track.minY, width: track.height, height: track.height)
|
|
5569
5713
|
}
|
|
5570
5714
|
override func draw(_ rect: CGRect) {
|
|
5571
5715
|
let track = trackRect
|
|
5572
|
-
guard track.width > 4, track.height > 4 else { return }
|
|
5573
|
-
|
|
5574
|
-
|
|
5716
|
+
guard track.width > 4, track.height > 4, let context = UIGraphicsGetCurrentContext() else { return }
|
|
5717
|
+
let position = visualPosition
|
|
5718
|
+
// The track's colour arrives *with* the thumb. Filling it from `isOn`
|
|
5719
|
+
// turns the whole track the instant the value changes, so the colour
|
|
5720
|
+
// overtakes the thumb and the two read as two separate things moving.
|
|
5721
|
+
let dim = isEnabled ? (offColor ?? .systemGray4) : UIColor.systemGray4
|
|
5722
|
+
let lit = isEnabled ? (onColor ?? .systemGreen) : UIColor.systemGray4
|
|
5723
|
+
let onness = effectiveUserInterfaceLayoutDirection != .rightToLeft ? position : 1 - position
|
|
5724
|
+
Self.blend(dim, lit, onness).setFill()
|
|
5725
|
+
let trackPath = UIBezierPath(roundedRect: track, cornerRadius: track.height / 2)
|
|
5726
|
+
trackPath.fill()
|
|
5727
|
+
// The well the thumb sits in: shaded under its own top rim.
|
|
5728
|
+
context.saveGState()
|
|
5729
|
+
trackPath.addClip()
|
|
5730
|
+
if let shade = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
|
5731
|
+
colors: [UIColor.black.withAlphaComponent(0.10).cgColor, UIColor.clear.cgColor] as CFArray,
|
|
5732
|
+
locations: [0, 1]) {
|
|
5733
|
+
context.drawLinearGradient(shade, start: CGPoint(x: track.midX, y: track.minY),
|
|
5734
|
+
end: CGPoint(x: track.midX, y: track.minY + track.height * 0.55), options: [])
|
|
5735
|
+
}
|
|
5736
|
+
context.restoreGState()
|
|
5737
|
+
|
|
5738
|
+
let thumb = drawnThumbRect
|
|
5739
|
+
let thumbPath = UIBezierPath(roundedRect: thumb, cornerRadius: thumb.height / 2)
|
|
5740
|
+
// Draw the shadow in the same frame, without layer animations or fades.
|
|
5741
|
+
context.saveGState()
|
|
5742
|
+
context.setShadow(offset: CGSize(width: 0, height: 1.5), blur: 3,
|
|
5743
|
+
color: UIColor.black.withAlphaComponent(isEnabled ? 0.28 : 0.16).cgColor)
|
|
5575
5744
|
// The solid variant deliberately ignores thumb alpha. No transient
|
|
5576
5745
|
// glass, scaling, fade, implicit layer animation or delayed redraw.
|
|
5577
5746
|
(thumbColor ?? .white).withAlphaComponent(1).setFill()
|
|
5578
|
-
|
|
5747
|
+
thumbPath.fill()
|
|
5748
|
+
context.restoreGState()
|
|
5749
|
+
// Shading stays above the opaque fill and away from its centre.
|
|
5750
|
+
context.saveGState()
|
|
5751
|
+
thumbPath.addClip()
|
|
5752
|
+
if let gloss = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
|
5753
|
+
colors: [UIColor.white.withAlphaComponent(0.55).cgColor, UIColor.clear.cgColor] as CFArray,
|
|
5754
|
+
locations: [0, 1]) {
|
|
5755
|
+
context.drawLinearGradient(gloss, start: CGPoint(x: thumb.midX, y: thumb.minY),
|
|
5756
|
+
end: CGPoint(x: thumb.midX, y: thumb.minY + thumb.height * 0.40), options: [])
|
|
5757
|
+
}
|
|
5758
|
+
if let sheen = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
|
5759
|
+
colors: [UIColor.clear.cgColor, UIColor.black.withAlphaComponent(0.07).cgColor] as CFArray,
|
|
5760
|
+
locations: [0, 1]) {
|
|
5761
|
+
context.drawLinearGradient(sheen, start: CGPoint(x: thumb.midX, y: thumb.maxY - thumb.height * 0.20),
|
|
5762
|
+
end: CGPoint(x: thumb.midX, y: thumb.maxY), options: [])
|
|
5763
|
+
}
|
|
5764
|
+
context.restoreGState()
|
|
5765
|
+
// A hairline rim, so it still separates from a pale track.
|
|
5766
|
+
UIColor.black.withAlphaComponent(0.06).setStroke()
|
|
5767
|
+
thumbPath.lineWidth = 0.5
|
|
5768
|
+
thumbPath.stroke()
|
|
5769
|
+
}
|
|
5770
|
+
|
|
5771
|
+
/// Mix two colours, for a track that changes with the thumb rather than
|
|
5772
|
+
/// ahead of it.
|
|
5773
|
+
private static func blend(_ from: UIColor, _ to: UIColor, _ amount: CGFloat) -> UIColor {
|
|
5774
|
+
let mix = min(1, max(0, amount))
|
|
5775
|
+
var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0
|
|
5776
|
+
var tr: CGFloat = 0, tg: CGFloat = 0, tb: CGFloat = 0, ta: CGFloat = 0
|
|
5777
|
+
guard from.getRed(&fr, green: &fg, blue: &fb, alpha: &fa),
|
|
5778
|
+
to.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) else { return mix < 0.5 ? from : to }
|
|
5779
|
+
return UIColor(red: fr + (tr - fr) * mix, green: fg + (tg - fg) * mix,
|
|
5780
|
+
blue: fb + (tb - fb) * mix, alpha: fa + (ta - fa) * mix)
|
|
5579
5781
|
}
|
|
5580
5782
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
5581
5783
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
@@ -5585,6 +5787,7 @@ final class CraftNativeSolidSwitch: UIControl {
|
|
|
5585
5787
|
guard isEnabled else { return false }
|
|
5586
5788
|
touchOrigin = touch.location(in: self)
|
|
5587
5789
|
dragPosition = nil
|
|
5790
|
+
press(1)
|
|
5588
5791
|
return true
|
|
5589
5792
|
}
|
|
5590
5793
|
func updateDrag(to point: CGPoint) {
|
|
@@ -5599,7 +5802,15 @@ final class CraftNativeSolidSwitch: UIControl {
|
|
|
5599
5802
|
return true
|
|
5600
5803
|
}
|
|
5601
5804
|
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
|
|
5602
|
-
|
|
5805
|
+
// The thumb carries on from wherever the finger left it, rather than
|
|
5806
|
+
// jumping to the end of the track the moment it lifts.
|
|
5807
|
+
defer {
|
|
5808
|
+
touchOrigin = nil
|
|
5809
|
+
if let released = dragPosition { drawnPosition = released }
|
|
5810
|
+
dragPosition = nil
|
|
5811
|
+
press(0)
|
|
5812
|
+
moveThumb()
|
|
5813
|
+
}
|
|
5603
5814
|
guard isEnabled, let touch, touchOrigin != nil,
|
|
5604
5815
|
bounds.insetBy(dx: -10, dy: -10).contains(touch.location(in: self)) else { return }
|
|
5605
5816
|
let next = dragPosition.map { ($0 >= 0.5) == (effectiveUserInterfaceLayoutDirection != .rightToLeft) } ?? !isOn
|
|
@@ -5607,8 +5818,10 @@ final class CraftNativeSolidSwitch: UIControl {
|
|
|
5607
5818
|
}
|
|
5608
5819
|
override func cancelTracking(with event: UIEvent?) {
|
|
5609
5820
|
touchOrigin = nil
|
|
5821
|
+
if let released = dragPosition { drawnPosition = released }
|
|
5610
5822
|
dragPosition = nil
|
|
5611
|
-
|
|
5823
|
+
press(0)
|
|
5824
|
+
moveThumb()
|
|
5612
5825
|
}
|
|
5613
5826
|
override func accessibilityActivate() -> Bool {
|
|
5614
5827
|
guard isEnabled else { return false }
|
|
@@ -193,10 +193,15 @@ final class DriverTests: XCTestCase {
|
|
|
193
193
|
visibleScrollFrame(target.frame, in: app.frame) else { return false }
|
|
194
194
|
// Hittable alone accepts controls clipped by the scroll view's edge.
|
|
195
195
|
// Only an ancestor scroll view clips this target, not its siblings.
|
|
196
|
+
// One that does not overlap the target is not its ancestor, whatever
|
|
197
|
+
// the descendants query says: with a keyboard up, the query matched
|
|
198
|
+
// a form's Send button inside the keyboard's suggestion strip, and
|
|
199
|
+
// Send, in full view above the keys, could never be scrolled to.
|
|
196
200
|
let identity = NSPredicate(format: "identifier == %@ AND label == %@", target.identifier, target.label)
|
|
197
|
-
|
|
201
|
+
let frame = target.frame
|
|
202
|
+
for scroll in app.scrollViews.allElementsBoundByIndex where scroll.frame.intersects(frame) {
|
|
198
203
|
if scroll.descendants(matching: .any).matching(identity).firstMatch.exists,
|
|
199
|
-
!visibleScrollFrame(
|
|
204
|
+
!visibleScrollFrame(frame, in: scroll.frame.intersection(app.frame)) { return false }
|
|
200
205
|
}
|
|
201
206
|
return true
|
|
202
207
|
}
|
|
@@ -287,6 +292,12 @@ final class DriverTests: XCTestCase {
|
|
|
287
292
|
|
|
288
293
|
/// A switch reports "0" or "1". Any other value is not a switch state to
|
|
289
294
|
/// wait on, so the tap stands as it would for any other control.
|
|
295
|
+
/// The switch answers `/exec` with 200 whatever the hook did, its first
|
|
296
|
+
/// word the hook's exit code: only `exit 0` is a hook that worked.
|
|
297
|
+
func hookSucceeded(_ answer: String) -> Bool {
|
|
298
|
+
answer.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("exit 0")
|
|
299
|
+
}
|
|
300
|
+
|
|
290
301
|
func switchFlipped(from before: String?, to after: String?) -> Bool {
|
|
291
302
|
let states: Set<String> = ["0", "1"]
|
|
292
303
|
guard let before, let after, states.contains(before), states.contains(after) else { return true }
|
|
@@ -390,7 +401,12 @@ final class DriverTests: XCTestCase {
|
|
|
390
401
|
check(false, "exec \(arg): hook failed or returned no successful response")
|
|
391
402
|
return
|
|
392
403
|
}
|
|
393
|
-
|
|
404
|
+
let answer = out.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
405
|
+
note("exec \(arg): \(answer)")
|
|
406
|
+
// A failed hook (a fresh install that never reinstalled) stops the
|
|
407
|
+
// journey here with its reason, not three steps later at a screen
|
|
408
|
+
// that was never reset.
|
|
409
|
+
check(hookSucceeded(answer), "exec \(arg): hook failed: \(answer)")
|
|
394
410
|
case "openurl":
|
|
395
411
|
if let url = URL(string: arg) {
|
|
396
412
|
XCUIDevice.shared.system.open(url)
|
|
@@ -304,17 +304,25 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
304
304
|
private struct MeasureKey: Hashable { let width: CGFloat, stretch: Bool }
|
|
305
305
|
private var measured: [MeasureKey: CGSize] = [:]
|
|
306
306
|
/// What this box measured is stale: it or something inside it changed (CraftMacRenderer.update).
|
|
307
|
-
func forgetMeasurements() { measured.removeAll(keepingCapacity: true); controlSize = nil }
|
|
307
|
+
func forgetMeasurements() { measured.removeAll(keepingCapacity: true); controlSize = nil; mergedLayoutStyle = nil }
|
|
308
308
|
private var measuredGeneration = -1
|
|
309
309
|
/// A control's own size (a button, a switch, a picker), asked once rather than at every width the box is
|
|
310
310
|
/// measured at: a button answers through SwiftUI's layout on current macOS. Forgotten with the measurements.
|
|
311
311
|
private var controlSize: CGSize?
|
|
312
312
|
var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
|
|
313
|
+
static let componentLayoutKeys = ["width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight", "flex", "flexGrow", "flexShrink", "flexBasis", "alignSelf", "position", "zIndex", "left", "right", "top", "bottom", "margin", "marginTop", "marginRight", "marginBottom", "marginLeft", "marginHorizontal", "marginVertical"]
|
|
314
|
+
/// A component wrapper's style with its root's layout keys merged in, kept as its measurements are (it
|
|
315
|
+
/// depends on the same things): a flex pass asks for each child's several times, and a wrapper's merge
|
|
316
|
+
/// asked its root for its own once per key, so nested wrappers rebuilt their styles many times over.
|
|
317
|
+
private var mergedLayoutStyle: [String: Any]?
|
|
318
|
+
private var mergedGeneration = -1
|
|
313
319
|
var layoutStyle: [String: Any] {
|
|
314
320
|
guard let child = componentRoot else { return style }
|
|
315
|
-
|
|
321
|
+
if mergedGeneration == CraftMacBox.measureGeneration, let merged = mergedLayoutStyle { return merged }
|
|
322
|
+
let inner = child.layoutStyle
|
|
316
323
|
var result = style
|
|
317
|
-
for key in
|
|
324
|
+
for key in Self.componentLayoutKeys where result[key] == nil { result[key] = inner[key] }
|
|
325
|
+
mergedLayoutStyle = result; mergedGeneration = CraftMacBox.measureGeneration
|
|
318
326
|
return result
|
|
319
327
|
}
|
|
320
328
|
override var isFlipped: Bool { true }
|
|
@@ -371,7 +379,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
371
379
|
required init?(coder: NSCoder) { fatalError("Native controls are created from STX") }
|
|
372
380
|
|
|
373
381
|
func configure(_ node: [String: Any]) {
|
|
374
|
-
controlSize = nil
|
|
382
|
+
controlSize = nil; mergedLayoutStyle = nil
|
|
375
383
|
props = node["props"] as? [String: Any] ?? [:]
|
|
376
384
|
flow?.componentScope = props["data-stx-component-wrapper"] != nil
|
|
377
385
|
style = node["style"] as? [String: Any] ?? [:]
|
|
@@ -775,11 +783,16 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
775
783
|
let rect = (text.stringValue as NSString).boundingRect(with: CGSize(width: max(1, limit - 6 - textPadding.left - textPadding.right), height: 1_000_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: text.font ?? NSFont.systemFont(ofSize: 13)])
|
|
776
784
|
size = CGSize(width: min(limit, ceil(rect.width) + 9 + textPadding.left + textPadding.right), height: max(18, ceil(rect.height) + 2))
|
|
777
785
|
// The field draws only whole lines inside its cell's insets, so a height short of what the cell
|
|
778
|
-
// asks for at the width it will draw at drops the last line. Ask the cell, not just the string
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
if
|
|
786
|
+
// asks for at the width it will draw at drops the last line. Ask the cell, not just the string,
|
|
787
|
+
// unless the line limit decides the height anyway: one line, or a cap the string already reaches.
|
|
788
|
+
let lines = text.maximumNumberOfLines
|
|
789
|
+
let cap = lines > 0 ? ceil((text.font?.ascender ?? 13) - (text.font?.descender ?? -3) + (text.font?.leading ?? 0)) * CGFloat(lines) + 2 : .infinity
|
|
790
|
+
if lines != 1, size.height < cap {
|
|
791
|
+
let fieldWidth = max(1, size.width - textPadding.left - textPadding.right)
|
|
792
|
+
if let drawn = text.cell?.cellSize(forBounds: CGRect(x: 0, y: 0, width: fieldWidth, height: 1_000_000)).height { size.height = max(size.height, ceil(drawn)) }
|
|
793
|
+
}
|
|
794
|
+
if lines > 0 { size.height = min(size.height, cap) }
|
|
795
|
+
if lines == 1 { size.height = max(18, (text.font?.pointSize ?? 13) + 5) }
|
|
783
796
|
size.height += textPadding.top + textPadding.bottom
|
|
784
797
|
} else if kind == "ScrollView" || kind == "FlatList" || kind == "Sidebar" { size = CGSize(width: limit, height: 100) }
|
|
785
798
|
else if kind == "WebView" { size = CGSize(width: limit, height: 300) }
|
|
@@ -15,6 +15,8 @@ enum CraftTrace {
|
|
|
15
15
|
return Double(info.kp_proc.p_starttime.tv_sec) + Double(info.kp_proc.p_starttime.tv_usec) / 1_000_000
|
|
16
16
|
}()
|
|
17
17
|
private static func since() -> Double { (Date().timeIntervalSince1970 - processStart) * 1000 }
|
|
18
|
+
/// Milliseconds on a monotonic clock, for timing a span from either side of the bridge.
|
|
19
|
+
static func now() -> Double { Double(clock_gettime_nsec_np(CLOCK_UPTIME_RAW)) / 1_000_000 }
|
|
18
20
|
static func mark(_ phase: @autoclosure () -> String) {
|
|
19
21
|
guard enabled else { return }
|
|
20
22
|
fputs(String(format: "[craft trace] %8.1f %@\n", since(), phase()), stderr)
|
|
@@ -135,10 +137,12 @@ final class CraftMacRuntime {
|
|
|
135
137
|
let route: String
|
|
136
138
|
let network: CraftMacNetwork
|
|
137
139
|
private let thread = CraftMacScriptThread()
|
|
140
|
+
/// Decodes what the screen posts (`receive`), in the order it was posted, and keeps its network tasks.
|
|
141
|
+
private let decoder = DispatchQueue(label: "craft.script.decode", qos: .userInteractive)
|
|
142
|
+
private var tasks: [String: URLSessionTask] = [:]
|
|
138
143
|
// Script thread only: the context and what the screen asked of it.
|
|
139
144
|
private var context: ZigJSContext?
|
|
140
145
|
private var timers: [Int: CFRunLoopTimer] = [:]
|
|
141
|
-
private var tasks: [String: URLSessionTask] = [:]
|
|
142
146
|
private var stopped = false
|
|
143
147
|
// Main thread only.
|
|
144
148
|
private(set) weak var scene: CraftMacScene?
|
|
@@ -175,7 +179,13 @@ final class CraftMacRuntime {
|
|
|
175
179
|
self?.report(message)
|
|
176
180
|
fputs("[craft native] \(message)\n\(stack)\n", stderr)
|
|
177
181
|
}
|
|
178
|
-
|
|
182
|
+
// An NSString: bridging the text to a Swift String would copy it again on the screen's thread.
|
|
183
|
+
let post: @convention(block) (NSString) -> Void = { [weak self] text in self?.receive(text) }
|
|
184
|
+
// Under CRAFT_TRACE the screen's side of each post is timed too (`post`, below), from JavaScript.
|
|
185
|
+
let tracePost: @convention(block) (Double, Double) -> Void = { bytes, start in
|
|
186
|
+
CraftTrace.mark(String(format: " post %.0f bytes (%.2f ms on the screen's thread)", bytes, CraftTrace.now() - start))
|
|
187
|
+
}
|
|
188
|
+
let now: @convention(block) () -> Double = { CraftTrace.now() }
|
|
179
189
|
let schedule: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
|
|
180
190
|
guard let self, !self.stopped else { return }
|
|
181
191
|
self.timers[id] = self.thread.after(max(0, min(delay.isFinite ? delay : 0, 2_147_483_647)) / 1000) { [weak self] in
|
|
@@ -205,6 +215,10 @@ final class CraftMacRuntime {
|
|
|
205
215
|
context.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
|
|
206
216
|
context.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
|
|
207
217
|
context.setObject(post, forKeyedSubscript: "craftNativePostMessage" as NSString)
|
|
218
|
+
if CraftTrace.enabled {
|
|
219
|
+
context.setObject(tracePost, forKeyedSubscript: "craftNativeTracePost" as NSString)
|
|
220
|
+
context.setObject(now, forKeyedSubscript: "craftNativeNow" as NSString)
|
|
221
|
+
}
|
|
208
222
|
context.setObject(schedule, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
|
|
209
223
|
context.setObject(cancel, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
|
|
210
224
|
context.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
|
|
@@ -222,7 +236,9 @@ final class CraftMacRuntime {
|
|
|
222
236
|
context.evaluateScript("""
|
|
223
237
|
globalThis.__stxNativeCallback = null;
|
|
224
238
|
globalThis.__stxNativeBridge = \(bridgeJSON);
|
|
225
|
-
__stxNativeBridge.postMessage =
|
|
239
|
+
__stxNativeBridge.postMessage = typeof craftNativeTracePost === 'function'
|
|
240
|
+
? function(text) { var start = craftNativeNow(); craftNativePostMessage(text); craftNativeTracePost(text.length, start); }
|
|
241
|
+
: function(text) { craftNativePostMessage(text); };
|
|
226
242
|
__stxNativeBridge.onMessage = function(callback) { globalThis.__stxNativeCallback = callback; };
|
|
227
243
|
globalThis.craft = globalThis.craft || {};
|
|
228
244
|
craft.storage = {
|
|
@@ -322,12 +338,12 @@ final class CraftMacRuntime {
|
|
|
322
338
|
CraftMacWebTabs.shared.closeAll(owner: self)
|
|
323
339
|
#endif
|
|
324
340
|
network.cancelStreams(owner: self)
|
|
341
|
+
decoder.async { self.tasks.values.forEach { $0.cancel() }; self.tasks.removeAll() }
|
|
325
342
|
let done = DispatchSemaphore(value: 0)
|
|
326
343
|
thread.run {
|
|
327
344
|
self.context?.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
|
|
328
345
|
self.stopped = true
|
|
329
346
|
self.timers.values.forEach(CFRunLoopTimerInvalidate); self.timers.removeAll()
|
|
330
|
-
self.tasks.values.forEach { $0.cancel() }; self.tasks.removeAll()
|
|
331
347
|
// Released on the thread that made it.
|
|
332
348
|
self.context = nil
|
|
333
349
|
done.signal()
|
|
@@ -355,10 +371,19 @@ final class CraftMacRuntime {
|
|
|
355
371
|
send("EVENT", ["handlerId": id, "nativeEvent": payload])
|
|
356
372
|
}
|
|
357
373
|
|
|
358
|
-
/// A message from the screen, on its thread
|
|
359
|
-
///
|
|
360
|
-
|
|
361
|
-
|
|
374
|
+
/// A message from the screen, on its thread: handed to a queue of its own to decode, so the screen's
|
|
375
|
+
/// post returns at once. Decoding a batch of a few hundred operations took the screen's thread several
|
|
376
|
+
/// milliseconds per post, inside the screen's render.
|
|
377
|
+
private func receive(_ text: NSString) {
|
|
378
|
+
decoder.async { [weak self] in self?.decode(text) }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/// Decodes the screen's messages in order. Network requests start and storage answers here; whatever
|
|
382
|
+
/// draws or needs AppKit goes to the main thread.
|
|
383
|
+
private func decode(_ text: NSString) {
|
|
384
|
+
guard let message = CraftTrace.span(" decode \(text.length) chars", atLeast: 1, {
|
|
385
|
+
text.data(using: String.Encoding.utf8.rawValue).flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }
|
|
386
|
+
}) else { return }
|
|
362
387
|
let payload = message["payload"] as? [String: Any] ?? [:]
|
|
363
388
|
switch message["type"] as? String {
|
|
364
389
|
case "RENDER", "MUTATE": DispatchQueue.main.async { self.whenAttached { self.handle(message) } }
|
|
@@ -414,15 +439,16 @@ final class CraftMacRuntime {
|
|
|
414
439
|
|
|
415
440
|
/// An answer to the screen's request `id`, from any thread.
|
|
416
441
|
func answer(_ id: String, _ value: Any = NSNull()) {
|
|
417
|
-
|
|
442
|
+
decoder.async { self.tasks.removeValue(forKey: id) }
|
|
418
443
|
send("API_RESPONSE", ["version": 1, "requestId": id, "data": value], correlation: id)
|
|
419
444
|
}
|
|
420
445
|
func fail(_ id: String, _ code: String, _ message: String) {
|
|
421
|
-
|
|
446
|
+
decoder.async { self.tasks.removeValue(forKey: id) }
|
|
422
447
|
send("API_ERROR", ["version": 1, "requestId": id, "code": code, "message": message], correlation: id)
|
|
423
448
|
}
|
|
424
449
|
|
|
425
|
-
/// `Network` requests, on the
|
|
450
|
+
/// `Network` requests, on the decoding queue, which keeps their tasks: a request leaves as soon as it is
|
|
451
|
+
/// posted, even while the screen goes on rendering. Their answers come back on the network's own queue.
|
|
426
452
|
private func fetch(_ id: String, _ method: String, _ args: [Any]) {
|
|
427
453
|
let options = args.first as? [String: Any] ?? [:]
|
|
428
454
|
if method == "cancelStream" { network.cancelStream(args.first as? String ?? "", owner: self); answer(id); return }
|
|
@@ -442,7 +468,7 @@ final class CraftMacRuntime {
|
|
|
442
468
|
tasks[id] = task; network.resume(task)
|
|
443
469
|
}
|
|
444
470
|
|
|
445
|
-
/// `Storage` and `SecureStorage`, on the
|
|
471
|
+
/// `Storage` and `SecureStorage`, on the decoding queue: UserDefaults and the Keychain answer on any thread.
|
|
446
472
|
private func store(_ id: String, _ module: String, _ method: String, _ args: [Any]) {
|
|
447
473
|
switch module {
|
|
448
474
|
case "SecureStorage":
|
package/dist/updater/index.d.ts
CHANGED
|
@@ -87,6 +87,19 @@ export interface UpdaterConfig {
|
|
|
87
87
|
*/
|
|
88
88
|
relaunch?: (appPath: string) => void | Promise<void>;
|
|
89
89
|
}
|
|
90
|
+
/** Background policy; the host keeps preferences in its own native model. */
|
|
91
|
+
export interface AutoCheckOptions {
|
|
92
|
+
enabled?: () => boolean | Promise<boolean>;
|
|
93
|
+
/** Delay the first check without delaying manual checks. Defaults to zero. */
|
|
94
|
+
initialDelay?: number;
|
|
95
|
+
/** How often to re-read enabled while a check is not due. Defaults to a minute. */
|
|
96
|
+
policyInterval?: number;
|
|
97
|
+
/** Download in the background, independently of manual check behavior. */
|
|
98
|
+
autoDownload?: boolean;
|
|
99
|
+
}
|
|
100
|
+
export interface CheckForUpdatesOptions {
|
|
101
|
+
autoDownload?: boolean;
|
|
102
|
+
}
|
|
90
103
|
export interface UpdateProgress {
|
|
91
104
|
phase: 'checking' | 'downloading' | 'extracting' | 'installing' | 'done' | 'error';
|
|
92
105
|
percent: number;
|
|
@@ -99,7 +112,12 @@ export declare class AutoUpdater extends EventEmitter {
|
|
|
99
112
|
private config;
|
|
100
113
|
private updateInfo;
|
|
101
114
|
private downloadPath;
|
|
115
|
+
private verifiedDownloadPath;
|
|
102
116
|
private checkTimer;
|
|
117
|
+
private autoCheckGeneration;
|
|
118
|
+
private autoCheckRunning;
|
|
119
|
+
private checking;
|
|
120
|
+
private downloading;
|
|
103
121
|
private cachedEtag;
|
|
104
122
|
private cachedLastModified;
|
|
105
123
|
private cachedManifest;
|
|
@@ -128,22 +146,18 @@ export declare class AutoUpdater extends EventEmitter {
|
|
|
128
146
|
*/
|
|
129
147
|
getLastError(): Error | null;
|
|
130
148
|
constructor(config: UpdaterConfig);
|
|
131
|
-
/**
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
startAutoCheck(): void;
|
|
135
|
-
/**
|
|
136
|
-
* Stop automatic update checking
|
|
137
|
-
*/
|
|
149
|
+
/** Start one background lifecycle. Repeated starts are idempotent. Never installs by default. */
|
|
150
|
+
startAutoCheck(options?: AutoCheckOptions): void;
|
|
151
|
+
/** Stop timers and invalidate policy reads already in flight. Downloads already started may finish. */
|
|
138
152
|
stopAutoCheck(): void;
|
|
139
|
-
/**
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
checkForUpdates(): Promise<UpdateInfo | null>;
|
|
153
|
+
/** Concurrent manual and background callers share one manifest request. */
|
|
154
|
+
checkForUpdates(options?: CheckForUpdatesOptions): Promise<UpdateInfo | null>;
|
|
155
|
+
private performCheck;
|
|
143
156
|
/**
|
|
144
157
|
* Download the update
|
|
145
158
|
*/
|
|
146
159
|
downloadUpdate(): Promise<string | null>;
|
|
160
|
+
private performDownload;
|
|
147
161
|
/**
|
|
148
162
|
* Install the downloaded update.
|
|
149
163
|
*
|