Skip to content

Commit cec48df

Browse files
hyperpolymathclaude
andcommitted
feat: Gossamer migration — RuntimeBridge, gossamer.conf.json, Tauri→Gossamer conversion
Added Gossamer configuration and RuntimeBridge alongside existing Tauri setup. Tauri files preserved for transition period. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fd9324d commit cec48df

File tree

3 files changed

+375
-0
lines changed

3 files changed

+375
-0
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
{
2+
"$schema": "https://gossamer.dev/schemas/config/v1",
3+
"productName": "Formatrix Docs",
4+
"version": "0.1.0",
5+
"identifier": "com.hyperpolymath.formatrix-docs",
6+
"build": {
7+
"frontendDist": "../../ui",
8+
"devUrl": "http://localhost:1420",
9+
"beforeDevCommand": "cd ../../ui && deno task build:res",
10+
"beforeBuildCommand": "cd ../../ui && deno task build:res"
11+
},
12+
"app": {
13+
"windows": [
14+
{
15+
"label": "main",
16+
"title": "Formatrix Docs",
17+
"width": 1200,
18+
"height": 800,
19+
"minWidth": 800,
20+
"minHeight": 600,
21+
"maxWidth": null,
22+
"maxHeight": null,
23+
"resizable": true,
24+
"fullscreen": false,
25+
"decorations": true,
26+
"transparent": false,
27+
"center": true,
28+
"alwaysOnTop": false,
29+
"visible": true,
30+
"url": "/"
31+
}
32+
],
33+
"security": {
34+
"csp": null,
35+
"capabilities": [
36+
"filesystem",
37+
"network",
38+
"shell",
39+
"clipboard"
40+
],
41+
"capabilityTokens": {
42+
"enabled": false,
43+
"issuer": "gossamer-runtime",
44+
"ttl": 3600
45+
},
46+
"sandbox": {
47+
"enabled": true,
48+
"allowExec": false,
49+
"allowNetwork": true,
50+
"allowFilesystem": true
51+
}
52+
},
53+
"ipc": {
54+
"protocol": "json",
55+
"bridgeInjection": true,
56+
"maxMessageSize": 16777216,
57+
"timeout": 30000
58+
},
59+
"tray": {
60+
"enabled": false,
61+
"icon": null,
62+
"tooltip": null,
63+
"menuOnLeftClick": true
64+
}
65+
},
66+
"plugins": {
67+
"dialog": {
68+
"all": true
69+
},
70+
"fs": {
71+
"all": true,
72+
"scope": ["$HOME/**", "$DOCUMENT/**", "$DOWNLOAD/**"]
73+
},
74+
"shell": {
75+
"all": false,
76+
"open": true
77+
}
78+
},
79+
"bundle": {
80+
"active": true,
81+
"targets": [
82+
"deb",
83+
"appimage",
84+
"dmg",
85+
"nsis"
86+
],
87+
"icon": [
88+
"icons/32x32.png",
89+
"icons/128x128.png",
90+
"icons/128x128@2x.png",
91+
"icons/icon.icns",
92+
"icons/icon.ico"
93+
],
94+
"resources": [],
95+
"copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)",
96+
"license": "PMPL-1.0-or-later",
97+
"shortDescription": "Cross-platform document editor with format tabs",
98+
"longDescription": "View and edit documents in multiple markup formats (TXT, MD, ADOC, DJOT, ORG, RST, TYP) with a unified AST for format conversion.",
99+
"linux": {
100+
"desktopEntry": true,
101+
"category": "Office",
102+
"section": "text",
103+
"depends": [],
104+
"appimage": {
105+
"bundleMediaFramework": true
106+
}
107+
},
108+
"macos": {
109+
"bundleIdentifier": "com.hyperpolymath.formatrix-docs",
110+
"minimumSystemVersion": "10.15",
111+
"frameworks": [],
112+
"entitlements": null,
113+
"signingIdentity": null,
114+
"notarization": {
115+
"enabled": false,
116+
"teamId": null
117+
}
118+
},
119+
"windows": {
120+
"webview2": "embed",
121+
"certificateThumbprint": null,
122+
"wix": {
123+
"language": "en-US"
124+
},
125+
"nsis": {
126+
"displayLanguageSelector": false,
127+
"installerIcon": null
128+
}
129+
},
130+
"mobile": {
131+
"ios": {
132+
"minimumVersion": "14.0",
133+
"deviceFamily": [
134+
"iphone",
135+
"ipad"
136+
],
137+
"capabilities": [],
138+
"frameworks": [],
139+
"entitlements": {}
140+
},
141+
"android": {
142+
"minimumSdk": 24,
143+
"targetSdk": 34,
144+
"permissions": [],
145+
"features": []
146+
}
147+
}
148+
},
149+
"ephapax": {
150+
"modules": [],
151+
"region": "app",
152+
"linearVerification": true,
153+
"regionBoundary": "strict",
154+
"preload": []
155+
}
156+
}

ui/deno.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"@tauri-apps/api/": "npm:@tauri-apps/api@2/",
77
"@tauri-apps/plugin-dialog": "npm:@tauri-apps/plugin-dialog@2/",
88
"@tauri-apps/plugin-fs": "npm:@tauri-apps/plugin-fs@2/",
9+
"@gossamer/api": "npm:@gossamer/api@^1",
910
"codemirror": "npm:codemirror@6/",
1011
"@codemirror/view": "npm:@codemirror/view@6/",
1112
"@codemirror/state": "npm:@codemirror/state@6/",
@@ -21,6 +22,8 @@
2122
"watch:res": "deno run -A npm:rescript@11 build -w",
2223
"dev": "deno task watch:res & cd ../crates/formatrix-gui && cargo tauri dev",
2324
"build": "deno task build:res && cd ../crates/formatrix-gui && cargo tauri build",
25+
"dev:gossamer": "deno task watch:res & cd ../crates/formatrix-gui && gossamer dev",
26+
"build:gossamer": "deno task build:res && cd ../crates/formatrix-gui && gossamer build",
2427
"fmt": "deno fmt",
2528
"lint": "deno lint",
2629
"clean": "rm -rf lib src/*.res.js src/**/*.res.js node_modules",

ui/src/RuntimeBridge.res

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

0 commit comments

Comments
 (0)