Skip to content

Commit 0fa85ca

Browse files
chore: embed original ReScript in .affine skeletons for easier porting (#57)
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> --> ## Summary <!-- What does this PR do, and why? --> Closes # ## Type of change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 💥 Breaking change (would change existing behaviour) - [ ] 🕳️ Soundness fix (fixes a checker/proof false-negative) - [ ] 📖 Documentation - [ ] 🧹 Refactor / tech debt (behaviour-preserving) - [ ] ⚡ Performance - [ ] 🔧 Build / CI / tooling ## How has this been verified? <!-- Establish ground truth: which tool did you RUN, and what did it report? Don't cite a status doc — cite the command and its output. --> ## Checklist - [ ] My commits are **signed** (`git commit -S`). - [ ] I ran the project's own checks/tests locally and they pass. - [ ] New files carry the correct `SPDX-License-Identifier` (code/config `MPL-2.0`, prose `CC-BY-SA-4.0`); I did not relicense existing files. - [ ] Docs are updated, and no public claim now overstates what the code does. - [ ] I have not introduced a soundness hole (or I have flagged where I might have). ## Notes for reviewers <!-- Anything that needs special attention, follow-up, or context. -->
1 parent 8ae7496 commit 0fa85ca

7 files changed

Lines changed: 1186 additions & 0 deletions

‎src/App.affine‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,37 @@
55
module App;
66

77
// TODO: Complete semantic implementation
8+
9+
10+
/* === ORIGINAL RESCRIPT IMPLEMENTATION ===
11+
// SPDX-License-Identifier: MPL-2.0
12+
// DotMatrix-FilePrinter - App module
13+
// Exports functions for the UI layer
14+
// Uses proven library for formally verified safety operations
15+
16+
// Re-export bindings for JavaScript consumption
17+
let checkGforth = Bindings.checkGforth
18+
let previewStrike = Bindings.previewStrike
19+
let executeStrike = Bindings.executeStrike
20+
let verifySubstrate = Bindings.verifySubstrate
21+
22+
// Utilities (powered by proven library)
23+
let stringToBytes = Bindings.stringToBytes
24+
let bytesToString = Bindings.bytesToString
25+
let parseByteString = Bindings.parseByteString
26+
let isValidByte = Bindings.isValidByte
27+
let isValidPath = Bindings.isValidPath
28+
let bytesToHex = Bindings.bytesToHex
29+
let bytesToHexCompact = Bindings.bytesToHexCompact
30+
let hexToBytes = Bindings.hexToBytes
31+
32+
// For hex input mode
33+
let decodeHex = Bindings.hexToBytes
34+
35+
// Constraint values for UI
36+
let maxByte = Types.Constraints.maxByte
37+
let forbiddenNbsp = Types.Constraints.forbiddenNbsp
38+
let forbiddenUtf8 = Types.Constraints.forbiddenUtf8
39+
let forbiddenBytes = Types.Constraints.forbiddenBytes
40+
41+
======================================== */

‎src/Bindings.affine‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,134 @@
55
module Bindings;
66

77
// TODO: Complete semantic implementation
8+
9+
10+
/* === ORIGINAL RESCRIPT IMPLEMENTATION ===
11+
// SPDX-License-Identifier: MPL-2.0
12+
// Backend FFI Bindings for DotMatrix-FilePrinter
13+
// Uses proven library for formally verified safety operations
14+
// Uses RuntimeBridge for Gossamer/Tauri/browser dispatch
15+
16+
// Core backend API — delegates to RuntimeBridge for runtime detection
17+
let invoke = RuntimeBridge.invoke
18+
19+
// Re-export types
20+
type contaminant = Types.contaminant
21+
type previewResult = Types.previewResult
22+
type verifyResult = Types.verifyResult
23+
24+
// Commands - these call the Rust backend
25+
26+
// Check if gforth is available
27+
let checkGforth = (): promise<bool> => {
28+
invoke("check_gforth", ())
29+
}
30+
31+
// Preview strike (dry-run)
32+
let previewStrike = (bytes: array<int>): promise<previewResult> => {
33+
invoke("preview_forth_strike", {"bytes": bytes})
34+
}
35+
36+
// Helper to create a rejected promise with an error message
37+
let rejectWithMessage: string => promise<'a> = %raw(`
38+
function(msg) { return Promise.reject(new Error(msg)); }
39+
`)
40+
41+
// Execute strike via Forth kernel (with path validation using SafePath)
42+
let executeStrike = (bytes: array<int>, path: string): promise<unit> => {
43+
// Validate path doesn't contain traversal attacks
44+
if !Proven_SafePath.isSafe(path) {
45+
rejectWithMessage("Invalid path: contains traversal sequences")
46+
} else {
47+
invoke("execute_forth_strike", {"bytes": bytes, "path": path})
48+
}
49+
}
50+
51+
// Verify substrate file (with path validation using SafePath)
52+
let verifySubstrate = (path: string): promise<verifyResult> => {
53+
if !Proven_SafePath.isSafe(path) {
54+
rejectWithMessage("Invalid path: contains traversal sequences")
55+
} else {
56+
invoke("verify_substrate", {"path": path})
57+
}
58+
}
59+
60+
// Utilities using proven library
61+
62+
// Convert string to byte array using SafeString.toCodePoints
63+
let stringToBytes = (str: string): array<int> => {
64+
switch Proven_SafeString.toCodePoints(str) {
65+
| Ok(bytes) => bytes
66+
| Error(_) => [] // Return empty on non-ASCII input
67+
}
68+
}
69+
70+
// Convert byte array to string using SafeString.fromCodePoints
71+
let bytesToString = (bytes: array<int>): string => {
72+
switch Proven_SafeString.fromCodePoints(bytes) {
73+
| Ok(str) => str
74+
| Error(_) => "" // Return empty on invalid code points
75+
}
76+
}
77+
78+
// Validate a single byte (uses SafeMath via Types.Constraints)
79+
let isValidByte = (byte: int): bool => {
80+
Types.Constraints.isValidByte(byte)
81+
}
82+
83+
// Validate path safety using SafePath
84+
let isValidPath = (path: string): bool => {
85+
Proven_SafePath.isSafe(path)
86+
}
87+
88+
// Parse comma-separated bytes using SafeMath.fromString
89+
let parseByteString = (str: string): result<array<int>, string> => {
90+
let parts = str
91+
->String.trim
92+
->String.split(",")
93+
->Array.map(String.trim)
94+
->Array.filter(s => s->String.length > 0)
95+
96+
// Use SafeMath.fromString for safe integer parsing
97+
let bytes = parts->Array.map(s => Proven_SafeMath.fromString(s))
98+
99+
if bytes->Array.some(Option.isNone) {
100+
Error("Invalid byte value")
101+
} else {
102+
let values = bytes->Array.filterMap(x => x)
103+
let invalid = values->Array.findIndex(b => !isValidByte(b))
104+
if invalid >= 0 {
105+
Error(`Byte at position ${Int.toString(invalid)} is invalid`)
106+
} else {
107+
Ok(values)
108+
}
109+
}
110+
}
111+
112+
// Format bytes as spaced hex using SafeHex.encodeSpaced
113+
let bytesToHex = (bytes: array<int>): string => {
114+
switch Proven_SafeHex.encodeSpaced(bytes) {
115+
| Ok(hex) => hex
116+
| Error(_) => "" // Return empty on invalid bytes
117+
}
118+
}
119+
120+
// Format bytes as compact hex (no spaces) using SafeHex.encode
121+
let bytesToHexCompact = (bytes: array<int>): string => {
122+
switch Proven_SafeHex.encode(bytes) {
123+
| Ok(hex) => hex
124+
| Error(_) => ""
125+
}
126+
}
127+
128+
// Decode hex string to bytes using SafeHex.decode
129+
let hexToBytes = (hexStr: string): result<array<int>, string> => {
130+
switch Proven_SafeHex.decode(hexStr) {
131+
| Ok(bytes) => Ok(bytes)
132+
| Error(Proven_SafeHex.InvalidLength) => Error("Invalid hex length (must be even)")
133+
| Error(Proven_SafeHex.InvalidCharacter) => Error("Invalid hex character")
134+
| Error(Proven_SafeHex.EmptyInput) => Ok([])
135+
}
136+
}
137+
138+
======================================== */

‎src/RuntimeBridge.affine‎

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,215 @@
55
module RuntimeBridge;
66

77
// TODO: Complete semantic implementation
8+
9+
10+
/* === ORIGINAL RESCRIPT IMPLEMENTATION ===
11+
// SPDX-License-Identifier: MPL-2.0
12+
13+
/// RuntimeBridge — Unified IPC bridge for DotMatrix-FilePrinter.
14+
///
15+
/// Detects the available runtime (Gossamer, Tauri, or browser-only) and
16+
/// dispatches `invoke` calls to the appropriate backend. This allows all
17+
/// command modules to use a single import instead of binding directly
18+
/// to `@tauri-apps/api/core`.
19+
///
20+
/// Priority order:
21+
/// 1. Gossamer (`window.__gossamer_invoke`) — own stack, preferred
22+
/// 2. Tauri (`window.__TAURI_INTERNALS__`) — legacy, transition
23+
/// 3. Browser (direct HTTP fetch) — development fallback
24+
///
25+
/// Migration path: Bindings.res replaces
26+
/// `@module("@tauri-apps/api/core") external invoke: ...`
27+
/// with
28+
/// `let invoke = RuntimeBridge.invoke`
29+
30+
// ---------------------------------------------------------------------------
31+
// Raw external bindings — exactly one of these will be available at runtime
32+
// ---------------------------------------------------------------------------
33+
34+
/// Gossamer IPC: injected by gossamer_channel_open() into the webview.
35+
%%raw(`
36+
function isGossamerRuntime() {
37+
return typeof window !== 'undefined'
38+
&& typeof window.__gossamer_invoke === 'function';
39+
}
40+
`)
41+
@val external isGossamerRuntime: unit => bool = "isGossamerRuntime"
42+
43+
%%raw(`
44+
function gossamerInvoke(cmd, args) {
45+
return window.__gossamer_invoke(cmd, args);
46+
}
47+
`)
48+
@val external gossamerInvoke: (string, 'a) => promise<'b> = "gossamerInvoke"
49+
50+
/// Tauri IPC: injected by the Tauri runtime into the webview.
51+
%%raw(`
52+
function isTauriRuntime() {
53+
return typeof window !== 'undefined'
54+
&& window.__TAURI_INTERNALS__ != null
55+
&& !window.__TAURI_INTERNALS__.__BROWSER_SHIM__;
56+
}
57+
`)
58+
@val external isTauriRuntime: unit => bool = "isTauriRuntime"
59+
60+
@module("@tauri-apps/api/core")
61+
external tauriInvoke: (string, 'a) => promise<'b> = "invoke"
62+
63+
// ---------------------------------------------------------------------------
64+
// Unified invoke — detects runtime and dispatches
65+
// ---------------------------------------------------------------------------
66+
67+
/// The runtime currently in use. Cached after first detection for performance.
68+
type runtime =
69+
| Gossamer
70+
| Tauri
71+
| BrowserOnly
72+
73+
%%raw(`
74+
var _detectedRuntime = null;
75+
function detectRuntime() {
76+
if (_detectedRuntime !== null) return _detectedRuntime;
77+
if (typeof window !== 'undefined' && typeof window.__gossamer_invoke === 'function') {
78+
_detectedRuntime = 'gossamer';
79+
} else if (typeof window !== 'undefined' && window.__TAURI_INTERNALS__ != null && !window.__TAURI_INTERNALS__.__BROWSER_SHIM__) {
80+
_detectedRuntime = 'tauri';
81+
} else {
82+
_detectedRuntime = 'browser';
83+
}
84+
return _detectedRuntime;
85+
}
86+
`)
87+
@val external detectRuntimeRaw: unit => string = "detectRuntime"
88+
89+
/// Detect and return the current runtime.
90+
let detectRuntime = (): runtime => {
91+
switch detectRuntimeRaw() {
92+
| "gossamer" => Gossamer
93+
| "tauri" => Tauri
94+
| _ => BrowserOnly
95+
}
96+
}
97+
98+
/// Invoke a backend command through whatever runtime is available.
99+
///
100+
/// - On Gossamer: calls `window.__gossamer_invoke(cmd, args)`
101+
/// - On Tauri: calls `window.__TAURI_INTERNALS__.invoke(cmd, args)`
102+
/// - On browser: rejects with a descriptive error
103+
///
104+
/// This is the primary function all command modules should use.
105+
let invoke = (cmd: string, args: 'a): promise<'b> => {
106+
if isGossamerRuntime() {
107+
gossamerInvoke(cmd, args)
108+
} else if isTauriRuntime() {
109+
tauriInvoke(cmd, args)
110+
} else {
111+
Promise.reject(
112+
JsError.throwWithMessage(
113+
`No desktop runtime — "${cmd}" requires Gossamer or Tauri`,
114+
),
115+
)
116+
}
117+
}
118+
119+
/// Check whether any desktop runtime is available.
120+
let hasDesktopRuntime = (): bool => {
121+
isGossamerRuntime() || isTauriRuntime()
122+
}
123+
124+
/// Get a human-readable name for the current runtime.
125+
let runtimeName = (): string => {
126+
switch detectRuntime() {
127+
| Gossamer => "Gossamer"
128+
| Tauri => "Tauri"
129+
| BrowserOnly => "Browser"
130+
}
131+
}
132+
133+
// ---------------------------------------------------------------------------
134+
// Dialog abstraction — Gossamer dialogs vs Tauri plugin-dialog
135+
// ---------------------------------------------------------------------------
136+
137+
module Dialog = {
138+
/// Gossamer file dialog: calls gossamer_dialog_open via IPC.
139+
/// Tauri file dialog: calls @tauri-apps/plugin-dialog.
140+
@module("@tauri-apps/plugin-dialog")
141+
external tauriOpenRaw: JSON.t => promise<Nullable.t<JSON.t>> = "open"
142+
143+
@module("@tauri-apps/plugin-dialog")
144+
external tauriSaveRaw: JSON.t => promise<Nullable.t<JSON.t>> = "save"
145+
146+
/// Open a file picker dialog.
147+
let \"open" = (opts: JSON.t): promise<Nullable.t<JSON.t>> => {
148+
if isGossamerRuntime() {
149+
gossamerInvoke("__gossamer_dialog_open", opts)
150+
} else if isTauriRuntime() {
151+
tauriOpenRaw(opts)
152+
} else {
153+
Promise.reject(
154+
JsError.throwWithMessage(
155+
"No desktop runtime — file dialogs require Gossamer or Tauri",
156+
),
157+
)
158+
}
159+
}
160+
161+
/// Open a save dialog.
162+
let save = (opts: JSON.t): promise<Nullable.t<JSON.t>> => {
163+
if isGossamerRuntime() {
164+
gossamerInvoke("__gossamer_dialog_save", opts)
165+
} else if isTauriRuntime() {
166+
tauriSaveRaw(opts)
167+
} else {
168+
Promise.reject(
169+
JsError.throwWithMessage(
170+
"No desktop runtime — save dialogs require Gossamer or Tauri",
171+
),
172+
)
173+
}
174+
}
175+
}
176+
177+
// ---------------------------------------------------------------------------
178+
// Filesystem abstraction — Gossamer fs vs Tauri plugin-fs
179+
// ---------------------------------------------------------------------------
180+
181+
module Fs = {
182+
@module("@tauri-apps/plugin-fs")
183+
external tauriReadTextFileRaw: string => promise<string> = "readTextFile"
184+
185+
@module("@tauri-apps/plugin-fs")
186+
external tauriWriteTextFileRaw: (string, string) => promise<unit> = "writeTextFile"
187+
188+
/// Read a text file from the local filesystem.
189+
let readTextFile = (path: string): promise<string> => {
190+
if isGossamerRuntime() {
191+
gossamerInvoke("__gossamer_fs_read_text", {"path": path})
192+
} else if isTauriRuntime() {
193+
tauriReadTextFileRaw(path)
194+
} else {
195+
Promise.reject(
196+
JsError.throwWithMessage(
197+
"No desktop runtime — filesystem access requires Gossamer or Tauri",
198+
),
199+
)
200+
}
201+
}
202+
203+
/// Write a text file to the local filesystem.
204+
let writeTextFile = (path: string, contents: string): promise<unit> => {
205+
if isGossamerRuntime() {
206+
gossamerInvoke("__gossamer_fs_write_text", {"path": path, "contents": contents})
207+
} else if isTauriRuntime() {
208+
tauriWriteTextFileRaw(path, contents)
209+
} else {
210+
Promise.reject(
211+
JsError.throwWithMessage(
212+
"No desktop runtime — filesystem access requires Gossamer or Tauri",
213+
),
214+
)
215+
}
216+
}
217+
}
218+
219+
======================================== */

0 commit comments

Comments
 (0)