Add CoreGUI preload and browser bridge support

This commit is contained in:
Claude 2026-04-14 15:31:24 +01:00
parent 30a8e7edd4
commit a85d086bbd
6 changed files with 1232 additions and 12 deletions

View file

@ -37,13 +37,15 @@ type WindowInfo = window.WindowInfo
// Bridges IPC actions to WebSocket events for TypeScript apps.
type Service struct {
*core.ServiceRuntime[Options]
wailsApp *application.App
app App
configData map[string]map[string]any
configFile *config.Config // config instance for file persistence
events *WSEventManager
chat *ChatStore
schemes map[string]SchemeHandler
wailsApp *application.App
app App
configData map[string]map[string]any
configFile *config.Config // config instance for file persistence
events *WSEventManager
chat *ChatStore
browserStorage *BrowserStorageStore
viewManifest ViewManifest
schemes map[string]SchemeHandler
}
// NewService returns a display Service with empty config sections.
@ -55,8 +57,9 @@ func NewService() (*Service, error) {
"systray": {},
"menu": {},
},
chat: NewChatStore(),
schemes: make(map[string]SchemeHandler),
chat: NewChatStore(),
browserStorage: NewBrowserStorageStore(),
schemes: make(map[string]SchemeHandler),
}, nil
}
@ -1104,6 +1107,13 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
return nil, false, dirErr
}
result, handled, err = path, true, nil
case "dialog:message":
var opts dialog.MessageDialogOptions
encodedR := corego.JSONMarshal(msg.Data)
if encodedR.OK {
_ = corego.JSONUnmarshal(encodedR.Value.([]byte), &opts)
}
result, handled, err = s.Core().PERFORM(dialog.TaskMessageDialog{Options: opts})
case "dialog:confirm":
title, e := wsRequire(msg.Data, "title")
if e != nil {
@ -1210,6 +1220,8 @@ func (s *Service) loadConfigFrom(path string) {
}
s.chat.Load(configFile)
s.browserStorage.Load(configFile)
s.loadViewManifest(viewManifestPath(path))
}
func (s *Service) handleConfigQuery(c *core.Core, q core.Query) (any, bool, error) {
@ -1480,6 +1492,9 @@ func (s *Service) CreateWindow(options CreateWindowOptions) (*window.WindowInfo,
return nil, err
}
info := result.(window.WindowInfo)
if injectErr := s.InjectWindowPreload(options.Name, deriveOrigin(options.URL)); injectErr != nil && s.windowService() != nil {
return nil, injectErr
}
return &info, nil
}

976
pkg/display/preload.go Normal file
View file

@ -0,0 +1,976 @@
package display
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"forge.lthn.ai/core/config"
coreerr "forge.lthn.ai/core/go-log"
"gopkg.in/yaml.v3"
)
const browserStorageConfigSection = "browser_storage"
// PreloadWindow accepts injected JavaScript before app-specific code runs.
// Use: _ = svc.InjectPreload(windowHandle, "https://app.example.com")
type PreloadWindow interface {
ExecJS(script string)
}
// ViewManifest describes app-specific preload scripts loaded from .core/view.yaml.
type ViewManifest struct {
Preloads []ViewPreload `yaml:"preloads" json:"preloads"`
}
// ViewPreload matches an origin and appends a preload script during injection.
type ViewPreload struct {
Origin string `yaml:"origin" json:"origin"`
Match string `yaml:"match" json:"match"`
Script string `yaml:"script" json:"script"`
}
// BrowserCookie mirrors document.cookie state for one origin.
type BrowserCookie struct {
Name string `json:"name"`
Value string `json:"value"`
Path string `json:"path,omitempty"`
Domain string `json:"domain,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Secure bool `json:"secure,omitempty"`
}
// OriginStorageState stores browser-like storage namespaces for a single origin.
type OriginStorageState struct {
LocalStorage map[string]string `json:"local_storage,omitempty"`
SessionStorage map[string]string `json:"session_storage,omitempty"`
Cookies []BrowserCookie `json:"cookies,omitempty"`
IndexedDB map[string]map[string]string `json:"indexed_db,omitempty"`
CacheStorage map[string]string `json:"cache_storage,omitempty"`
StorageBuckets map[string]map[string]string `json:"storage_buckets,omitempty"`
OPFS map[string]string `json:"opfs,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// BrowserStorageSnapshot persists all origin-scoped storage namespaces.
type BrowserStorageSnapshot struct {
Origins map[string]OriginStorageState `json:"origins"`
}
// BrowserStoragePolyfillState is the JSON payload injected into preload scripts.
type BrowserStoragePolyfillState struct {
Origin string `json:"origin"`
LocalStorage map[string]string `json:"localStorage"`
SessionStorage map[string]string `json:"sessionStorage"`
Cookies []BrowserCookie `json:"cookies"`
IndexedDB map[string]map[string]string `json:"indexedDB"`
CacheStorage map[string]string `json:"cacheStorage"`
StorageBuckets map[string]map[string]string `json:"storageBuckets"`
OPFS map[string]string `json:"opfs"`
}
// BrowserStorageStore keeps origin-scoped browser data searchable from Go.
type BrowserStorageStore struct {
mu sync.RWMutex
origins map[string]OriginStorageState
}
func NewBrowserStorageStore() *BrowserStorageStore {
return &BrowserStorageStore{
origins: make(map[string]OriginStorageState),
}
}
func (s *BrowserStorageStore) Load(cfg *config.Config) {
if cfg == nil {
return
}
var snapshot BrowserStorageSnapshot
if err := cfg.Get(browserStorageConfigSection, &snapshot); err != nil {
return
}
if snapshot.Origins == nil {
snapshot.Origins = make(map[string]OriginStorageState)
}
s.mu.Lock()
defer s.mu.Unlock()
s.origins = make(map[string]OriginStorageState, len(snapshot.Origins))
for origin, state := range snapshot.Origins {
s.origins[normalizeOrigin(origin)] = cloneOriginStorageState(state)
}
}
func (s *BrowserStorageStore) Snapshot() BrowserStorageSnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return s.snapshotLocked()
}
func (s *BrowserStorageStore) snapshotLocked() BrowserStorageSnapshot {
snapshot := BrowserStorageSnapshot{
Origins: make(map[string]OriginStorageState, len(s.origins)),
}
for origin, state := range s.origins {
snapshot.Origins[origin] = cloneOriginStorageState(state)
}
return snapshot
}
func (s *BrowserStorageStore) persist(cfg *config.Config) error {
if cfg == nil {
return nil
}
if err := cfg.Set(browserStorageConfigSection, s.Snapshot()); err != nil {
return err
}
return cfg.Commit()
}
func (s *BrowserStorageStore) SaveOrigin(origin string, state OriginStorageState) {
s.mu.Lock()
defer s.mu.Unlock()
origin = normalizeOrigin(origin)
state = cloneOriginStorageState(state)
if state.UpdatedAt.IsZero() {
state.UpdatedAt = time.Now().UTC()
}
s.origins[origin] = state
}
func (s *BrowserStorageStore) Origin(origin string) (OriginStorageState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
state, ok := s.origins[normalizeOrigin(origin)]
if !ok {
return OriginStorageState{}, false
}
return cloneOriginStorageState(state), true
}
func (s *BrowserStorageStore) PolyfillState(origin string) BrowserStoragePolyfillState {
s.mu.RLock()
defer s.mu.RUnlock()
state := cloneOriginStorageState(s.origins[normalizeOrigin(origin)])
return BrowserStoragePolyfillState{
Origin: normalizeOrigin(origin),
LocalStorage: cloneStringMap(state.LocalStorage),
SessionStorage: cloneStringMap(state.SessionStorage),
Cookies: append([]BrowserCookie(nil), state.Cookies...),
IndexedDB: cloneNestedStringMap(state.IndexedDB),
CacheStorage: cloneStringMap(state.CacheStorage),
StorageBuckets: cloneNestedStringMap(state.StorageBuckets),
OPFS: cloneStringMap(state.OPFS),
}
}
func (s *BrowserStorageStore) Search(query string) []StoreSearchResult {
s.mu.RLock()
defer s.mu.RUnlock()
needle := strings.ToLower(strings.TrimSpace(query))
var results []StoreSearchResult
for origin, state := range s.origins {
for key, value := range state.LocalStorage {
if matchesStorageQuery(needle, key, value) {
results = append(results, newStorageSearchResult(origin, "localStorage", key, value, state.UpdatedAt))
}
}
for key, value := range state.SessionStorage {
if matchesStorageQuery(needle, key, value) {
results = append(results, newStorageSearchResult(origin, "sessionStorage", key, value, state.UpdatedAt))
}
}
for _, cookie := range state.Cookies {
if matchesStorageQuery(needle, cookie.Name, cookie.Value) {
results = append(results, newStorageSearchResult(origin, "cookie", cookie.Name, cookie.Value, state.UpdatedAt))
}
}
for database, values := range state.IndexedDB {
for key, value := range values {
if matchesStorageQuery(needle, database+"."+key, value) {
results = append(results, newStorageSearchResult(origin, "indexedDB", database+"."+key, value, state.UpdatedAt))
}
}
}
for key, value := range state.CacheStorage {
if matchesStorageQuery(needle, key, value) {
results = append(results, newStorageSearchResult(origin, "cacheStorage", key, value, state.UpdatedAt))
}
}
for bucket, values := range state.StorageBuckets {
for key, value := range values {
if matchesStorageQuery(needle, bucket+"."+key, value) {
results = append(results, newStorageSearchResult(origin, "storageBucket", bucket+"."+key, value, state.UpdatedAt))
}
}
}
for path, value := range state.OPFS {
if matchesStorageQuery(needle, path, value) {
results = append(results, newStorageSearchResult(origin, "opfs", path, value, state.UpdatedAt))
}
}
}
sort.Slice(results, func(i, j int) bool {
return results[i].UpdatedAt.After(results[j].UpdatedAt)
})
return results
}
func (s *Service) SaveBrowserStorageState(origin string, state OriginStorageState) error {
if s.browserStorage == nil {
s.browserStorage = NewBrowserStorageStore()
}
s.browserStorage.SaveOrigin(origin, state)
return s.browserStorage.persist(s.configFile)
}
func (s *Service) BrowserStorageState(origin string) (OriginStorageState, bool) {
if s.browserStorage == nil {
return OriginStorageState{}, false
}
return s.browserStorage.Origin(origin)
}
// PreloadScript composes storage, Electron, and local-ML shims for an origin.
// Use: script, _ := svc.PreloadScript("https://app.example.com")
func (s *Service) PreloadScript(origin string) (string, error) {
if s.browserStorage == nil {
s.browserStorage = NewBrowserStorageStore()
}
storageScript, err := buildStoragePolyfillScript(s.browserStorage.PolyfillState(origin))
if err != nil {
return "", err
}
scripts := []string{
storageScript,
buildCoreMLScript(),
buildElectronShimScript(),
}
scripts = append(scripts, s.appPreloadScripts(origin)...)
return strings.Join(scripts, "\n"), nil
}
// InjectPreload installs the composed preload script into a live window handle.
// Use: _ = svc.InjectPreload(windowHandle, "https://app.example.com")
func (s *Service) InjectPreload(target PreloadWindow, origin string) error {
if target == nil {
return coreerr.E("display.InjectPreload", "preload target is required", nil)
}
script, err := s.PreloadScript(origin)
if err != nil {
return err
}
target.ExecJS(script)
return nil
}
// InjectWindowPreload resolves a named window and installs the preload script.
// Use: _ = svc.InjectWindowPreload("main", "https://app.example.com")
func (s *Service) InjectWindowPreload(name, origin string) error {
ws := s.windowService()
if ws == nil {
return coreerr.E("display.InjectWindowPreload", "window service not available", nil)
}
target, ok := ws.Manager().Get(name)
if !ok {
return coreerr.E("display.InjectWindowPreload", "window not found: "+name, nil)
}
return s.InjectPreload(target, origin)
}
func (s *Service) loadViewManifest(path string) {
s.viewManifest = ViewManifest{}
data, err := os.ReadFile(path)
if err != nil {
return
}
var manifest ViewManifest
if err := yaml.Unmarshal(data, &manifest); err != nil {
return
}
s.viewManifest = manifest
}
func viewManifestPath(configPath string) string {
if configPath == "" {
return filepath.Join(".core", "view.yaml")
}
return filepath.Join(filepath.Dir(filepath.Dir(configPath)), "view.yaml")
}
func (s *Service) appPreloadScripts(origin string) []string {
if len(s.viewManifest.Preloads) == 0 {
return nil
}
normalizedOrigin := normalizeOrigin(origin)
scripts := make([]string, 0, len(s.viewManifest.Preloads))
for _, preload := range s.viewManifest.Preloads {
if !preloadMatchesOrigin(preload, normalizedOrigin) {
continue
}
script := strings.TrimSpace(preload.Script)
if script == "" {
continue
}
scripts = append(scripts, script)
}
return scripts
}
func preloadMatchesOrigin(preload ViewPreload, origin string) bool {
if strings.TrimSpace(preload.Origin) == "" && strings.TrimSpace(preload.Match) == "" {
return true
}
if preload.Origin != "" && normalizeOrigin(preload.Origin) == origin {
return true
}
match := strings.TrimSpace(preload.Match)
if match == "" {
return false
}
if match == "*" {
return true
}
if strings.HasSuffix(match, "*") {
return strings.HasPrefix(origin, strings.TrimSuffix(match, "*"))
}
return strings.Contains(origin, match)
}
func buildStoragePolyfillScript(state BrowserStoragePolyfillState) (string, error) {
encodedState, err := json.Marshal(state)
if err != nil {
return "", coreerr.E("display.buildStoragePolyfillScript", "marshal browser storage state", err)
}
return fmt.Sprintf(`(function () {
const initialState = %s;
const root = globalThis;
const queueTask = typeof root.queueMicrotask === 'function'
? root.queueMicrotask.bind(root)
: (fn) => Promise.resolve().then(fn);
const clone = (value) => JSON.parse(JSON.stringify(value));
const bridge = () => {
if (root.__coreGUIBridge && typeof root.__coreGUIBridge === 'object') {
return root.__coreGUIBridge;
}
return null;
};
const ensureObject = (value) => (value && typeof value === 'object' ? value : {});
const navigatorObject = root.navigator || {};
const state = root.__coreStorageState = clone(initialState);
const normaliseState = () => {
state.localStorage = ensureObject(state.localStorage);
state.sessionStorage = ensureObject(state.sessionStorage);
state.indexedDB = ensureObject(state.indexedDB);
state.cacheStorage = ensureObject(state.cacheStorage);
state.storageBuckets = ensureObject(state.storageBuckets);
state.opfs = ensureObject(state.opfs);
state.cookies = Array.isArray(state.cookies) ? state.cookies : [];
};
const replaceState = (nextState) => {
for (const key of Object.keys(state)) {
delete state[key];
}
Object.assign(state, clone(nextState));
normaliseState();
};
normaliseState();
const exportState = () => clone({
origin: state.origin,
localStorage: state.localStorage,
sessionStorage: state.sessionStorage,
cookies: state.cookies,
indexedDB: state.indexedDB,
cacheStorage: state.cacheStorage,
storageBuckets: state.storageBuckets,
opfs: state.opfs,
});
const sync = () => {
const currentBridge = bridge();
if (currentBridge && typeof currentBridge.syncStorage === 'function') {
Promise.resolve(currentBridge.syncStorage(state.origin, exportState())).catch(() => {});
}
};
const makeStorageArea = (field) => ({
get length() {
return Object.keys(state[field]).length;
},
clear() {
state[field] = {};
sync();
},
getItem(key) {
const value = state[field][String(key)];
return value === undefined ? null : String(value);
},
key(index) {
return Object.keys(state[field])[index] ?? null;
},
removeItem(key) {
delete state[field][String(key)];
sync();
},
setItem(key, value) {
state[field][String(key)] = String(value);
sync();
},
});
const cookieString = () => state.cookies.map((cookie) => {
if (!cookie || !cookie.name) {
return '';
}
return String(cookie.name) + '=' + String(cookie.value ?? '');
}).filter(Boolean).join('; ');
const upsertCookie = (rawCookie) => {
const segments = String(rawCookie ?? '')
.split(';')
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length === 0) {
return;
}
const [nameValue, ...attributes] = segments;
const separator = nameValue.indexOf('=');
if (separator === -1) {
return;
}
const cookie = {
name: nameValue.slice(0, separator).trim(),
value: nameValue.slice(separator + 1).trim(),
path: '',
domain: '',
secure: false,
expires_at: '',
};
for (const attribute of attributes) {
const lower = attribute.toLowerCase();
if (lower === 'secure') {
cookie.secure = true;
continue;
}
if (lower.startsWith('path=')) {
cookie.path = attribute.slice(5);
continue;
}
if (lower.startsWith('domain=')) {
cookie.domain = attribute.slice(7);
continue;
}
if (lower.startsWith('expires=')) {
cookie.expires_at = attribute.slice(8);
}
}
state.cookies = state.cookies.filter((entry) => entry.name !== cookie.name);
state.cookies.push(cookie);
sync();
};
try {
Object.defineProperty(root, 'localStorage', {
configurable: true,
value: makeStorageArea('localStorage'),
});
} catch (_) {}
try {
Object.defineProperty(root, 'sessionStorage', {
configurable: true,
value: makeStorageArea('sessionStorage'),
});
} catch (_) {}
if (root.document) {
try {
Object.defineProperty(root.document, 'cookie', {
configurable: true,
get: cookieString,
set: upsertCookie,
});
} catch (_) {}
}
const makeRequest = (executor) => {
const request = {
result: undefined,
error: undefined,
onsuccess: null,
onerror: null,
onupgradeneeded: null,
};
queueTask(() => executor(request));
return request;
};
const makeObjectStore = (container) => ({
get(key) {
return makeRequest((request) => {
request.result = container[String(key)];
if (typeof request.onsuccess === 'function') {
request.onsuccess({ target: request });
}
});
},
put(value, key) {
return makeRequest((request) => {
container[String(key)] = typeof value === 'string' ? value : JSON.stringify(value);
sync();
request.result = String(key);
if (typeof request.onsuccess === 'function') {
request.onsuccess({ target: request });
}
});
},
add(value, key) {
return this.put(value, key);
},
delete(key) {
return makeRequest((request) => {
delete container[String(key)];
sync();
if (typeof request.onsuccess === 'function') {
request.onsuccess({ target: request });
}
});
},
clear() {
return makeRequest((request) => {
Object.keys(container).forEach((key) => delete container[key]);
sync();
if (typeof request.onsuccess === 'function') {
request.onsuccess({ target: request });
}
});
},
getAll() {
return makeRequest((request) => {
request.result = Object.values(container);
if (typeof request.onsuccess === 'function') {
request.onsuccess({ target: request });
}
});
},
index() {
return this;
},
});
if (!root.indexedDB) {
root.indexedDB = {
open(databaseName) {
return makeRequest((request) => {
const name = String(databaseName);
state.indexedDB[name] = ensureObject(state.indexedDB[name]);
request.result = {
name,
close() {},
createObjectStore(storeName) {
const key = String(storeName);
state.indexedDB[name][key] = state.indexedDB[name][key] || {};
sync();
return makeObjectStore(state.indexedDB[name][key]);
},
transaction(storeNames) {
const storeList = Array.isArray(storeNames) ? storeNames : [storeNames];
return {
objectStore(storeName) {
const key = String(storeName || storeList[0] || 'default');
state.indexedDB[name][key] = state.indexedDB[name][key] || {};
return makeObjectStore(state.indexedDB[name][key]);
},
};
},
};
if (typeof request.onupgradeneeded === 'function') {
request.onupgradeneeded({ target: request });
}
if (typeof request.onsuccess === 'function') {
request.onsuccess({ target: request });
}
});
},
};
}
if (!root.caches) {
root.caches = {
async delete(cacheName) {
const prefix = String(cacheName) + '::';
let deleted = false;
for (const key of Object.keys(state.cacheStorage)) {
if (key.startsWith(prefix)) {
deleted = true;
delete state.cacheStorage[key];
}
}
if (deleted) {
sync();
}
return deleted;
},
async keys() {
return Array.from(new Set(Object.keys(state.cacheStorage).map((key) => key.split('::')[0])));
},
async open(cacheName) {
const prefix = String(cacheName) + '::';
return {
async delete(requestKey) {
const cacheKey = prefix + String(requestKey);
const deleted = cacheKey in state.cacheStorage;
delete state.cacheStorage[cacheKey];
if (deleted) {
sync();
}
return deleted;
},
async keys() {
return Object.keys(state.cacheStorage)
.filter((key) => key.startsWith(prefix))
.map((key) => key.slice(prefix.length));
},
async match(requestKey) {
const value = state.cacheStorage[prefix + String(requestKey)];
if (value === undefined) {
return undefined;
}
return typeof Response === 'function' ? new Response(value) : value;
},
async put(requestKey, responseValue) {
if (responseValue && typeof responseValue.text === 'function') {
state.cacheStorage[prefix + String(requestKey)] = await responseValue.text();
} else {
state.cacheStorage[prefix + String(requestKey)] = String(responseValue ?? '');
}
sync();
},
};
},
};
}
if (!navigatorObject.storageBuckets) {
navigatorObject.storageBuckets = {
async open(bucketName) {
const key = String(bucketName);
state.storageBuckets[key] = state.storageBuckets[key] || {};
return {
name: key,
kv: {
delete(itemKey) {
delete state.storageBuckets[key][String(itemKey)];
sync();
},
get(itemKey) {
return state.storageBuckets[key][String(itemKey)] ?? null;
},
keys() {
return Object.keys(state.storageBuckets[key]);
},
set(itemKey, value) {
state.storageBuckets[key][String(itemKey)] = String(value);
sync();
},
},
async persist() {
return true;
},
};
},
};
}
const makeFileHandle = (path) => ({
kind: 'file',
name: path.split('/').pop() || path,
async createWritable() {
return {
async close() {
return undefined;
},
async write(chunk) {
state.opfs[path] = typeof chunk === 'string' ? chunk : JSON.stringify(chunk);
sync();
},
};
},
async getFile() {
const content = state.opfs[path] ?? '';
if (typeof File === 'function') {
return new File([content], path.split('/').pop() || 'file.txt', { type: 'text/plain' });
}
return new Blob([content], { type: 'text/plain' });
},
});
const makeDirectoryHandle = (path) => ({
kind: 'directory',
name: path.split('/').pop() || '',
async getDirectoryHandle(name) {
const child = path ? path + '/' + String(name) : String(name);
return makeDirectoryHandle(child);
},
async getFileHandle(name, options) {
const child = path ? path + '/' + String(name) : String(name);
if (!options || !options.create) {
if (!(child in state.opfs)) {
throw new Error('File not found: ' + child);
}
}
state.opfs[child] = state.opfs[child] || '';
sync();
return makeFileHandle(child);
},
async keys() {
const prefix = path ? path + '/' : '';
return Object.keys(state.opfs)
.filter((key) => key.startsWith(prefix))
.map((key) => key.slice(prefix.length));
},
});
navigatorObject.storage = navigatorObject.storage || {};
if (!navigatorObject.storage.getDirectory) {
navigatorObject.storage.getDirectory = async () => makeDirectoryHandle('');
}
root.core = root.core || {};
root.core.storage = root.core.storage || {};
root.core.storage.origin = state.origin;
root.core.storage.exportState = exportState;
root.core.storage.reset = () => {
replaceState(initialState);
sync();
return exportState();
};
root.core.storage.sync = sync;
})();`, string(encodedState)), nil
}
func buildCoreMLScript() string {
return `(function () {
const root = globalThis;
root.core = root.core || {};
root.core.ml = root.core.ml || {};
root.core.ml.generate = async (prompt) => {
const text = String(prompt ?? '');
try {
const response = await fetch('http://127.0.0.1:8090/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'local',
stream: false,
messages: [{ role: 'user', content: text }],
}),
});
if (!response.ok) {
throw new Error('core.ml bridge returned ' + response.status);
}
const payload = await response.json();
return payload?.choices?.[0]?.message?.content ?? '';
} catch (_) {
if (root.__coreGUIBridge && typeof root.__coreGUIBridge.generate === 'function') {
return Promise.resolve(root.__coreGUIBridge.generate(text));
}
return 'Local inference bridge unavailable for prompt: ' + text;
}
};
})();`
}
func buildElectronShimScript() string {
return `(function () {
const root = globalThis;
const invoke = (action, payload = {}) => {
const bridge = root.__coreGUIBridge;
if (!bridge) {
return Promise.reject(new Error('CoreGUI bridge unavailable for ' + action));
}
if (typeof bridge.invoke === 'function') {
return Promise.resolve(bridge.invoke(action, payload));
}
if (typeof bridge.call === 'function') {
return Promise.resolve(bridge.call(action, payload));
}
return Promise.reject(new Error('CoreGUI bridge unavailable for ' + action));
};
const subscribe = (channel, handler) => {
const bridge = root.__coreGUIBridge;
if (bridge && typeof bridge.on === 'function') {
return bridge.on(channel, handler);
}
return () => {};
};
root.electron = root.electron || {
clipboard: {
readText() {
return invoke('clipboard:read').then((result) => {
if (result && typeof result === 'object' && 'text' in result) {
return String(result.text ?? '');
}
return String(result ?? '');
});
},
writeText(text) {
return invoke('clipboard:write', { text: String(text ?? '') });
},
},
dialog: {
showMessageBox(options) {
return invoke('dialog:message', options ?? {});
},
showOpenDialog(options) {
return invoke('dialog:open-file', options ?? {});
},
showSaveDialog(options) {
return invoke('dialog:save-file', options ?? {});
},
},
ipcRenderer: {
invoke(channel, payload) {
return invoke('core.ipc.query', { channel, payload });
},
on(channel, handler) {
return subscribe(channel, handler);
},
send(channel, payload) {
return invoke('core.ipc.action', { channel, payload });
},
},
shell: {
openExternal(target) {
return invoke('browser:open-url', { url: String(target ?? '') });
},
openPath(target) {
return invoke('browser:open-file', { path: String(target ?? '') });
},
},
};
root.require = root.require || ((name) => {
if (name === 'electron') {
return root.electron;
}
throw new Error('Unsupported module: ' + String(name));
});
})();`
}
func deriveOrigin(rawURL string) string {
normalized := strings.TrimSpace(rawURL)
if normalized == "" {
return "app://local"
}
parsed, err := url.Parse(normalized)
if err != nil {
return "app://local"
}
if parsed.Scheme == "" && parsed.Host == "" {
return "app://local"
}
origin := parsed.Scheme + "://"
if parsed.Host != "" {
origin += parsed.Host
return origin
}
return origin + strings.Trim(parsed.Path, "/")
}
func normalizeOrigin(origin string) string {
origin = strings.TrimSpace(origin)
if origin == "" {
return "app://local"
}
return deriveOrigin(origin)
}
func matchesStorageQuery(query string, key string, value string) bool {
if query == "" {
return true
}
needle := strings.ToLower(query)
return strings.Contains(strings.ToLower(key), needle) || strings.Contains(strings.ToLower(value), needle)
}
func newStorageSearchResult(origin string, area string, key string, value string, updatedAt time.Time) StoreSearchResult {
return StoreSearchResult{
Origin: origin,
StorageArea: area,
Key: key,
Snippet: trimSnippet(value),
UpdatedAt: updatedAt,
}
}
func cloneOriginStorageState(state OriginStorageState) OriginStorageState {
return OriginStorageState{
LocalStorage: cloneStringMap(state.LocalStorage),
SessionStorage: cloneStringMap(state.SessionStorage),
Cookies: append([]BrowserCookie(nil), state.Cookies...),
IndexedDB: cloneNestedStringMap(state.IndexedDB),
CacheStorage: cloneStringMap(state.CacheStorage),
StorageBuckets: cloneNestedStringMap(state.StorageBuckets),
OPFS: cloneStringMap(state.OPFS),
UpdatedAt: state.UpdatedAt,
}
}
func cloneStringMap(source map[string]string) map[string]string {
if len(source) == 0 {
return map[string]string{}
}
cloned := make(map[string]string, len(source))
for key, value := range source {
cloned[key] = value
}
return cloned
}
func cloneNestedStringMap(source map[string]map[string]string) map[string]map[string]string {
if len(source) == 0 {
return map[string]map[string]string{}
}
cloned := make(map[string]map[string]string, len(source))
for key, values := range source {
cloned[key] = cloneStringMap(values)
}
return cloned
}

121
pkg/display/preload_test.go Normal file
View file

@ -0,0 +1,121 @@
package display
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type capturePreloadWindow struct {
script string
}
func (c *capturePreloadWindow) ExecJS(script string) {
c.script = script
}
func TestPreloadScript_Good(t *testing.T) {
root := filepath.Join(t.TempDir(), ".core")
configPath := filepath.Join(root, "gui", "config.yaml")
viewPath := filepath.Join(root, "view.yaml")
require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
require.NoError(t, os.WriteFile(viewPath, []byte(`preloads:
- origin: https://app.example.com
script: |
window.__appPreloadLoaded = true;
`), 0o644))
svc, err := NewService()
require.NoError(t, err)
svc.loadConfigFrom(configPath)
svc.registerBuiltinSchemes()
require.NoError(t, svc.SaveBrowserStorageState("https://app.example.com", OriginStorageState{
LocalStorage: map[string]string{"theme": "dark"},
SessionStorage: map[string]string{"session_id": "abc123"},
}))
script, err := svc.PreloadScript("https://app.example.com/dashboard")
require.NoError(t, err)
assert.Contains(t, script, "window.__appPreloadLoaded = true;")
assert.Contains(t, script, "root.core.ml.generate")
assert.Contains(t, script, "root.electron = root.electron ||")
assert.Contains(t, script, `"theme":"dark"`)
assert.Contains(t, script, `"session_id":"abc123"`)
}
func TestInjectPreload_Good(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
target := &capturePreloadWindow{}
err = svc.InjectPreload(target, "https://app.example.com")
require.NoError(t, err)
assert.Contains(t, target.script, "root.core.ml.generate")
assert.Contains(t, target.script, "root.core.storage")
}
func TestBrowserStoragePersistenceAndSearch_Good(t *testing.T) {
root := filepath.Join(t.TempDir(), ".core")
configPath := filepath.Join(root, "gui", "config.yaml")
require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
svc, err := NewService()
require.NoError(t, err)
svc.loadConfigFrom(configPath)
svc.registerBuiltinSchemes()
require.NoError(t, svc.SaveBrowserStorageState("https://app.example.com", OriginStorageState{
LocalStorage: map[string]string{
"invoice_status": "paid",
},
CacheStorage: map[string]string{
"GET https://api.example.com/invoices/42": `{"invoice":"paid"}`,
},
}))
response, err := svc.ResolveScheme(context.Background(), "core://store?q=invoice")
require.NoError(t, err)
results, ok := response.Data["results"].([]StoreSearchResult)
require.True(t, ok)
require.NotEmpty(t, results)
assert.Equal(t, "https://app.example.com", results[0].Origin)
assert.NotEmpty(t, results[0].StorageArea)
assert.NotEmpty(t, results[0].Key)
reloaded, err := NewService()
require.NoError(t, err)
reloaded.loadConfigFrom(configPath)
state, ok := reloaded.BrowserStorageState("https://app.example.com")
require.True(t, ok)
assert.Equal(t, "paid", state.LocalStorage["invoice_status"])
assert.Contains(t, state.CacheStorage["GET https://api.example.com/invoices/42"], "paid")
}
func TestResolveScheme_CoreRoutes_Good(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
svc.registerBuiltinSchemes()
for _, rawURL := range []string{
"core://network",
"core://agent",
"core://wallet",
"core://identity",
} {
response, err := svc.ResolveScheme(context.Background(), rawURL)
require.NoError(t, err)
assert.Equal(t, "core", response.Scheme)
assert.Equal(t, 200, response.StatusCode)
assert.Contains(t, response.Data, "available")
}
}

View file

@ -26,6 +26,8 @@ type StoreSearchResult struct {
ConversationID string `json:"conversation_id"`
Title string `json:"title"`
Role string `json:"role"`
StorageArea string `json:"storage_area,omitempty"`
Key string `json:"key,omitempty"`
Snippet string `json:"snippet"`
UpdatedAt time.Time `json:"updated_at"`
}
@ -103,6 +105,60 @@ func (s *Service) handleCoreScheme(_ context.Context, path string, params url.Va
"results": results,
},
}, nil
case "network":
return SchemeResponse{
Scheme: "core",
Path: "network",
ContentType: "application/json",
StatusCode: 200,
Data: map[string]any{
"available": false,
"connections": []any{},
"fleet_nodes": []any{},
"peer_count": 0,
"websocketInfo": s.GetEventInfo(),
},
}, nil
case "agent":
return SchemeResponse{
Scheme: "core",
Path: "agent",
ContentType: "application/json",
StatusCode: 200,
Data: map[string]any{
"available": false,
"dispatch_queue": []any{},
"sessions": []any{},
"workspace_status": "unavailable",
},
}, nil
case "wallet":
return SchemeResponse{
Scheme: "core",
Path: "wallet",
ContentType: "application/json",
StatusCode: 200,
Data: map[string]any{
"available": false,
"balance": "0",
"staking": []any{},
"transactions": []any{},
},
}, nil
case "identity":
return SchemeResponse{
Scheme: "core",
Path: "identity",
ContentType: "application/json",
StatusCode: 200,
Data: map[string]any{
"available": false,
"certificates": []any{},
"consent_state": "unknown",
"did": "",
"keys": []any{},
},
}, nil
default:
return SchemeResponse{}, coreerr.E("display.handleCoreScheme", "unknown core route: "+path, nil)
}
@ -128,6 +184,10 @@ func (s *Service) searchStore(query string) []StoreSearchResult {
}
}
if s.browserStorage != nil {
results = append(results, s.browserStorage.Search(query)...)
}
sort.Slice(results, func(i, j int) bool {
return results[i].UpdatedAt.After(results[j].UpdatedAt)
})

26
ui/src/global.d.ts vendored Normal file
View file

@ -0,0 +1,26 @@
export {};
declare global {
interface Window {
__coreGUIBridge?: {
call?: (action: string, payload?: unknown) => Promise<unknown> | unknown;
generate?: (prompt: string) => Promise<string> | string;
invoke?: (action: string, payload?: unknown) => Promise<unknown> | unknown;
on?: (channel: string, handler: (...args: unknown[]) => void) => (() => void) | void;
syncStorage?: (origin: string, state: unknown) => Promise<void> | void;
};
core?: {
ml?: {
generate?: (prompt: string) => Promise<string>;
};
storage?: {
exportState?: () => unknown;
origin?: string;
reset?: () => unknown;
sync?: () => void;
};
};
electron?: unknown;
require?: (name: string) => unknown;
}
}

View file

@ -311,7 +311,11 @@ export class ChatService {
this.queuedAttachmentsState.set([]);
this.busyState.set(true);
const response = buildResponse(content, this.selectedModelState(), this.settingsState());
const response = await generateAssistantResponse(
content,
this.selectedModelState(),
this.settingsState(),
);
await streamIntoMessage(response, (fragment, done) => {
this.updateMessage(updatedConversation.id, assistantMessage.id, (message) => ({
...message,
@ -435,8 +439,26 @@ function inferToolCalls(content: string): ToolInvocation[] {
];
}
function buildResponse(content: string, model: string, settings: ChatSettings): string {
async function generateAssistantResponse(
content: string,
model: string,
settings: ChatSettings,
): Promise<string> {
const prompt = content.trim() || 'your multimodal prompt';
try {
if (typeof window.core?.ml?.generate === 'function') {
const generated = await window.core.ml.generate(prompt);
if (generated.trim()) {
return generated;
}
}
} catch {
// Fall back to the local RFC demo response below.
}
return buildFallbackResponse(prompt, model, settings);
}
function buildFallbackResponse(prompt: string, model: string, settings: ChatSettings): string {
return [
`Using ${model} with temperature ${settings.temperature.toFixed(1)}.`,
'',
@ -448,7 +470,7 @@ function buildResponse(content: string, model: string, settings: ChatSettings):
`const response = await window.core.ml.generate(${JSON.stringify(prompt)});`,
'```',
'',
'The actual inference bridge is not present in this workspace, so this shell demonstrates the RFC flow with local state, progressive rendering, tool-call blocks, and multimodal attachments.',
'The live CoreGUI inference bridge was unavailable, so this shell fell back to the local RFC demo response with progressive rendering, tool-call blocks, and multimodal attachments.',
].join('\n');
}