Hydrate display storage preload state
This commit is contained in:
parent
750f7d9f43
commit
390cd600d8
3 changed files with 258 additions and 21 deletions
|
|
@ -28,8 +28,12 @@ func (s *Service) InjectPreload(webview PreloadTarget, origin string) error {
|
|||
// before page code runs.
|
||||
// Use: script, _ := display.BuildPreloadScript("https://example.com")
|
||||
func (s *Service) BuildPreloadScript(pageURL string) (string, error) {
|
||||
storageBootstrap := map[string]map[string]string{}
|
||||
if s.storage != nil {
|
||||
storageBootstrap = s.storage.Snapshot(pageURL)
|
||||
}
|
||||
parts := []string{
|
||||
s.injectStoragePolyfills(pageURL),
|
||||
s.injectStoragePolyfills(pageURL, storageBootstrap),
|
||||
s.injectBackgroundServiceShims(),
|
||||
s.injectElectronShim(),
|
||||
s.injectCoreMLShim(),
|
||||
|
|
@ -41,16 +45,11 @@ func (s *Service) BuildPreloadScript(pageURL string) (string, error) {
|
|||
return strings.Join(parts, "\n"), nil
|
||||
}
|
||||
|
||||
func (s *Service) injectStoragePolyfills(pageOrigin string) string {
|
||||
func (s *Service) injectStoragePolyfills(pageOrigin string, bootstrap map[string]map[string]string) string {
|
||||
return `(function() {
|
||||
const __coreOrigin = (() => {
|
||||
try {
|
||||
const parsed = new URL(` + core.JSONMarshalString(pageOrigin) + `, globalThis.location?.href || "http://localhost");
|
||||
return parsed.origin || ` + core.JSONMarshalString(pageOrigin) + `;
|
||||
} catch (_) {
|
||||
return ` + core.JSONMarshalString(pageOrigin) + `;
|
||||
}
|
||||
})();
|
||||
const __corePageURL = ` + core.JSONMarshalString(pageOrigin) + `;
|
||||
const __coreOrigin = ` + core.JSONMarshalString(storageOriginForPageURL(pageOrigin)) + ` || __corePageURL;
|
||||
const __coreBootstrapStorage = ` + core.JSONMarshalString(bootstrap) + `;
|
||||
const __coreScopes = globalThis.__coreStorageScopes || (globalThis.__coreStorageScopes = {});
|
||||
const __scope = __coreScopes[__coreOrigin] || (__coreScopes[__coreOrigin] = { localStorage: {}, sessionStorage: {}, cookies: {}, indexedDB: {}, caches: {}, buckets: {}, opfs: {} });
|
||||
const __coreBridge = globalThis.__coreBridge || (globalThis.__coreBridge = {
|
||||
|
|
@ -61,7 +60,84 @@ func (s *Service) injectStoragePolyfills(pageOrigin string) string {
|
|||
return Promise.resolve({ route, payload, bridged: false });
|
||||
}
|
||||
});
|
||||
const hydrateBucket = (bucketName, target, source) => {
|
||||
if (!source || typeof source !== "object") {
|
||||
return;
|
||||
}
|
||||
const parseMaybeJSON = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (bucketName === "cookies") {
|
||||
try {
|
||||
target[key] = typeof value === "string" ? JSON.parse(value) : value;
|
||||
} catch (_) {
|
||||
target[key] = { value: String(value ?? "") };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (bucketName.startsWith("cache:")) {
|
||||
const cacheName = bucketName.slice("cache:".length);
|
||||
target[cacheName] = target[cacheName] || {};
|
||||
target[cacheName][key] = parseMaybeJSON(value);
|
||||
continue;
|
||||
}
|
||||
if (bucketName.startsWith("indexeddb:")) {
|
||||
const databaseName = bucketName.slice("indexeddb:".length);
|
||||
const [storeName, recordKey] = key.split(":", 2);
|
||||
target[databaseName] = target[databaseName] || { stores: {} };
|
||||
target[databaseName].stores = target[databaseName].stores || {};
|
||||
target[databaseName].stores[storeName] = target[databaseName].stores[storeName] || {};
|
||||
target[databaseName].stores[storeName][recordKey] = parseMaybeJSON(value);
|
||||
continue;
|
||||
}
|
||||
if (bucketName.startsWith("storageBucket:")) {
|
||||
const bucketNameValue = bucketName.slice("storageBucket:".length);
|
||||
target[bucketNameValue] = target[bucketNameValue] || { kv: {}, files: {} };
|
||||
target[bucketNameValue].kv = target[bucketNameValue].kv || {};
|
||||
target[bucketNameValue].kv[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (bucketName === "opfs") {
|
||||
target[key] = { contents: value };
|
||||
continue;
|
||||
}
|
||||
target[key] = value;
|
||||
}
|
||||
};
|
||||
Object.entries(__coreBootstrapStorage || {}).forEach(([bucketName, bucket]) => {
|
||||
if (bucketName.startsWith("cache:")) {
|
||||
hydrateBucket(bucketName, __scope.caches, bucket);
|
||||
return;
|
||||
}
|
||||
if (bucketName.startsWith("indexeddb:")) {
|
||||
hydrateBucket(bucketName, __scope.indexedDB, bucket);
|
||||
return;
|
||||
}
|
||||
if (bucketName.startsWith("storageBucket:")) {
|
||||
hydrateBucket(bucketName, __scope.buckets, bucket);
|
||||
return;
|
||||
}
|
||||
if (bucketName === "opfs") {
|
||||
hydrateBucket(bucketName, __scope.opfs, bucket);
|
||||
return;
|
||||
}
|
||||
if (!__scope[bucketName]) {
|
||||
__scope[bucketName] = {};
|
||||
}
|
||||
hydrateBucket(bucketName, __scope[bucketName], bucket);
|
||||
});
|
||||
const persist = (bucket, key, value) => {
|
||||
if (bucket === "sessionStorage") {
|
||||
return;
|
||||
}
|
||||
__coreBridge.invoke('display.storage.set', { origin: __coreOrigin, bucket, key, value }).catch(() => undefined);
|
||||
};
|
||||
const createStorage = (bucketName, bucket) => ({
|
||||
|
|
@ -77,10 +153,51 @@ func (s *Service) injectStoragePolyfills(pageOrigin string) string {
|
|||
globalThis.core.storage.local = createStorage('localStorage', __scope.localStorage);
|
||||
globalThis.core.storage.session = createStorage('sessionStorage', __scope.sessionStorage);
|
||||
globalThis.core.storage.cookies = createStorage('cookies', __scope.cookies);
|
||||
const cookieEntries = () => Object.entries(__scope.cookies).map(([name, value]) => name + "=" + value).join("; ");
|
||||
const currentLocation = () => globalThis.location || {};
|
||||
const cookieValue = (record) => {
|
||||
if (record && typeof record === "object" && !Array.isArray(record)) {
|
||||
return String(record.value ?? "");
|
||||
}
|
||||
return String(record ?? "");
|
||||
};
|
||||
const cookieIsExpired = (record) => {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
||||
return false;
|
||||
}
|
||||
const expiresAt = record.expires ? Date.parse(record.expires) : NaN;
|
||||
return Number.isFinite(expiresAt) && expiresAt <= Date.now();
|
||||
};
|
||||
const cookieMatchesPath = (record) => {
|
||||
const path = String(currentLocation().pathname || "/");
|
||||
const cookiePath = (record && typeof record === "object" && record.path) ? String(record.path) : "/";
|
||||
return path === cookiePath || path.startsWith(cookiePath.endsWith("/") ? cookiePath : cookiePath + "/") || cookiePath === "/";
|
||||
};
|
||||
const cookieMatchesDomain = (record) => {
|
||||
const hostname = String(currentLocation().hostname || "");
|
||||
const domain = (record && typeof record === "object" && record.domain) ? String(record.domain).replace(/^\./, "") : "";
|
||||
if (!domain) {
|
||||
return true;
|
||||
}
|
||||
return hostname === domain || hostname.endsWith("." + domain);
|
||||
};
|
||||
const cookieMatchesSecure = (record) => {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record) || !record.secure) {
|
||||
return true;
|
||||
}
|
||||
const protocol = String(currentLocation().protocol || "");
|
||||
return protocol === "https:" || protocol === "wss:" || currentLocation().hostname === "localhost";
|
||||
};
|
||||
const cookieEntries = () => Object.entries(__scope.cookies)
|
||||
.filter(([, record]) => !cookieIsExpired(record) && cookieMatchesPath(record) && cookieMatchesDomain(record) && cookieMatchesSecure(record))
|
||||
.map(([name, record]) => name + "=" + cookieValue(record))
|
||||
.join("; ");
|
||||
const setCookie = (value) => {
|
||||
const rawCookie = String(value ?? "");
|
||||
const [pair] = rawCookie.split(";", 1);
|
||||
const parts = rawCookie.split(";").map((part) => part.trim()).filter(Boolean);
|
||||
const [pair, ...attributes] = parts;
|
||||
if (!pair) {
|
||||
return;
|
||||
}
|
||||
const separatorIndex = pair.indexOf("=");
|
||||
if (separatorIndex < 0) {
|
||||
return;
|
||||
|
|
@ -89,8 +206,54 @@ func (s *Service) injectStoragePolyfills(pageOrigin string) string {
|
|||
if (!name) {
|
||||
return;
|
||||
}
|
||||
__scope.cookies[name] = pair.slice(separatorIndex + 1).trim();
|
||||
persist('cookies', name, __scope.cookies[name]);
|
||||
const record = {
|
||||
value: pair.slice(separatorIndex + 1).trim(),
|
||||
path: "/",
|
||||
domain: String(currentLocation().hostname || ""),
|
||||
secure: false,
|
||||
sameSite: "Lax"
|
||||
};
|
||||
attributes.forEach((attribute) => {
|
||||
const [rawKey, ...rawValue] = attribute.split("=");
|
||||
const key = String(rawKey || "").trim().toLowerCase();
|
||||
const value = rawValue.join("=").trim();
|
||||
switch (key) {
|
||||
case "expires":
|
||||
if (value) {
|
||||
const expiresAt = new Date(value);
|
||||
if (!Number.isNaN(expiresAt.getTime())) {
|
||||
record.expires = expiresAt.toISOString();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "max-age": {
|
||||
const seconds = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(seconds)) {
|
||||
record.expires = new Date(Date.now() + (seconds * 1000)).toISOString();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "path":
|
||||
record.path = value || "/";
|
||||
break;
|
||||
case "domain":
|
||||
record.domain = value.replace(/^\./, "");
|
||||
break;
|
||||
case "secure":
|
||||
record.secure = true;
|
||||
break;
|
||||
case "samesite":
|
||||
record.sameSite = value || "Lax";
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (cookieIsExpired(record)) {
|
||||
delete __scope.cookies[name];
|
||||
persist('cookies', name, '');
|
||||
return;
|
||||
}
|
||||
__scope.cookies[name] = record;
|
||||
persist('cookies', name, JSON.stringify(record));
|
||||
};
|
||||
const asyncResult = (value) => Promise.resolve(value);
|
||||
const createIDBRequest = (result) => {
|
||||
|
|
@ -101,12 +264,12 @@ func (s *Service) injectStoragePolyfills(pageOrigin string) string {
|
|||
});
|
||||
return request;
|
||||
};
|
||||
const createObjectStore = (database, storeName) => ({
|
||||
const createObjectStore = (databaseName, database, storeName) => ({
|
||||
put(value, key) {
|
||||
database.stores[storeName] = database.stores[storeName] || {};
|
||||
const resolvedKey = key ?? value?.id ?? crypto.randomUUID?.() ?? String(Date.now());
|
||||
database.stores[storeName][resolvedKey] = value;
|
||||
persist('indexeddb', storeName + ':' + resolvedKey, JSON.stringify(value));
|
||||
persist('indexeddb:' + databaseName, storeName + ':' + resolvedKey, JSON.stringify(value));
|
||||
return createIDBRequest(resolvedKey);
|
||||
},
|
||||
get(key) {
|
||||
|
|
@ -119,7 +282,7 @@ func (s *Service) injectStoragePolyfills(pageOrigin string) string {
|
|||
if (database.stores?.[storeName]) {
|
||||
delete database.stores[storeName][key];
|
||||
}
|
||||
persist('indexeddb', storeName + ':' + key, '');
|
||||
persist('indexeddb:' + databaseName, storeName + ':' + key, '');
|
||||
return createIDBRequest(undefined);
|
||||
},
|
||||
clear() {
|
||||
|
|
@ -136,14 +299,14 @@ func (s *Service) injectStoragePolyfills(pageOrigin string) string {
|
|||
name,
|
||||
createObjectStore(storeName) {
|
||||
database.stores[storeName] = database.stores[storeName] || {};
|
||||
return createObjectStore(database, storeName);
|
||||
return createObjectStore(name, database, storeName);
|
||||
},
|
||||
transaction(storeNames) {
|
||||
const names = Array.isArray(storeNames) ? storeNames : [storeNames];
|
||||
return {
|
||||
objectStore(storeName) {
|
||||
const target = names.includes(storeName) ? storeName : names[0];
|
||||
return createObjectStore(database, target);
|
||||
return createObjectStore(name, database, target);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
|
@ -160,12 +323,12 @@ func (s *Service) injectStoragePolyfills(pageOrigin string) string {
|
|||
async put(request, response) {
|
||||
const key = typeof request === 'string' ? request : request?.url;
|
||||
bucket[key] = response;
|
||||
persist('cache', name + ':' + key, JSON.stringify(response));
|
||||
persist('cache:' + name, key, JSON.stringify(response));
|
||||
},
|
||||
async delete(request) {
|
||||
const key = typeof request === 'string' ? request : request?.url;
|
||||
delete bucket[key];
|
||||
persist('cache', name + ':' + key, '');
|
||||
persist('cache:' + name, key, '');
|
||||
return true;
|
||||
},
|
||||
async keys() {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package display
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
|
@ -65,6 +66,44 @@ func storageDatabasePath() string {
|
|||
return core.Path(home, ".core", "state", fmt.Sprintf("gui-storage-%d.db", os.Getpid()))
|
||||
}
|
||||
|
||||
func storageOriginForPageURL(pageURL string) string {
|
||||
trimmed := strings.TrimSpace(pageURL)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return trimmed
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
|
||||
case "http", "https":
|
||||
if parsed.Host == "" {
|
||||
return trimmed
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host
|
||||
case "core":
|
||||
if parsed.Host == "" {
|
||||
return "core://"
|
||||
}
|
||||
return "core://" + parsed.Host
|
||||
case "file":
|
||||
if parsed.Path == "" {
|
||||
return trimmed
|
||||
}
|
||||
return "file://" + parsed.Path
|
||||
default:
|
||||
origin := parsed.Scheme + "://" + parsed.Host
|
||||
if parsed.Path != "" {
|
||||
origin += parsed.Path
|
||||
}
|
||||
origin = strings.TrimRight(origin, "/")
|
||||
if strings.TrimSpace(origin) == "://" {
|
||||
return trimmed
|
||||
}
|
||||
return origin
|
||||
}
|
||||
}
|
||||
|
||||
func makeStorageEntryKey(origin, bucket, key string) string {
|
||||
return strings.Join([]string{origin, bucket, key}, "\x00")
|
||||
}
|
||||
|
|
@ -175,6 +214,26 @@ func (r *StorageRegistry) Search(query string) []StorageEntry {
|
|||
return results
|
||||
}
|
||||
|
||||
func (r *StorageRegistry) Snapshot(pageURL string) map[string]map[string]string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
origin := storageOriginForPageURL(pageURL)
|
||||
snapshot := make(map[string]map[string]string)
|
||||
for _, entry := range r.entries {
|
||||
if origin != "" && !strings.EqualFold(entry.Origin, origin) {
|
||||
continue
|
||||
}
|
||||
bucket := snapshot[entry.Bucket]
|
||||
if bucket == nil {
|
||||
bucket = make(map[string]string)
|
||||
snapshot[entry.Bucket] = bucket
|
||||
}
|
||||
bucket[entry.Key] = entry.Value
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (r *StorageRegistry) Close() error {
|
||||
if r == nil || r.store == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -92,3 +92,18 @@ func TestStorageRegistry_Search_Ugly(t *testing.T) {
|
|||
results := r.Search("does-not-exist")
|
||||
require.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestStorageRegistry_Snapshot_Good(t *testing.T) {
|
||||
r := NewStorageRegistry()
|
||||
r.Set("core://settings", "localStorage", "theme", "dark")
|
||||
r.Set("core://settings", "cookies", "session", `{"value":"abc","path":"/","secure":false}`)
|
||||
r.Set("core://other", "localStorage", "theme", "light")
|
||||
|
||||
snapshot := r.Snapshot("core://settings/profile")
|
||||
require.Contains(t, snapshot, "localStorage")
|
||||
require.Contains(t, snapshot, "cookies")
|
||||
assert.Equal(t, "dark", snapshot["localStorage"]["theme"])
|
||||
assert.Equal(t, `{"value":"abc","path":"/","secure":false}`, snapshot["cookies"]["session"])
|
||||
_, otherOriginPresent := snapshot["other"]
|
||||
assert.False(t, otherOriginPresent)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue