@astryxdesign/native 0.7.5-canary.9a5909c → 0.7.5-canary.a8ef89f
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
CHANGED
|
@@ -21,6 +21,7 @@ rests on evidence.
|
|
|
21
21
|
| Icons | `NATIVE_ICON_DEFAULTS` | Astryx's built-in icon names mapped to SF Symbols on Apple platforms and Material Symbols on Android. |
|
|
22
22
|
| Wire | `AstryxNode`, `AstryxAction` | Each component as JSON a TypeScript core sends a native host (arm C), and the action a host sends back when a view calls a callback. Swift decodes `<Name>Node` and turns it into `<Name>Props` with `viewProps(send:)`. |
|
|
23
23
|
| Logic core | `@astryxdesign/native/logic` | `createLogicCore` holds a screen's state, builds its node tree, and reduces actions; `connectBridge` carries it to a native host as JSON. ECMAScript built-ins only, so it runs in Hermes. Shaped after EJ Campbell's astryx-swift spike. |
|
|
24
|
+
| Logic host | `AstryxLogicHost` (Swift) | Runs a logic core bundle in JavaScriptCore, decodes each tree into `AstryxNode`, and sends views' actions back. A Hermes host speaks the same bridge. |
|
|
24
25
|
|
|
25
26
|
Contracts cover Badge, Button, Card, Divider, Heading, HStack, ProgressBar,
|
|
26
27
|
Selector, Switch, Text, TextInput, and VStack. React Native and Android
|
|
@@ -90,14 +91,17 @@ const action: AstryxAction = {
|
|
|
90
91
|
```
|
|
91
92
|
|
|
92
93
|
```swift
|
|
93
|
-
let
|
|
94
|
-
if case .switch(let wifi) =
|
|
95
|
-
let props = wifi.viewProps
|
|
94
|
+
let host = AstryxLogicHost(bundle: bundleSource) // calls the bundle's global astryxStart(host)
|
|
95
|
+
if case .switch(let wifi) = host.root {
|
|
96
|
+
let props = wifi.viewProps(send: host.send)
|
|
96
97
|
}
|
|
97
98
|
```
|
|
98
99
|
|
|
99
100
|
```ts
|
|
101
|
+
// tsconfig: "customConditions": ["ios"]
|
|
102
|
+
import type {AstryxAction, AstryxNode} from '@astryxdesign/native/components';
|
|
100
103
|
import {connectBridge, createLogicCore} from '@astryxdesign/native/logic';
|
|
104
|
+
import type {Bridge, BridgeHost} from '@astryxdesign/native/logic';
|
|
101
105
|
|
|
102
106
|
const core = createLogicCore<{wifi: boolean}, AstryxNode, AstryxAction>({
|
|
103
107
|
initial: {wifi: true},
|
|
@@ -112,13 +116,10 @@ const core = createLogicCore<{wifi: boolean}, AstryxNode, AstryxAction>({
|
|
|
112
116
|
? {wifi: action.args[0]}
|
|
113
117
|
: state,
|
|
114
118
|
});
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
/* to Swift */
|
|
120
|
-
},
|
|
121
|
-
});
|
|
119
|
+
// The bundle's entry: the native host calls it with its two calls in and
|
|
120
|
+
// gets back the bridge it reads snapshots from and dispatches actions to.
|
|
121
|
+
(globalThis as {astryxStart?: (host: BridgeHost) => Bridge}).astryxStart =
|
|
122
|
+
host => connectBridge(core, host);
|
|
122
123
|
```
|
|
123
124
|
|
|
124
125
|
## Working on it
|
|
@@ -144,8 +145,23 @@ files are ignored on `main`.
|
|
|
144
145
|
Boundaries, checked by `pnpm check:package-boundaries`:
|
|
145
146
|
|
|
146
147
|
- `@astryxdesign/core` never imports this package.
|
|
147
|
-
-
|
|
148
|
-
|
|
148
|
+
- No file in this package, in any folder, imports core, apart from the
|
|
149
|
+
build-time reads AST-082 FR16 lists until core's portable entry ships: the
|
|
150
|
+
theme generator in `scripts/` may import `@astryxdesign/core/theme` and
|
|
151
|
+
`@astryxdesign/core/theme/tokens`; `scripts/swift-ios-strings.mjs` may
|
|
152
|
+
read core's locale catalogs (`@astryxdesign/core/locales/*.json`); and
|
|
153
|
+
`scripts/core-docs.mjs` reads core's component documentation and built-in
|
|
154
|
+
icon names as data.
|
|
155
|
+
- The check reads source text in every folder except `node_modules`, `dist`,
|
|
156
|
+
`.build`, and `.swiftpm`. It flags any other module specifier that names
|
|
157
|
+
core (`import`, `require`, `require.resolve`, `import.meta.resolve`, or a
|
|
158
|
+
relative path into `packages/core`). It also flags a path to core's package,
|
|
159
|
+
written as adjacent string segments (`'@astryxdesign', 'core'`,
|
|
160
|
+
`'node_modules', '@astryxdesign/core'`, `'packages', 'core'`) or inside one
|
|
161
|
+
string (`node_modules/@astryxdesign/core`, `packages/core`). In
|
|
162
|
+
`scripts/core-docs.mjs` it allows exactly one such path, the `CORE_ROOT`
|
|
163
|
+
definition, and only the listed reads under `CORE_ROOT`. It does not follow
|
|
164
|
+
paths computed in other ways.
|
|
149
165
|
|
|
150
166
|
## Experiment matrix
|
|
151
167
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
//
|
|
3
|
+
// The host side of arm C (AST-082 FR12): a TypeScript logic core runs in a
|
|
4
|
+
// JavaScript engine inside the app and native views draw its tree. Its shape
|
|
5
|
+
// follows the ClientHost of EJ Campbell's astryx-swift spike
|
|
6
|
+
// (github.com/ejc3/astryx-swift, MIT), which runs the core in Hermes. This host
|
|
7
|
+
// uses JavaScriptCore, part of the platform SDK, so the package needs nothing
|
|
8
|
+
// else; a Hermes host speaks the same bridge.
|
|
9
|
+
|
|
10
|
+
#if os(iOS) || os(macOS)
|
|
11
|
+
import Foundation
|
|
12
|
+
import JavaScriptCore
|
|
13
|
+
import Observation
|
|
14
|
+
|
|
15
|
+
/// Runs a logic core bundle and carries its tree to native views and their
|
|
16
|
+
/// actions back, as the JSON `@astryxdesign/native/logic`'s `connectBridge`
|
|
17
|
+
/// speaks.
|
|
18
|
+
///
|
|
19
|
+
/// The bundle defines a global function, `astryxStart` by default, that takes
|
|
20
|
+
/// the host (`connected(version)` and `changed(json)`) and returns the bridge
|
|
21
|
+
/// (`getSnapshot()` and `dispatch(json)`).
|
|
22
|
+
@MainActor
|
|
23
|
+
@Observable
|
|
24
|
+
public final class AstryxLogicHost {
|
|
25
|
+
/// The bridge version this host speaks (`BRIDGE_VERSION` in TypeScript).
|
|
26
|
+
public static let bridgeVersion = 1
|
|
27
|
+
|
|
28
|
+
/// The current tree; nil before the first snapshot and once the host fails.
|
|
29
|
+
public private(set) var root: AstryxNode?
|
|
30
|
+
/// Why the core stopped or a snapshot could not be read.
|
|
31
|
+
public private(set) var failure: String?
|
|
32
|
+
|
|
33
|
+
@ObservationIgnored private let context: JSContext
|
|
34
|
+
@ObservationIgnored private var bridge: JSValue?
|
|
35
|
+
|
|
36
|
+
public init(bundle source: String, entry: String = "astryxStart") {
|
|
37
|
+
context = JSContext()
|
|
38
|
+
context.exceptionHandler = { [weak self] _, exception in
|
|
39
|
+
let message = exception?.toString() ?? "An unknown JavaScript error"
|
|
40
|
+
MainActor.assumeIsolated { self?.fail(message) }
|
|
41
|
+
}
|
|
42
|
+
context.evaluateScript(source)
|
|
43
|
+
guard failure == nil else { return }
|
|
44
|
+
guard let start = context.objectForKeyedSubscript(entry), start.isObject else {
|
|
45
|
+
fail("The bundle defines no \(entry) function")
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
let host = JSValue(newObjectIn: context)
|
|
49
|
+
let connected: @convention(block) (Int) -> Void = { [weak self] version in
|
|
50
|
+
MainActor.assumeIsolated {
|
|
51
|
+
if version != Self.bridgeVersion {
|
|
52
|
+
self?.fail("The core speaks bridge \(version); this host speaks \(Self.bridgeVersion)")
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
let changed: @convention(block) (String) -> Void = { [weak self] json in
|
|
57
|
+
MainActor.assumeIsolated { self?.receive(json) }
|
|
58
|
+
}
|
|
59
|
+
host?.setObject(connected, forKeyedSubscript: "connected" as NSString)
|
|
60
|
+
host?.setObject(changed, forKeyedSubscript: "changed" as NSString)
|
|
61
|
+
bridge = start.call(withArguments: [host as Any])
|
|
62
|
+
guard failure == nil, let snapshot = bridge?.invokeMethod("getSnapshot", withArguments: []),
|
|
63
|
+
snapshot.isString, let json = snapshot.toString() else {
|
|
64
|
+
if failure == nil { fail("The bridge returned no snapshot") }
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
receive(json)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Sends a view's action to the core: `node.viewProps(send: host.send)`.
|
|
71
|
+
/// Views call it on the main thread, where it dispatches at once; from any
|
|
72
|
+
/// other thread it dispatches on the main actor.
|
|
73
|
+
public nonisolated func send(_ action: AstryxAction) {
|
|
74
|
+
if Thread.isMainThread {
|
|
75
|
+
MainActor.assumeIsolated { dispatch(action) }
|
|
76
|
+
} else {
|
|
77
|
+
Task { @MainActor in self.dispatch(action) }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private func dispatch(_ action: AstryxAction) {
|
|
82
|
+
guard failure == nil, let bridge else { return }
|
|
83
|
+
bridge.invokeMethod("dispatch", withArguments: [action.json])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func receive(_ json: String) {
|
|
87
|
+
// A failed host stays failed: a later tree does not bring it back.
|
|
88
|
+
guard failure == nil else { return }
|
|
89
|
+
do {
|
|
90
|
+
root = try JSONDecoder().decode(AstryxNode.self, from: Data(json.utf8))
|
|
91
|
+
} catch {
|
|
92
|
+
fail("The core sent a tree this binding cannot read: \(error)")
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Stops the host: no tree to draw and no actions sent.
|
|
97
|
+
private func fail(_ message: String) {
|
|
98
|
+
failure = message
|
|
99
|
+
root = nil
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
#endif
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import Testing
|
|
4
|
+
@testable import AstryxNative
|
|
5
|
+
|
|
6
|
+
/// A logic core with the bridge `@astryxdesign/native/logic` exposes, written
|
|
7
|
+
/// out by hand so the Swift tests need no build step.
|
|
8
|
+
private let settingsBundle = """
|
|
9
|
+
function astryxStart(host) {
|
|
10
|
+
var state = {wifi: true};
|
|
11
|
+
function view() {
|
|
12
|
+
return {component: 'VStack', key: 'root', props: {children: [
|
|
13
|
+
{component: 'Switch', key: 'wifi', props: {label: 'Wi-Fi', value: state.wifi}, events: ['onChange']},
|
|
14
|
+
{component: 'Text', key: 'status', props: {children: state.wifi ? 'Connected' : 'Off'}}
|
|
15
|
+
]}};
|
|
16
|
+
}
|
|
17
|
+
host.connected(1);
|
|
18
|
+
return {
|
|
19
|
+
getSnapshot: function () { return JSON.stringify(view()); },
|
|
20
|
+
dispatch: function (json) {
|
|
21
|
+
var action = JSON.parse(json);
|
|
22
|
+
if (action.key === 'wifi' && action.event === 'onChange') {
|
|
23
|
+
state = {wifi: action.args[0]};
|
|
24
|
+
host.changed(JSON.stringify(view()));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@MainActor
|
|
32
|
+
@Test func aViewsActionReachesTheCoreAndTheNewTreeComesBack() throws {
|
|
33
|
+
let host = AstryxLogicHost(bundle: settingsBundle)
|
|
34
|
+
#expect(host.failure == nil)
|
|
35
|
+
guard case .vStack(let stack) = host.root, case .switch(let wifi) = stack.props.children?[0] else {
|
|
36
|
+
Issue.record("unexpected first tree")
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
#expect(wifi.props.value == true)
|
|
40
|
+
|
|
41
|
+
// The view calls its onChange; the action goes to the core.
|
|
42
|
+
let props = wifi.viewProps(send: host.send)
|
|
43
|
+
props.onChange?(false)
|
|
44
|
+
|
|
45
|
+
guard case .vStack(let next) = host.root, case .text(let status) = next.props.children?[1] else {
|
|
46
|
+
Issue.record("unexpected next tree")
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
#expect(status.props.children == "Off")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@MainActor
|
|
53
|
+
@Test func aLaterErrorClearsTheTreeAndStopsActions() throws {
|
|
54
|
+
let bundle = settingsBundle + "\nvar fragile = astryxStart; astryxStart = function (h) { var b = fragile(h); var d = b.dispatch; b.dispatch = function (j) { if (JSON.parse(j).args[0] === false) throw new Error('core broke'); d(j); }; return b; };"
|
|
55
|
+
let host = AstryxLogicHost(bundle: bundle)
|
|
56
|
+
guard case .vStack(let stack) = host.root, case .switch(let wifi) = stack.props.children?[0] else {
|
|
57
|
+
Issue.record("unexpected first tree")
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
wifi.viewProps(send: host.send).onChange?(false)
|
|
61
|
+
#expect(host.failure?.contains("core broke") == true)
|
|
62
|
+
#expect(host.root == nil)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@MainActor
|
|
66
|
+
@Test func aTreeAfterAFailureIsIgnored() {
|
|
67
|
+
let bundle = """
|
|
68
|
+
function astryxStart(h) {
|
|
69
|
+
h.connected(2);
|
|
70
|
+
h.changed(JSON.stringify({component: 'Text', key: 'late', props: {children: 'Too late'}}));
|
|
71
|
+
return {getSnapshot: function () { return '{}'; }, dispatch: function () {}};
|
|
72
|
+
}
|
|
73
|
+
"""
|
|
74
|
+
let host = AstryxLogicHost(bundle: bundle)
|
|
75
|
+
#expect(host.failure?.contains("bridge 2") == true)
|
|
76
|
+
#expect(host.root == nil)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@MainActor
|
|
80
|
+
@Test func aBundleWithoutTheEntryOrWithAnErrorFails() {
|
|
81
|
+
#expect(AstryxLogicHost(bundle: "var x = 1;").failure == "The bundle defines no astryxStart function")
|
|
82
|
+
#expect(AstryxLogicHost(bundle: "throw new Error('boom');").failure?.contains("boom") == true)
|
|
83
|
+
let wrongVersion = AstryxLogicHost(bundle: "function astryxStart(h) { h.connected(2); return {getSnapshot: function () { return '{}'; }}; }")
|
|
84
|
+
#expect(wrongVersion.failure?.contains("bridge 2") == true)
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/native",
|
|
3
|
-
"version": "0.7.5-canary.
|
|
3
|
+
"version": "0.7.5-canary.a8ef89f",
|
|
4
4
|
"description": "Experimental native platforms for Astryx (iOS, Android, macOS, Windows) — published to npm only under the @canary dist-tag; never released as a stable (latest) version.",
|
|
5
5
|
"author": "Meta Open Source",
|
|
6
6
|
"license": "MIT",
|
|
@@ -69,10 +69,10 @@
|
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@astryxdesign/core": "0.7.5-canary.
|
|
72
|
+
"@astryxdesign/core": "0.7.5-canary.a8ef89f"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
-
"@astryxdesign/core": "0.7.5-canary.
|
|
75
|
+
"@astryxdesign/core": "0.7.5-canary.a8ef89f"
|
|
76
76
|
},
|
|
77
77
|
"scripts": {
|
|
78
78
|
"build": "node ../../scripts/clean-dist.mjs && tsc --project tsconfig.build.json && node scripts/generate.mjs --types",
|