From a85d086bbd12116d8dc69732657bbb85ac492ace Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 15:31:24 +0100 Subject: [PATCH] Add CoreGUI preload and browser bridge support --- pkg/display/display.go | 33 +- pkg/display/preload.go | 976 ++++++++++++++++++++++++++++++++ pkg/display/preload_test.go | 121 ++++ pkg/display/routes.go | 60 ++ ui/src/global.d.ts | 26 + ui/src/services/chat.service.ts | 28 +- 6 files changed, 1232 insertions(+), 12 deletions(-) create mode 100644 pkg/display/preload.go create mode 100644 pkg/display/preload_test.go create mode 100644 ui/src/global.d.ts diff --git a/pkg/display/display.go b/pkg/display/display.go index 5f7da868..87f9dbf1 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -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 } diff --git a/pkg/display/preload.go b/pkg/display/preload.go new file mode 100644 index 00000000..d2b16924 --- /dev/null +++ b/pkg/display/preload.go @@ -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 +} diff --git a/pkg/display/preload_test.go b/pkg/display/preload_test.go new file mode 100644 index 00000000..b565773d --- /dev/null +++ b/pkg/display/preload_test.go @@ -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") + } +} diff --git a/pkg/display/routes.go b/pkg/display/routes.go index cee4c814..2c824a08 100644 --- a/pkg/display/routes.go +++ b/pkg/display/routes.go @@ -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) }) diff --git a/ui/src/global.d.ts b/ui/src/global.d.ts new file mode 100644 index 00000000..f4763217 --- /dev/null +++ b/ui/src/global.d.ts @@ -0,0 +1,26 @@ +export {}; + +declare global { + interface Window { + __coreGUIBridge?: { + call?: (action: string, payload?: unknown) => Promise | unknown; + generate?: (prompt: string) => Promise | string; + invoke?: (action: string, payload?: unknown) => Promise | unknown; + on?: (channel: string, handler: (...args: unknown[]) => void) => (() => void) | void; + syncStorage?: (origin: string, state: unknown) => Promise | void; + }; + core?: { + ml?: { + generate?: (prompt: string) => Promise; + }; + storage?: { + exportState?: () => unknown; + origin?: string; + reset?: () => unknown; + sync?: () => void; + }; + }; + electron?: unknown; + require?: (name: string) => unknown; + } +} diff --git a/ui/src/services/chat.service.ts b/ui/src/services/chat.service.ts index 751be5a1..9ec70499 100644 --- a/ui/src/services/chat.service.ts +++ b/ui/src/services/chat.service.ts @@ -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 { 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'); }