ui: share links now put the payload in the URL fragment, embed decodes both

This commit is contained in:
Steven Hugg committed 2026-10-04 11:31:36 +02:00
1 parent d7e7b52b3a
commit 108a20fe40
7 files changed
+97 -31

No files matched your search

+1
View File
@@ -38,6 +38,7 @@ See [extension/CHANGELOG.md](extension/CHANGELOG.md).
- Click `#include`/`#link` decorations to open the file; offer to import a GitHub repo given by `repo=` ("user/repo" or a tree URL).
- Tool info dialog and generated toolchain docs; Help window with ID docs.
- File | New only lists extensions that have skeleton files.
- "Share Playable Link" packs the game into the URL fragment instead of the query string, so it no longer hits the web server's ~8 KB request-line limit, and encodes it as URL-safe Base64 to avoid percent-encoding. Old `?r=` links still play.
- Removed the Markdown/Showdown platform, the old `script` platform, Google Analytics, Sentry (replaced with a new error-reporting endpoint) and the HTTPS redirect.
### Asset editor
-1
View File
@@ -357,7 +357,6 @@ body {
<p>You can also embed it into an IFRAME:</p>
<textarea rows="4" cols="80" id="embedIframeTextarea" class="cliptext"></textarea>
<button type="button" class="btn btn-primary" data-clipboard-target="#embedIframeTextarea">Copy IFRAME Tag</button>
<p id="embedAdviceWarnIE">Note: These links may be too long for IE/Edge browsers.</p>
<p id="embedAdviceWarnAll">Note: These links may be too long for some browsers.</p>
</div>
<div class="modal-footer">
+52
View File
@@ -572,6 +572,58 @@ export function setScriptLoader(loader:ScriptLoader) : ScriptLoader {
return prev;
}
// URL-safe Base64 (RFC 4648 §5) helpers, used to pack binary blobs
// (e.g. compressed ROMs) into shareable URLs. Unlike standard Base64, the
// alphabet ('-' and '_' instead of '+' and '/'/no padding) is safe inside a
// URL fragment, so it survives without percent-encoding.
const B64URL_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const B64URL_LOOKUP = (() => {
const lut = new Int16Array(128).fill(-1);
for (let i = 0; i < B64URL_CHARS.length; ++i) lut[B64URL_CHARS.charCodeAt(i)] = i;
// accept standard Base64 too, so old '?r=' links still decode
lut[0x2b] = 62; // '+'
lut[0x2f] = 63; // '/'
return lut;
})();
export function encodeBase64Url(data : Uint8Array) : string {
let out = '';
for (let i = 0; i < data.length; i += 3) {
const b0 = data[i];
out += B64URL_CHARS[b0 >> 2];
if (i + 1 < data.length) {
const b1 = data[i + 1];
out += B64URL_CHARS[((b0 & 3) << 4) | (b1 >> 4)];
if (i + 2 < data.length) {
const b2 = data[i + 2];
out += B64URL_CHARS[((b1 & 15) << 2) | (b2 >> 6)];
out += B64URL_CHARS[b2 & 63];
} else {
out += B64URL_CHARS[(b1 & 15) << 2];
}
} else {
out += B64URL_CHARS[(b0 & 3) << 4];
}
}
return out;
}
export function decodeBase64Url(s : string) : Uint8Array {
const out = new Uint8Array((s.length * 3) >> 2);
let bits = 0, nbits = 0, o = 0;
for (let i = 0; i < s.length; ++i) {
const v = B64URL_LOOKUP[s.charCodeAt(i)];
if (v < 0) continue; // skip padding/whitespace/unknown characters
bits = (bits << 6) | v;
nbits += 6;
if (nbits >= 8) {
nbits -= 8;
out[o++] = (bits >> nbits) & 0xff;
}
}
return out.subarray(0, o);
}
export function decodeQueryString(qs : string) : {} {
if (qs.startsWith('?')) qs = qs.substr(1);
var a = qs.split('&');
+10 -16
View File
@@ -2,7 +2,7 @@
import { lzgmini } from "../common/util";
import { PLATFORMS } from "../common/emu";
import { Platform } from "../common/baseplatform";
import { stringToByteArray, getWithBinary, loadScript, getRootBasePlatform } from "../common/util";
import { getWithBinary, loadScript, getRootBasePlatform, decodeBase64Url, decodeQueryString } from "../common/util";
import { StateRecorderImpl } from "../common/recorder";
import { importPlatform } from "../platform/_index";
import { saveAs } from "file-saver";
@@ -14,19 +14,12 @@ export var stateRecorder : StateRecorderImpl;
// external libs (TODO)
declare var ga, GIF;
var _qs = (function (a) {
if (!a || a.length == 0)
return {};
var b = {};
for (var i = 0; i < a.length; ++i) {
var p = a[i].split('=', 2);
if (p.length == 1)
b[p[0]] = "";
else
b[p[0]] = decodeURIComponent(p[1].replace(/\+/g, " "));
}
return b;
})(window.location.search.substr(1).split('&'));
// Payload for the playable-link can be large, so it lives in the URL fragment
// (which never reaches the web server); older links may still put it in the
// query string, so parse both and let the fragment win.
var _qs = decodeQueryString(window.location.search) as { [key: string]: string };
var _hashqs = window.location.hash ? decodeQueryString(window.location.hash.substr(1)) as { [key: string]: string } : {};
for (var _k in _hashqs) _qs[_k] = _hashqs[_k];
// catch errors
function installErrorHandler() {
@@ -155,8 +148,9 @@ async function startPlatform(qs) {
}, 'arraybuffer');
return true;
} else if (lzgvar) {
// decompress from lzg
var lzgrom = stringToByteArray(atob(lzgvar));
// decompress from lzg; the payload is URL-safe Base64 (the decoder also
// accepts standard Base64, so pre-fragment links keep working)
var lzgrom = decodeBase64Url(lzgvar);
rom = new lzgmini().decode(lzgrom);
}
addPageFocusHandlers();
+1 -1
View File
@@ -30,7 +30,7 @@ export interface GHSession extends GHRepoMetadata {
paths?: string[];
}
const README_md_template = "$NAME\n=====\n\n[Open this project in 8bitworkshop](http://8bitworkshop.com/redir.html?platform=$PLATFORM&githubURL=$GITHUBURL&file=$MAINFILE).\n";
const README_md_template = "$NAME\n=====\n\n[Open this project in 8bitworkshop](https://8bitworkshop.com/redir.html?platform=$PLATFORM&githubURL=$GITHUBURL&file=$MAINFILE).\n";
export function getRepos(): { [key: string]: GHRepoMetadata } {
var repos = {};
+10 -12
View File
@@ -1,5 +1,5 @@
import { OutputSoundFile, TAPFile } from '../common/audio/CommodoreTape';
import { byteArrayToString, compressLZG, getBasePlatform, getFilenameForPath, getFilenamePrefix, loadScript } from '../common/util';
import { compressLZG, encodeBase64Url, getBasePlatform, getFilenameForPath, getFilenamePrefix, loadScript } from '../common/util';
import { alertError, alertInfo, setWaitDialog, setWaitProgress } from './dialogs';
import { getCurrentEditorFilename, getCurrentMainFilename, getCurrentOutput, getCurrentProject, getPlatformStore, getWorkerParams, platform, platform_id, projectWindows } from './ui';
import { saveAs } from "file-saver";
@@ -20,22 +20,19 @@ export function _shareEmbedLink(e) {
// TODO: Module is bad var name (conflicts with MAME)
var lzgrom = compressLZG(window['Module'], Array.from(<Uint8Array>getCurrentOutput()));
window['Module'] = null; // so we load it again next time
var lzgb64 = btoa(byteArrayToString(lzgrom));
var embed = {
p: platform_id,
//n: current_project.mainPath,
r: lzgb64
};
var linkqs = $.param(embed);
var fulllink = get8bitworkshopLink(linkqs, 'player.html');
// Pack the compressed ROM into the URL fragment so it never reaches the
// web server (avoids Apache's ~8190-byte LimitRequestLine), and use
// URL-safe Base64 so it needs no percent-encoding.
var lzgb64 = encodeBase64Url(lzgrom);
var linkqs = $.param({ p: platform_id });
var fulllink = get8bitworkshopLink(linkqs, 'player.html', 'r=' + lzgb64);
var iframelink = '<iframe width=640 height=600 src="' + fulllink + '">';
$("#embedLinkTextarea").text(fulllink);
$("#embedIframeTextarea").text(iframelink);
$("#embedLinkModal").modal('show');
$("#embedAdviceWarnAll").hide();
$("#embedAdviceWarnIE").hide();
if (fulllink.length >= 65536) $("#embedAdviceWarnAll").show();
else if (fulllink.length >= 5120) $("#embedAdviceWarnIE").show();
if (fulllink.length >= 65535) $("#embedAdviceWarnAll").show();
});
return true;
}
@@ -49,12 +46,13 @@ function loadClipboardLibrary() {
});
}
function get8bitworkshopLink(linkqs: string, fn: string) {
function get8bitworkshopLink(linkqs: string, fn: string, hash?: string) {
console.log(linkqs);
var loc = window.location;
var prefix = loc.pathname.replace('index.html', '');
var protocol = (loc.host == '8bitworkshop.com') ? 'https:' : loc.protocol;
var fulllink = protocol + '//' + loc.host + prefix + fn + '?' + linkqs;
if (hash) fulllink += '#' + hash;
return fulllink;
}
+23 -1
View File
@@ -2,7 +2,7 @@
import assert from "assert";
import { describe } from "mocha";
import { EmuHalt } from "../../src/common/emu"
import { lzgmini, isProbablyBinary, hex } from "../../src/common/util";
import { lzgmini, isProbablyBinary, hex, encodeBase64Url, decodeBase64Url } from "../../src/common/util";
import { Tokenizer, TokenType } from "../../src/common/tokenizer";
import { OPS_6502 } from "../../src/common/cpu/disasm6502";
import { MOS6502 } from "../../src/common/cpu/MOS6502";
@@ -140,6 +140,28 @@ describe('string functions', function () {
// control bytes are a soft signal; one form feed is fine in source
assert.ok(!isProbablyBinary(null, new TextEncoder().encode("a\fb\n")));
});
it('Should round-trip URL-safe Base64', function () {
for (let len = 0; len <= 64; ++len) {
const data = new Uint8Array(len);
for (let i = 0; i < len; ++i) data[i] = (i * 37 + len * 11) & 0xff;
const enc = encodeBase64Url(data);
assert.ok(!/[+/=]/.test(enc), `output must be URL-safe: ${enc}`);
assert.deepStrictEqual(Array.from(decodeBase64Url(enc)), Array.from(data));
}
});
it('Should encode URL-safe Base64 like standard Base64 (modulo alphabet)', function () {
const data = new Uint8Array([0x00, 0x10, 0xff, 0xfe, 0xfb, 0x42, 0x01]);
const std = Buffer.from(data).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
assert.strictEqual(encodeBase64Url(data), std);
});
it('Should decode legacy standard Base64 (old share links)', function () {
const data = new Uint8Array([0x01, 0x02, 0x03, 0x80, 0xfe, 0xff]);
const std = Buffer.from(data).toString('base64');
assert.deepStrictEqual(Array.from(decodeBase64Url(std)), Array.from(data));
});
});
describe('EmuHalt', function () {