[MIRROR] Port of the iframe storage for settings (#12040)

Co-authored-by: Kashargul <144968721+Kashargul@users.noreply.github.com>
This commit is contained in:
CHOMPStation2StaffMirrorBot
2025-12-04 17:26:10 -07:00
committed by GitHub
parent 1b99ac2969
commit ba5019bd9d
12 changed files with 326 additions and 32 deletions

View File

@@ -0,0 +1,33 @@
name: "Generate Client Storage"
on:
push:
branches:
- master
paths:
- tgui/public/*
jobs:
dispatch_repo:
if: ( !contains(github.event.head_commit.message, '[ci skip]') )
name: Repository Dispatch
runs-on: ubuntu-latest
steps:
- name: Generate App Token
id: app-token-generation
uses: actions/create-github-app-token@v2
if: env.APP_PRIVATE_KEY != '' && env.APP_ID != ''
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: vorestation
env:
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
APP_ID: ${{ secrets.APP_ID }}
- name: Send Repository Dispatch
if: success()
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ steps.app-token-generation.outputs.token }}
repository: vorestation/byond-client-storage
event-type: on_master_push

View File

@@ -11,3 +11,7 @@
/datum/config_entry/flag/smart_cache_assets
/datum/config_entry/flag/save_spritesheets
/datum/config_entry/string/storage_cdn_iframe
protection = CONFIG_ENTRY_LOCKED
default = "https://vorestation.github.io/byond-client-storage/iframe.html"

View File

@@ -40,6 +40,25 @@ SUBSYSTEM_DEF(tgui)
basehtml = replacetextEx(basehtml, "<!-- tgui:nt-copyright -->", "Nanotrasen (c) 2284-[text2num(time2text(world.realtime,"YYYY")) + STATION_YEAR_OFFSET]")
/datum/controller/subsystem/tgui/OnConfigLoad()
var/storage_iframe = CONFIG_GET(string/storage_cdn_iframe)
if(storage_iframe && storage_iframe != /datum/config_entry/string/storage_cdn_iframe::default)
basehtml = replacetext(basehtml, "\[tgui:storagecdn\]", storage_iframe)
return
if(CONFIG_GET(string/asset_transport) == "webroot")
var/datum/asset_transport/webroot/webroot = SSassets.transport
var/datum/asset_cache_item/item = webroot.register_asset("iframe.html", file("tgui/public/iframe.html"))
basehtml = replacetext(basehtml, "\[tgui:storagecdn\]", webroot.get_asset_url("iframe.html", item))
return
if(!storage_iframe)
return
basehtml = replacetext(basehtml, "\[tgui:storagecdn\]", storage_iframe)
/datum/controller/subsystem/tgui/Shutdown()
close_all_uis()

View File

@@ -269,6 +269,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
if (CONFIG_GET(flag/chatlog_database_backend))
chatlog_token = vchatlog_generate_token(ckey, GLOB.round_id)
winset(src, null, list("browser-options" = "find,refresh"))
// Instantiate stat panel
stat_panel = new(src, "statbrowser")
stat_panel.subscribe(src, PROC_REF(on_stat_panel_message))

View File

@@ -42,3 +42,12 @@ SMART_CACHE_ASSETS
## Useful for developers to debug potential spritesheet issues to determine where the issue is cropping up (either in DM-side sprite generation or in the TGUI-side display of said spritesheet).
## Will only seek to waste disk space if ran on production.
#SAVE_SPRITESHEETS
# If configured, this allows server operators to define the persistent origin used for clientside
# storage. This must host the same file as available in tgui/public/iframe.html. This is also hosted
# on the GitHub Pages site for the /tg/station repository, so does not need to be configured.
# If multiple servers use the same domain name, clientside features such as message saving
# and chat tabs will be persistent across both.
# If this setting is not configured, but the webroot CDN is, that will be used instead of GitHub Pages.
# If this setting is mpty, and the webroot CDN is disabled, byondstorage will be used.
# STORAGE_CDN_IFRAME https://vorestation.github.io/byond-client-storage/iframe.html

5
tgui/global.d.ts vendored
View File

@@ -63,6 +63,11 @@ type ByondType = {
*/
strictMode: boolean;
/**
* The external URL for the IndexedDB IFrame to use as the origin
*/
storageCdn: string;
/**
* Makes a BYOND call.
*

View File

@@ -6,12 +6,13 @@
* @license MIT
*/
export const IMPL_MEMORY = 0;
export const IMPL_HUB_STORAGE = 1;
type StorageImplementation = typeof IMPL_MEMORY | typeof IMPL_HUB_STORAGE;
export const IMPL_IFRAME_INDEXED_DB = 2;
const KEY_NAME = 'chomp'; // CHOMPEdit - CHOMPStation Localstore
type StorageImplementation =
| typeof IMPL_HUB_STORAGE
| typeof IMPL_IFRAME_INDEXED_DB;
type StorageBackend = {
impl: StorageImplementation;
@@ -61,28 +62,160 @@ class HubStorageBackend implements StorageBackend {
}
}
class IFrameIndexedDbBackend implements StorageBackend {
public impl: StorageImplementation;
private documentElement: HTMLIFrameElement;
private iframeWindow: Window;
constructor() {
this.impl = IMPL_IFRAME_INDEXED_DB;
}
async ready(): Promise<boolean | null> {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = Byond.storageCdn;
const completePromise: Promise<boolean> = new Promise((resolve) => {
fetch(Byond.storageCdn, { method: 'HEAD' })
.then((response) => {
if (response.status !== 200) {
resolve(false);
}
})
.catch(() => {
resolve(false);
});
window.addEventListener('message', (message) => {
if (message.data === 'ready') {
resolve(true);
}
});
});
this.documentElement = document.body.appendChild(iframe);
if (!this.documentElement.contentWindow) {
return new Promise((res) => res(false));
}
this.iframeWindow = this.documentElement.contentWindow;
return completePromise;
}
async get(key: string): Promise<any> {
const promise = new Promise((resolve) => {
window.addEventListener('message', (message) => {
if (message.data.key && message.data.key === key) {
resolve(message.data.value);
}
});
});
this.iframeWindow.postMessage(
{ type: 'get', key: `${KEY_NAME}-${key}` },
'*',
);
return promise;
}
async set(key: string, value: any): Promise<void> {
this.iframeWindow.postMessage(
{ type: 'set', key: `${KEY_NAME}-${key}`, value: value },
'*',
);
}
async remove(key: string): Promise<void> {
this.iframeWindow.postMessage(
{ type: 'remove', key: `${KEY_NAME}-${key}` },
'*',
);
}
async clear(): Promise<void> {
this.iframeWindow.postMessage({ type: 'clear' }, '*');
}
async destroy(): Promise<void> {
document.body.removeChild(this.documentElement);
}
}
/**
* Web Storage Proxy object, which selects the best backend available
* depending on the environment.
*/
class StorageProxy implements StorageBackend {
private backendPromise: Promise<StorageBackend>;
public impl: StorageImplementation = IMPL_MEMORY;
public impl: StorageImplementation = IMPL_IFRAME_INDEXED_DB;
constructor() {
this.backendPromise = (async () => {
// If we have not enabled byondstorage yet, we need to check
// if we can use the IFrame, or if we need to enable byondstorage
if (!testHubStorage()) {
// If we have an IFrame URL we can use, and we haven't already enabled
// byondstorage, we should use the IFrame backend
if (Byond.storageCdn) {
const iframe = new IFrameIndexedDbBackend();
if ((await iframe.ready()) === true) {
if (await iframe.get('byondstorage-migrated')) return iframe;
Byond.winset(null, 'browser-options', '+byondstorage');
await new Promise<void>((resolve) => {
document.addEventListener('byondstorageupdated', async () => {
setTimeout(() => {
const hub = new HubStorageBackend();
// Migrate these existing settings from byondstorage to the IFrame
for (const setting of [
'panel-settings',
'chat-state',
'chat-messages',
]) {
hub
.get(setting)
.then((settings) => iframe.set(setting, settings));
}
iframe.set('byondstorage-migrated', true);
Byond.winset(null, 'browser-options', '-byondstorage');
resolve();
}, 1);
});
});
return iframe;
}
iframe.destroy();
}
// IFrame hasn't worked out for us, we'll need to enable byondstorage
Byond.winset(null, 'browser-options', '+byondstorage');
return new Promise((resolve) => {
const listener = () => {
document.removeEventListener('byondstorageupdated', listener);
resolve(new HubStorageBackend());
// This event is emitted *before* byondstorage is actually created
// so we have to wait a little bit before we can use it
setTimeout(() => resolve(new HubStorageBackend()), 1);
};
document.addEventListener('byondstorageupdated', listener);
});
}
// byondstorage is already enabled, we can use it straight away
return new HubStorageBackend();
})() as Promise<StorageBackend>;
})();
}
async get(key: string): Promise<any> {

View File

@@ -334,6 +334,7 @@ export const chatMiddleware = (store) => {
next(action);
const page = selectCurrentChatPage(store.getState());
chatRenderer.changePage(page);
needsUpdate = true;
return;
}
if (type === rebuildChat.type) {

View File

@@ -29,6 +29,7 @@
// Expose inlined metadata
Byond.windowId = parseMetaTag('tgui:windowId');
Byond.storageCdn = parseMetaTag('tgui:storagecdn');
// Backwards compatibility
window.__windowId__ = Byond.windowId;
@@ -397,32 +398,17 @@
],
};
try {
window
.showSaveFilePicker(opts)
.then((fileHandle) => {
fileHandle
.createWritable()
.then((writeableFileHandle) => {
writeableFileHandle
.write(blob)
.then(() => {
writeableFileHandle.close();
})
.catch((e) => {
console.error(e);
});
})
.catch((e) => {
console.error(e);
});
})
.catch((e) => {
console.error(e);
window
.showSaveFilePicker(opts)
.then(function (file) {
return file.createWritable();
})
.then(function (file) {
return file.write(blob).then(function () {
return file.close();
});
} catch (e) {
console.error(e);
}
})
.catch(function () {});
}
};

File diff suppressed because one or more lines are too long

102
tgui/public/iframe.html Normal file
View File

@@ -0,0 +1,102 @@
<!doctype html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta charset="utf-8" />
<script type="text/javascript">
const INDEXED_DB_VERSION = 1;
const INDEXED_DB_NAME = 'tgui';
const INDEXED_DB_STORE_NAME = 'storage';
const READ_ONLY = 'readonly';
const READ_WRITE = 'readwrite';
const MAX_MESSAGES = 1000;
const dbPromise = new Promise((resolve, reject) => {
const indexedDB = window.indexedDB;
const req = indexedDB.open(INDEXED_DB_NAME, INDEXED_DB_VERSION);
req.onupgradeneeded = (event) => {
try {
if (event.oldVersion < 1) {
req.result.createObjectStore(INDEXED_DB_STORE_NAME);
}
} catch (err) {
reject(new Error('Failed to upgrade IDB: ' + req.error));
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => {
reject(new Error('Failed to open IDB: ' + req.error));
};
});
window.addEventListener('message', (messageEvent) => {
switch (messageEvent.data.type) {
case 'ping':
messageEvent.source.postMessage(true, '*');
break;
case 'get':
get(messageEvent.data.key).then((value) => {
messageEvent.source.postMessage(
{ key: messageEvent.data.key, value: value },
'*',
);
});
break;
case 'set':
set(messageEvent.data.key, messageEvent.data.value);
break;
case 'remove':
remove(messageEvent.data.key);
break;
case 'clear':
clear();
break;
default:
break;
}
});
const getStore = async (mode) => {
return dbPromise.then((db) =>
db
.transaction(INDEXED_DB_STORE_NAME, mode)
.objectStore(INDEXED_DB_STORE_NAME),
);
};
const get = async (key) => {
const store = await getStore(READ_ONLY);
return new Promise((resolve, reject) => {
const req = store.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
};
const set = async (key, value) => {
const store = await getStore(READ_WRITE);
store.put(value, key);
};
const remove = async (key) => {
const store = await getStore(READ_WRITE);
store.delete(key);
};
const clear = async () => {
const store = await getStore(READ_WRITE);
store.clear();
};
window.onload = () => {
window.parent.postMessage('ready', '*');
};
</script>
</head>
</html>

View File

@@ -6,6 +6,7 @@
<!-- Inlined metadata -->
<meta id="tgui:windowId" content="[tgui:windowId]" />
<meta id="tgui:strictMode" content="[tgui:strictMode]" />
<meta id="tgui:storagecdn" content="[tgui:storagecdn]" />
<!-- Early setup -->
<!-- tgui:helpers -->