craft-native 0.0.496 → 0.0.499
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/templates/CraftAppLinks.kt.template +28 -0
- package/dist/android/templates/CraftBridge.kt.template +3 -29
- package/dist/android/templates/CraftNativeBackground.kt.template +21 -2
- package/dist/android/templates/CraftNativeCapabilities.kt.template +145 -28
- package/dist/android/templates/CraftNativeShell.kt.template +49 -9
- package/dist/android/templates/CraftNativeSounds.kt.template +22 -3
- package/dist/android/templates/MainActivityNative.kt.template +19 -13
- package/dist/browser.js +27 -5
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +8 -1
- package/dist/ios/templates/CraftHybrid.swift +135 -49
- package/dist/ios/templates/CraftNativeActions.swift +47 -3
- package/dist/ios/templates/CraftNativeFiles.swift +8 -1
- package/dist/ios/templates/CraftNativeScreen.swift +46 -11
- package/dist/ios/templates/CraftPersistentStorage.swift +28 -5
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -909,8 +909,24 @@ async function createBrowserPlatform(options = {}) {
|
|
|
909
909
|
}
|
|
910
910
|
if (method === "write") {
|
|
911
911
|
const settings = object(args[2]);
|
|
912
|
-
const
|
|
913
|
-
|
|
912
|
+
const [name, data] = args;
|
|
913
|
+
const refuse = (message) => Object.assign(new Error(message), { code: "INVALID_ARGUMENT" });
|
|
914
|
+
if (typeof name !== "string" || !name.trim())
|
|
915
|
+
throw refuse("files.write needs a file name");
|
|
916
|
+
if (typeof data !== "string")
|
|
917
|
+
throw refuse("files.write needs its contents as a string");
|
|
918
|
+
const encoding = settings.encoding ?? "utf8";
|
|
919
|
+
if (encoding !== "utf8" && encoding !== "base64")
|
|
920
|
+
throw refuse("files.write encoding is 'utf8' or 'base64'");
|
|
921
|
+
let contents = data;
|
|
922
|
+
if (encoding === "base64") {
|
|
923
|
+
try {
|
|
924
|
+
contents = Uint8Array.from(atob(data), (letter) => letter.charCodeAt(0));
|
|
925
|
+
} catch {
|
|
926
|
+
throw refuse("files.write base64 is invalid");
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return file([contents], name, String(settings.type ?? "application/octet-stream"));
|
|
914
930
|
}
|
|
915
931
|
if (method === "remove") {
|
|
916
932
|
const path = String(args[0]);
|
|
@@ -1071,14 +1087,15 @@ async function createBrowserPlatform(options = {}) {
|
|
|
1071
1087
|
throw error;
|
|
1072
1088
|
}
|
|
1073
1089
|
}
|
|
1090
|
+
const fadeOut = (options) => typeof options.fadeOut === "number" ? options.fadeOut : 0;
|
|
1074
1091
|
if (method === "stopAll") {
|
|
1075
1092
|
soundRequests.clear();
|
|
1076
|
-
await Promise.all([...sounds.keys()].map((key) => stopSound(key,
|
|
1093
|
+
await Promise.all([...sounds.keys()].map((key) => stopSound(key, fadeOut(first))));
|
|
1077
1094
|
return true;
|
|
1078
1095
|
}
|
|
1079
1096
|
if (method === "stop") {
|
|
1080
1097
|
soundRequests.delete(id);
|
|
1081
|
-
await stopSound(id,
|
|
1098
|
+
await stopSound(id, fadeOut(object(args[1])));
|
|
1082
1099
|
return Boolean(sound);
|
|
1083
1100
|
}
|
|
1084
1101
|
if (!sound)
|
|
@@ -1092,7 +1109,12 @@ async function createBrowserPlatform(options = {}) {
|
|
|
1092
1109
|
return true;
|
|
1093
1110
|
}
|
|
1094
1111
|
if (method === "setVolume") {
|
|
1095
|
-
|
|
1112
|
+
const volume = args[1];
|
|
1113
|
+
const asked = object(args[2]).fade;
|
|
1114
|
+
const milliseconds = typeof asked === "number" ? asked : 0;
|
|
1115
|
+
if (typeof volume !== "number" || !Number.isFinite(volume) || !Number.isFinite(milliseconds) || milliseconds < 0)
|
|
1116
|
+
return false;
|
|
1117
|
+
await fade(sound, volume, milliseconds);
|
|
1096
1118
|
return true;
|
|
1097
1119
|
}
|
|
1098
1120
|
}
|
package/dist/cli.js
CHANGED
|
@@ -3246,7 +3246,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3246
3246
|
`);
|
|
3247
3247
|
}
|
|
3248
3248
|
// package.json
|
|
3249
|
-
var version = "0.0.
|
|
3249
|
+
var version = "0.0.499";
|
|
3250
3250
|
|
|
3251
3251
|
// bin/cli.ts
|
|
3252
3252
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -822,6 +822,9 @@ final class CraftChrome: ObservableObject {
|
|
|
822
822
|
/// A shared material capsule; nil keeps separate floating buttons.
|
|
823
823
|
var accessoryLayout: String? = nil
|
|
824
824
|
var companions: [Tab] = []
|
|
825
|
+
/// A floating action that is on: a mode it switched (tracking in
|
|
826
|
+
/// Maps), drawn in the tint whatever screen is showing.
|
|
827
|
+
var active = false
|
|
825
828
|
/// A tap opens these destinations in a compact menu instead of navigating.
|
|
826
829
|
var menu: [TabMenuItem] = []
|
|
827
830
|
}
|
|
@@ -846,6 +849,8 @@ final class CraftChrome: ObservableObject {
|
|
|
846
849
|
guard let path = visiblePath else { return false }
|
|
847
850
|
return path == accessory.id || path.hasPrefix(accessory.id + "/")
|
|
848
851
|
}
|
|
852
|
+
/// Drawn in the tint: its destination is showing, or the app says it is on.
|
|
853
|
+
func accessoryActive(_ accessory: Tab) -> Bool { accessory.active || accessorySelected(accessory) }
|
|
849
854
|
var highlightedTab: String? { accessorySelected ? nil : selected }
|
|
850
855
|
let tabSwipeProgress = CraftTabSwipeProgress()
|
|
851
856
|
private(set) var tabSwipePosition: CGFloat? {
|
|
@@ -977,13 +982,15 @@ final class CraftChrome: ObservableObject {
|
|
|
977
982
|
let companionTitle = action["title"] as? String else { return nil }
|
|
978
983
|
return Tab(id: companionPath, title: companionTitle, symbol: action["symbol"] as? String ?? "circle",
|
|
979
984
|
accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true,
|
|
985
|
+
active: action["selected"] as? Bool ?? false,
|
|
980
986
|
menu: ((action["menu"] as? [[String: Any]]) ?? []).prefix(12).compactMap { choice in
|
|
981
987
|
guard let destination = choice["path"] as? String,
|
|
982
988
|
destination.hasPrefix("/"), !destination.hasPrefix("//"),
|
|
983
989
|
let label = choice["title"] as? String, !label.isEmpty else { return nil }
|
|
984
990
|
return TabMenuItem(id: destination, title: label, symbol: choice["symbol"] as? String ?? "circle")
|
|
985
991
|
})
|
|
986
|
-
}
|
|
992
|
+
},
|
|
993
|
+
active: item["selected"] as? Bool ?? false)
|
|
987
994
|
} else {
|
|
988
995
|
next = nil
|
|
989
996
|
}
|
|
@@ -1248,7 +1248,9 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1248
1248
|
private var companionButtons: [UIButton] = []
|
|
1249
1249
|
private var companionModels: [CraftChrome.Tab] = []
|
|
1250
1250
|
private var companionGlasses: [UIVisualEffectView] = []
|
|
1251
|
-
|
|
1251
|
+
/// Whether the shared surface was last configured for a group; its
|
|
1252
|
+
/// material is replaced only when that changes, never on a badge update.
|
|
1253
|
+
private var accessorySurfaceGrouped: Bool?
|
|
1252
1254
|
private let accessoryBadge = UILabel()
|
|
1253
1255
|
private let accessoryGlass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
|
1254
1256
|
private static let tabPictures = NSCache<NSURL, UIImage>()
|
|
@@ -1481,12 +1483,10 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1481
1483
|
let button = UIButton(type: .system)
|
|
1482
1484
|
var configuration = UIButton.Configuration.plain()
|
|
1483
1485
|
configuration.image = UIImage(systemName: action.symbol)
|
|
1484
|
-
configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
|
1485
1486
|
button.configuration = configuration
|
|
1486
1487
|
button.accessibilityIdentifier = "craft-tab-companion:" + action.id
|
|
1487
1488
|
button.accessibilityLabel = action.accessibilityLabel ?? action.title
|
|
1488
1489
|
button.accessibilityValue = action.badge
|
|
1489
|
-
button.accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
|
|
1490
1490
|
if action.menu.isEmpty {
|
|
1491
1491
|
button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
|
|
1492
1492
|
} else {
|
|
@@ -1506,31 +1506,23 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1506
1506
|
glass.clipsToBounds = true
|
|
1507
1507
|
glass.isUserInteractionEnabled = false
|
|
1508
1508
|
view.addSubview(glass)
|
|
1509
|
-
view.addSubview(button)
|
|
1510
1509
|
companionGlasses.append(glass)
|
|
1511
1510
|
companionButtons.append(button)
|
|
1512
1511
|
}
|
|
1513
1512
|
}
|
|
1514
|
-
for (index, action) in companionModels.enumerated() {
|
|
1515
|
-
companionButtons[index].accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
|
|
1516
|
-
}
|
|
1517
1513
|
let grouped = model?.accessoryLayout != nil
|
|
1514
|
+
// A group is one control, as the map controls in Maps are: its
|
|
1515
|
+
// actions live inside the shared surface, so the glass lights the
|
|
1516
|
+
// one being pressed and keeps every symbol legible over the page.
|
|
1517
|
+
// Separate actions stay in the shell, each on its own surface.
|
|
1518
|
+
let actionHost: UIView = grouped ? accessoryGlass.contentView : view
|
|
1518
1519
|
for glass in companionGlasses { glass.isHidden = grouped }
|
|
1519
|
-
for button in companionButtons {
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
let separator = UIView()
|
|
1524
|
-
separator.backgroundColor = .separator
|
|
1525
|
-
separator.isUserInteractionEnabled = false
|
|
1526
|
-
separator.isAccessibilityElement = false
|
|
1527
|
-
view.addSubview(separator)
|
|
1528
|
-
return separator
|
|
1529
|
-
} : []
|
|
1520
|
+
for (index, button) in companionButtons.enumerated() {
|
|
1521
|
+
if button.superview !== actionHost { actionHost.addSubview(button) }
|
|
1522
|
+
styleAccessory(button, for: companionModels[index], grouped: grouped)
|
|
1523
|
+
}
|
|
1530
1524
|
if let model {
|
|
1531
1525
|
var configuration = UIButton.Configuration.plain()
|
|
1532
|
-
configuration.image = UIImage(systemName: model.symbol)
|
|
1533
|
-
configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
|
1534
1526
|
configuration.title = model.iconOnly ? nil : model.displayTitle
|
|
1535
1527
|
configuration.subtitle = model.iconOnly ? nil : model.badge
|
|
1536
1528
|
configuration.imagePadding = model.iconOnly ? 0 : 8
|
|
@@ -1538,7 +1530,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1538
1530
|
button.configuration = configuration
|
|
1539
1531
|
button.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
|
|
1540
1532
|
button.accessibilityValue = model.badge
|
|
1541
|
-
button
|
|
1533
|
+
styleAccessory(button, for: model, grouped: grouped)
|
|
1542
1534
|
}
|
|
1543
1535
|
}
|
|
1544
1536
|
#if compiler(>=6.2)
|
|
@@ -1559,22 +1551,17 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1559
1551
|
// Older systems have no bottom-accessory container. Keep the action
|
|
1560
1552
|
// available above the system bar, without recreating the tab control.
|
|
1561
1553
|
#if compiler(>=6.2)
|
|
1562
|
-
if #available(iOS 26.0, *) {
|
|
1563
|
-
bottomAccessory = nil
|
|
1564
|
-
accessoryGlass.effect = UIGlassEffect()
|
|
1565
|
-
}
|
|
1554
|
+
if #available(iOS 26.0, *) { bottomAccessory = nil }
|
|
1566
1555
|
#endif
|
|
1567
|
-
if model != nil,
|
|
1556
|
+
if model != nil, accessoryGlass.superview !== view || accessoryButton.superview !== actionHost {
|
|
1568
1557
|
accessoryButton.removeFromSuperview()
|
|
1569
|
-
view.addSubview(accessoryGlass)
|
|
1570
|
-
|
|
1558
|
+
if accessoryGlass.superview !== view { view.addSubview(accessoryGlass) }
|
|
1559
|
+
actionHost.addSubview(accessoryButton)
|
|
1571
1560
|
view.addSubview(accessoryBadge)
|
|
1572
1561
|
}
|
|
1573
1562
|
accessoryButton.isHidden = model == nil
|
|
1574
1563
|
accessoryGlass.isHidden = model == nil
|
|
1575
|
-
|
|
1576
|
-
accessoryGlass.layer.cornerRadius = grouped ? 26 : 24
|
|
1577
|
-
accessoryGlass.clipsToBounds = true
|
|
1564
|
+
configureAccessorySurface(grouped: grouped)
|
|
1578
1565
|
accessoryButton.backgroundColor = .clear
|
|
1579
1566
|
accessoryButton.layer.cornerRadius = 24
|
|
1580
1567
|
accessoryBadge.text = model?.badge
|
|
@@ -1591,15 +1578,86 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1591
1578
|
// companion-only update creates new controls or a shelf changes mode.
|
|
1592
1579
|
if accessoryGlass.superview === view {
|
|
1593
1580
|
view.bringSubviewToFront(accessoryGlass)
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1581
|
+
if !grouped {
|
|
1582
|
+
for button in companionButtons { view.bringSubviewToFront(button) }
|
|
1583
|
+
view.bringSubviewToFront(accessoryButton)
|
|
1584
|
+
}
|
|
1597
1585
|
view.bringSubviewToFront(accessoryBadge)
|
|
1598
1586
|
}
|
|
1599
1587
|
for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar() }
|
|
1600
1588
|
view.setNeedsLayout()
|
|
1601
1589
|
}
|
|
1602
1590
|
|
|
1591
|
+
/// Symbols in a group carry the weight of the system's own map controls,
|
|
1592
|
+
/// and their size: at 20 points a symbol filled its 48-point action
|
|
1593
|
+
/// where the ones in Maps leave half of theirs clear. The control and
|
|
1594
|
+
/// its touch targets keep their size; only the symbol is smaller. A
|
|
1595
|
+
/// separate action keeps the lighter bar-button weight.
|
|
1596
|
+
private static func accessorySymbol(grouped: Bool) -> UIImage.SymbolConfiguration {
|
|
1597
|
+
UIImage.SymbolConfiguration(pointSize: grouped ? 15 : 22, weight: grouped ? .medium : .regular)
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/// One action's symbol, tint and state. An action that is on (its
|
|
1601
|
+
/// destination is showing, or the app says so) is drawn in the tint and,
|
|
1602
|
+
/// where the symbol has one, filled: Maps' location arrow while tracking.
|
|
1603
|
+
/// Off, a grouped action is the label colour, as the map controls are.
|
|
1604
|
+
private func styleAccessory(_ button: UIButton, for action: CraftChrome.Tab, grouped: Bool) {
|
|
1605
|
+
let active = CraftChrome.shared.accessoryActive(action)
|
|
1606
|
+
let filled = active && !action.symbol.hasSuffix(".fill") ? UIImage(systemName: action.symbol + ".fill") : nil
|
|
1607
|
+
let tint: UIColor = CraftChrome.shared.tint ?? config.tintColor.flatMap { UIColor(hex: $0) } ?? view.tintColor
|
|
1608
|
+
button.configuration?.image = filled ?? UIImage(systemName: action.symbol)
|
|
1609
|
+
button.configuration?.preferredSymbolConfigurationForImage = Self.accessorySymbol(grouped: grouped)
|
|
1610
|
+
// Said to the configuration as well as the view: glass draws its
|
|
1611
|
+
// content in the label colour unless the content names its own.
|
|
1612
|
+
button.configuration?.baseForegroundColor = active ? tint : grouped ? .label : nil
|
|
1613
|
+
// A group places each symbol itself, when it lays out.
|
|
1614
|
+
if !grouped { button.configuration?.contentInsets = .zero }
|
|
1615
|
+
button.tintColor = active ? tint : grouped ? .label : nil
|
|
1616
|
+
button.accessibilityTraits = active ? [.button, .selected] : [.button]
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/// The surface under the floating actions. A group is a capsule of
|
|
1620
|
+
/// interactive Liquid Glass on iOS 26, shaped by the system so its rim,
|
|
1621
|
+
/// lensing and shadow are not clipped away; before iOS 26 it is the tab
|
|
1622
|
+
/// bar's material with a hairline. A single action stays a plain circle.
|
|
1623
|
+
private func configureAccessorySurface(grouped: Bool) {
|
|
1624
|
+
accessoryGlass.isUserInteractionEnabled = grouped
|
|
1625
|
+
if accessorySurfaceGrouped != grouped {
|
|
1626
|
+
accessorySurfaceGrouped = grouped
|
|
1627
|
+
var system = false
|
|
1628
|
+
#if compiler(>=6.2)
|
|
1629
|
+
if #available(iOS 26.0, *) {
|
|
1630
|
+
let glass = UIGlassEffect()
|
|
1631
|
+
glass.isInteractive = grouped
|
|
1632
|
+
accessoryGlass.effect = glass
|
|
1633
|
+
accessoryGlass.cornerConfiguration = grouped ? .capsule() : .corners(radius: 24)
|
|
1634
|
+
system = true
|
|
1635
|
+
}
|
|
1636
|
+
#endif
|
|
1637
|
+
if system {
|
|
1638
|
+
accessoryGlass.layer.cornerRadius = 0
|
|
1639
|
+
accessoryGlass.layer.borderWidth = 0
|
|
1640
|
+
accessoryGlass.clipsToBounds = false
|
|
1641
|
+
} else {
|
|
1642
|
+
accessoryGlass.effect = UIBlurEffect(style: grouped ? .systemUltraThinMaterial : .systemMaterial)
|
|
1643
|
+
accessoryGlass.layer.cornerCurve = .continuous
|
|
1644
|
+
accessoryGlass.layer.borderWidth = grouped ? 0.5 : 0
|
|
1645
|
+
accessoryGlass.layer.borderColor = UIColor.white.withAlphaComponent(0.35).cgColor
|
|
1646
|
+
accessoryGlass.clipsToBounds = true
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
// Before iOS 26 the capsule follows the group's shorter side.
|
|
1650
|
+
if accessoryGlass.clipsToBounds {
|
|
1651
|
+
accessoryGlass.layer.cornerRadius = grouped ? Self.accessoryGroupBreadth / 2 : 24
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/// A group's breadth, and the distance from one symbol to the next. As
|
|
1656
|
+
/// in Maps, the first and last symbols sit at the centres of the
|
|
1657
|
+
/// capsule's round ends, so two actions make a 48 by 103 point control.
|
|
1658
|
+
static let accessoryGroupBreadth: CGFloat = 48
|
|
1659
|
+
static let accessoryGroupStep: CGFloat = 55
|
|
1660
|
+
|
|
1603
1661
|
@objc private func openTabAccessory() {
|
|
1604
1662
|
if let accessoryModel { hybrid.open(accessoryModel.id) }
|
|
1605
1663
|
}
|
|
@@ -1617,6 +1675,11 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1617
1675
|
}
|
|
1618
1676
|
#endif
|
|
1619
1677
|
let iconOnly = accessoryModel?.iconOnly == true
|
|
1678
|
+
if let axis = accessoryModel?.accessoryLayout {
|
|
1679
|
+
layoutAccessoryGroup(vertical: axis == "vertical")
|
|
1680
|
+
CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY))
|
|
1681
|
+
return
|
|
1682
|
+
}
|
|
1620
1683
|
let height: CGFloat = iconOnly ? 48 : 44
|
|
1621
1684
|
let width: CGFloat = iconOnly ? 48 : max(0, view.bounds.width - 32)
|
|
1622
1685
|
let trailingX = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
|
|
@@ -1624,25 +1687,12 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1624
1687
|
let x = iconOnly ? trailingX : 16
|
|
1625
1688
|
accessoryButton.frame = CGRect(x: x, y: tabBar.frame.minY - height - (iconOnly ? 8 : 0), width: width, height: height)
|
|
1626
1689
|
accessoryGlass.frame = accessoryButton.frame
|
|
1627
|
-
let layout = accessoryModel?.accessoryLayout
|
|
1628
1690
|
for (index, button) in companionButtons.enumerated() {
|
|
1629
1691
|
let offset = CGFloat(companionButtons.count - index)
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
} else {
|
|
1633
|
-
let direction: CGFloat = view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? 1 : -1
|
|
1634
|
-
button.frame = accessoryButton.frame.offsetBy(dx: direction * offset * (width + (layout == nil ? 12 : 0)), dy: 0)
|
|
1635
|
-
}
|
|
1692
|
+
let direction: CGFloat = view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? 1 : -1
|
|
1693
|
+
button.frame = accessoryButton.frame.offsetBy(dx: direction * offset * (width + 12), dy: 0)
|
|
1636
1694
|
companionGlasses[index].frame = button.frame
|
|
1637
|
-
if layout != nil {
|
|
1638
|
-
accessoryGlass.frame = accessoryGlass.frame.union(button.frame)
|
|
1639
|
-
let line = 1 / (view.window?.screen.scale ?? 2)
|
|
1640
|
-
accessorySeparators[index].frame = layout == "vertical"
|
|
1641
|
-
? CGRect(x: button.frame.minX + 12, y: button.frame.maxY - line / 2, width: width - 24, height: line)
|
|
1642
|
-
: CGRect(x: view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? button.frame.minX : button.frame.maxX - line / 2, y: button.frame.minY + 12, width: line, height: height - 24)
|
|
1643
|
-
}
|
|
1644
1695
|
}
|
|
1645
|
-
if layout != nil { accessoryGlass.frame = accessoryGlass.frame.insetBy(dx: -2, dy: -2) }
|
|
1646
1696
|
let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
|
|
1647
1697
|
accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
|
|
1648
1698
|
// A compact action floats over the page, not across its whole width.
|
|
@@ -1651,6 +1701,42 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1651
1701
|
CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY) + accessoryInset)
|
|
1652
1702
|
}
|
|
1653
1703
|
|
|
1704
|
+
/// One capsule above the trailing end of the tab bar, companions first
|
|
1705
|
+
/// and the primary action last. Nothing divides the actions: the glass
|
|
1706
|
+
/// is one control, and each action is found by its place and its symbol.
|
|
1707
|
+
private func layoutAccessoryGroup(vertical: Bool) {
|
|
1708
|
+
let breadth = Self.accessoryGroupBreadth
|
|
1709
|
+
let step = Self.accessoryGroupStep
|
|
1710
|
+
let buttons = companionButtons + [accessoryButton]
|
|
1711
|
+
let length = breadth + step * CGFloat(buttons.count - 1)
|
|
1712
|
+
let size = vertical ? CGSize(width: breadth, height: length) : CGSize(width: length, height: breadth)
|
|
1713
|
+
let rightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
|
|
1714
|
+
// The tab bar's own platter sits this far in from the screen's edge.
|
|
1715
|
+
let margin: CGFloat = 22
|
|
1716
|
+
let x = rightToLeft ? view.safeAreaInsets.left + margin : view.bounds.maxX - view.safeAreaInsets.right - size.width - margin
|
|
1717
|
+
accessoryGlass.frame = CGRect(x: x, y: tabBar.frame.minY - size.height - 10, width: size.width, height: size.height)
|
|
1718
|
+
for (index, button) in buttons.enumerated() {
|
|
1719
|
+
// Every point of the capsule belongs to the nearest action; its
|
|
1720
|
+
// symbol stays where Maps puts it, at the centre of a round end
|
|
1721
|
+
// for the first and the last.
|
|
1722
|
+
let place = rightToLeft && !vertical ? buttons.count - 1 - index : index
|
|
1723
|
+
let centre = breadth / 2 + CGFloat(place) * step
|
|
1724
|
+
let from = place == 0 ? 0 : centre - step / 2
|
|
1725
|
+
let to = place == buttons.count - 1 ? length : centre + step / 2
|
|
1726
|
+
button.frame = vertical
|
|
1727
|
+
? CGRect(x: 0, y: from, width: breadth, height: to - from)
|
|
1728
|
+
: CGRect(x: from, y: 0, width: to - from, height: breadth)
|
|
1729
|
+
let shift = 2 * (centre - from) - (to - from)
|
|
1730
|
+
let before = max(0, shift), after = max(0, -shift)
|
|
1731
|
+
button.configuration?.contentInsets = vertical
|
|
1732
|
+
? NSDirectionalEdgeInsets(top: before, leading: 0, bottom: after, trailing: 0)
|
|
1733
|
+
: NSDirectionalEdgeInsets(top: 0, leading: rightToLeft ? after : before, bottom: 0, trailing: rightToLeft ? before : after)
|
|
1734
|
+
}
|
|
1735
|
+
let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
|
|
1736
|
+
let primary = accessoryButton.convert(accessoryButton.bounds, to: view)
|
|
1737
|
+
accessoryBadge.frame = CGRect(x: primary.maxX - badgeWidth + 4, y: primary.minY - 3, width: badgeWidth, height: 18)
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1654
1740
|
private var webAttached = false
|
|
1655
1741
|
|
|
1656
1742
|
private func installTabSwipeGestures() {
|
|
@@ -391,7 +391,7 @@ enum CraftNativeActions {
|
|
|
391
391
|
complete(requestToken, failure("CAPABILITY_DISABLED", "Local database is disabled"), completion)
|
|
392
392
|
return
|
|
393
393
|
}
|
|
394
|
-
databaseQueue.async { complete(requestToken, database(method: method, args: args), completion) }
|
|
394
|
+
databaseQueue.async { complete(requestToken, database(requestToken: requestToken, method: method, args: args), completion) }
|
|
395
395
|
return
|
|
396
396
|
case ("Browser", "open"):
|
|
397
397
|
// SFSafariViewController, or for `mode: 'auth'` the system's web
|
|
@@ -1275,7 +1275,47 @@ enum CraftNativeActions {
|
|
|
1275
1275
|
#endif
|
|
1276
1276
|
}
|
|
1277
1277
|
|
|
1278
|
-
|
|
1278
|
+
/// A screen has gone. A transaction it began and never finished is rolled
|
|
1279
|
+
/// back.
|
|
1280
|
+
///
|
|
1281
|
+
/// The database is one connection shared by every screen, and a
|
|
1282
|
+
/// transaction left open on it does not end with the screen that began
|
|
1283
|
+
/// it: every later write joins it, reads back as written, and is gone
|
|
1284
|
+
/// when the process exits, since nobody is left to commit; every later
|
|
1285
|
+
/// `beginTransaction` is refused. Rolling back loses what the closed
|
|
1286
|
+
/// screen had not committed, which is what closing mid-transaction means,
|
|
1287
|
+
/// and gives every later write its own fate back. Queued behind whatever
|
|
1288
|
+
/// that screen had already asked for.
|
|
1289
|
+
static func endScope(_ scope: String) {
|
|
1290
|
+
databaseQueue.async {
|
|
1291
|
+
guard transactionScope == scope else { return }
|
|
1292
|
+
transactionScope = nil
|
|
1293
|
+
guard let database, sqlite3_get_autocommit(database) == 0 else { return }
|
|
1294
|
+
_ = execute(sql: "ROLLBACK", params: [])
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/// Which screen's transaction is open, read off the database around each
|
|
1299
|
+
/// call rather than off the method's name, so a `BEGIN` sent through
|
|
1300
|
+
/// `execute` is owned like one sent through `beginTransaction`: a call
|
|
1301
|
+
/// that leaves a transaction open where there was none began it, and one
|
|
1302
|
+
/// that leaves none ended it. The scope is the part of a request token
|
|
1303
|
+
/// before its last `/`. Only touched on `databaseQueue`.
|
|
1304
|
+
private static var transactionScope: String?
|
|
1305
|
+
|
|
1306
|
+
private static func database(requestToken: String, method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
|
|
1307
|
+
let before = database.map { sqlite3_get_autocommit($0) == 0 } ?? false
|
|
1308
|
+
let answer = databaseCall(method: method, args: args)
|
|
1309
|
+
let after = database.map { sqlite3_get_autocommit($0) == 0 } ?? false
|
|
1310
|
+
if after && !before {
|
|
1311
|
+
transactionScope = requestToken.range(of: "/", options: .backwards).map { String(requestToken[..<$0.lowerBound]) } ?? ""
|
|
1312
|
+
} else if !after {
|
|
1313
|
+
transactionScope = nil
|
|
1314
|
+
}
|
|
1315
|
+
return answer
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
private static func databaseCall(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
|
|
1279
1319
|
guard openDatabase() else { return failure("DATABASE_ERROR", "Could not open the local database") }
|
|
1280
1320
|
switch method {
|
|
1281
1321
|
case "execute":
|
|
@@ -1497,7 +1537,11 @@ enum CraftNativeActions {
|
|
|
1497
1537
|
"host": url.host ?? "",
|
|
1498
1538
|
"path": url.path,
|
|
1499
1539
|
"query": url.query ?? "",
|
|
1500
|
-
|
|
1540
|
+
// A link is whatever its sender wrote, and may name a parameter
|
|
1541
|
+
// twice. Built with unique keys this trapped, and the app closed
|
|
1542
|
+
// on being opened by such a link. The first value is kept, which
|
|
1543
|
+
// is the one Android gives.
|
|
1544
|
+
"queryParams": Dictionary((components?.queryItems ?? []).map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first }),
|
|
1501
1545
|
]
|
|
1502
1546
|
}
|
|
1503
1547
|
|
|
@@ -902,8 +902,15 @@ enum CraftNativeFiles {
|
|
|
902
902
|
guard let text = data as? String else {
|
|
903
903
|
return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write needs its contents as a string"))
|
|
904
904
|
}
|
|
905
|
+
// Absent or null is utf8. Anything else that is not one of the two
|
|
906
|
+
// names is refused, a number included, rather than read as utf8.
|
|
907
|
+
let encoding: String?
|
|
908
|
+
switch options["encoding"] {
|
|
909
|
+
case nil, is NSNull: encoding = "utf8"
|
|
910
|
+
case let named: encoding = named as? String
|
|
911
|
+
}
|
|
905
912
|
let bytes: Data
|
|
906
|
-
switch
|
|
913
|
+
switch encoding {
|
|
907
914
|
case "utf8":
|
|
908
915
|
bytes = Data(text.utf8)
|
|
909
916
|
case "base64":
|
|
@@ -4798,14 +4798,23 @@ final class CraftPressFeedbackDelegate: NSObject, UIGestureRecognizerDelegate {
|
|
|
4798
4798
|
|
|
4799
4799
|
/// Whether a scrolling view's gesture runs together with `other`. A scroll
|
|
4800
4800
|
/// view is the delegate of its own pan as well as of its keyboard tap, so this
|
|
4801
|
-
/// answers for both.
|
|
4801
|
+
/// answers for both. Most things run together (the keyboard tap watches
|
|
4802
4802
|
/// alongside a control's own gestures; the back-swipe and the tab swipe decide
|
|
4803
|
-
/// for themselves)
|
|
4804
|
-
///
|
|
4803
|
+
/// for themselves), with two exceptions for a scroll view's pan.
|
|
4804
|
+
///
|
|
4805
|
+
/// Another scroll view's pan: run together, a drag on a nested scroll view
|
|
4806
|
+
/// scrolled its parent at the same time.
|
|
4807
|
+
///
|
|
4808
|
+
/// A press. A row or a card that can be pressed is a view with a tap
|
|
4809
|
+
/// recognizer, and UIKit fails a tap the moment a drag begins on the scroll
|
|
4810
|
+
/// view around it. That is what keeps a flick, or the touch that stops a
|
|
4811
|
+
/// moving list, from also pressing whatever is under the finger. Told to run
|
|
4812
|
+
/// together, both happened: a short scroll pressed the thing it began on.
|
|
4805
4813
|
func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestureRecognizer) -> Bool {
|
|
4806
|
-
guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer
|
|
4807
|
-
|
|
4808
|
-
return false
|
|
4814
|
+
guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer else { return true }
|
|
4815
|
+
if let otherScroll = other.view as? UIScrollView, other === otherScroll.panGestureRecognizer { return false }
|
|
4816
|
+
if other is UITapGestureRecognizer, other.delegate is CraftPressGestureDelegate { return false }
|
|
4817
|
+
return true
|
|
4809
4818
|
}
|
|
4810
4819
|
|
|
4811
4820
|
/// Scroll containers observe taps without cancelling native input focus or
|
|
@@ -6930,6 +6939,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6930
6939
|
private var runningAnimation: CraftNativeRunningAnimation?
|
|
6931
6940
|
private var renderedRoot: RenderedNode?
|
|
6932
6941
|
private weak var lastFocusedInput: UIView?
|
|
6942
|
+
/// On its way off screen, or off it: from `viewWillDisappear` until the
|
|
6943
|
+
/// screen is next about to appear.
|
|
6944
|
+
///
|
|
6945
|
+
/// A screen ends editing as it starts to leave (#497), and its view stays
|
|
6946
|
+
/// in the window for the length of the transition. Whatever put focus
|
|
6947
|
+
/// back in that time brought the keyboard up for a screen that was then
|
|
6948
|
+
/// removed, where nothing could put it away: a render, and a sign-in
|
|
6949
|
+
/// screen makes one as it navigates; the acknowledgement an input had
|
|
6950
|
+
/// scheduled a tenth of a second before; a modal closing; `autoFocus`.
|
|
6951
|
+
/// Each of them asks this first.
|
|
6952
|
+
private var isLeavingScreen = false
|
|
6933
6953
|
|
|
6934
6954
|
/// The person put the keyboard away (Return, or a tap on nothing in a
|
|
6935
6955
|
/// scroll view): the last input is let go. Kept, the next render or
|
|
@@ -7035,6 +7055,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7035
7055
|
let timers = self.timers
|
|
7036
7056
|
CraftNativeScriptThread.run { timers.pending.values.forEach { $0.cancel() } }
|
|
7037
7057
|
pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
|
|
7058
|
+
CraftNativeActions.endScope(capabilityScope)
|
|
7038
7059
|
lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
|
|
7039
7060
|
if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
|
|
7040
7061
|
}
|
|
@@ -7091,6 +7112,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7091
7112
|
|
|
7092
7113
|
override func viewWillAppear(_ animated: Bool) {
|
|
7093
7114
|
super.viewWillAppear(animated)
|
|
7115
|
+
isLeavingScreen = false
|
|
7094
7116
|
if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
|
|
7095
7117
|
navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
|
|
7096
7118
|
} else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
|
|
@@ -7133,7 +7155,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7133
7155
|
//
|
|
7134
7156
|
// `lastFocusedInput` is deliberately left alone. A screen merely
|
|
7135
7157
|
// covered by a push comes back, and restoreFocus putting the person
|
|
7136
|
-
// back where they were is the behaviour that was already there.
|
|
7158
|
+
// back where they were is the behaviour that was already there. It
|
|
7159
|
+
// is not consulted again until the screen is coming back, or what
|
|
7160
|
+
// is ended here would be started again a moment later.
|
|
7161
|
+
isLeavingScreen = true
|
|
7137
7162
|
view.endEditing(true)
|
|
7138
7163
|
screenVisibilityChanged(false)
|
|
7139
7164
|
stopCelebrations(in: view)
|
|
@@ -7298,6 +7323,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7298
7323
|
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
7299
7324
|
pendingCapabilityDeadlines.removeAll()
|
|
7300
7325
|
pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
|
|
7326
|
+
CraftNativeActions.endScope(capabilityScope)
|
|
7301
7327
|
pendingCapabilityRequests.removeAll()
|
|
7302
7328
|
if let owner = voiceRecordingOwner {
|
|
7303
7329
|
voiceRecordingOwner = nil
|
|
@@ -9307,7 +9333,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9307
9333
|
reply(.failure(CraftNativeActionError(code: "UNSUPPORTED_TARGET", message: "Keyboard focus is supported only for text inputs")))
|
|
9308
9334
|
return true
|
|
9309
9335
|
}
|
|
9310
|
-
|
|
9336
|
+
// A screen that is leaving does not open a keyboard it will
|
|
9337
|
+
// not be there to close.
|
|
9338
|
+
guard !isLeavingScreen, node.view.becomeFirstResponder() else {
|
|
9311
9339
|
reply(.failure(CraftNativeActionError(code: "FOCUS_FAILED", message: "The mounted view refused keyboard focus")))
|
|
9312
9340
|
return true
|
|
9313
9341
|
}
|
|
@@ -9652,6 +9680,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9652
9680
|
}
|
|
9653
9681
|
|
|
9654
9682
|
private func restoreFocus(_ focused: UIView?) {
|
|
9683
|
+
guard !isLeavingScreen else { return }
|
|
9655
9684
|
guard let focused, focused.window != nil, !focused.isFirstResponder,
|
|
9656
9685
|
lastFocusedInput === focused, firstResponder(in: rootStack) == nil else { return }
|
|
9657
9686
|
var ancestor: UIView? = focused
|
|
@@ -9862,7 +9891,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9862
9891
|
updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: field)
|
|
9863
9892
|
updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
|
|
9864
9893
|
if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
|
|
9865
|
-
DispatchQueue.main.async {
|
|
9894
|
+
DispatchQueue.main.async { [weak self] in
|
|
9895
|
+
guard self?.isLeavingScreen == false else { return }
|
|
9896
|
+
_ = field.becomeFirstResponder()
|
|
9897
|
+
}
|
|
9866
9898
|
}
|
|
9867
9899
|
case "SegmentedControl":
|
|
9868
9900
|
let control = result as! CraftNativeSegmentedControl
|
|
@@ -9982,7 +10014,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9982
10014
|
if !visible, wasVisible == true {
|
|
9983
10015
|
let restore = modalFocusRestore.removeValue(forKey: modalID)
|
|
9984
10016
|
DispatchQueue.main.async { [weak self, weak restore] in
|
|
9985
|
-
guard let self, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
|
|
10017
|
+
guard let self, !self.isLeavingScreen, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
|
|
9986
10018
|
self.lastFocusedInput = restore
|
|
9987
10019
|
_ = restore.becomeFirstResponder()
|
|
9988
10020
|
}
|
|
@@ -11185,7 +11217,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11185
11217
|
updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: textView)
|
|
11186
11218
|
updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: textView)
|
|
11187
11219
|
if props["autoFocus"] as? Bool == true, !textView.isFirstResponder {
|
|
11188
|
-
DispatchQueue.main.async {
|
|
11220
|
+
DispatchQueue.main.async { [weak self] in
|
|
11221
|
+
guard self?.isLeavingScreen == false else { return }
|
|
11222
|
+
_ = textView.becomeFirstResponder()
|
|
11223
|
+
}
|
|
11189
11224
|
}
|
|
11190
11225
|
}
|
|
11191
11226
|
|