Skip to content

Commit 717e26c

Browse files
committed
Add support for a persisted disk image
Add a mode where we persist the changes made to a chunked disk image in the origin private file system feature that is supported by modern browsers. They also support being able to get a synchronous file handle, which allows us to directly intercept read/write calls from the emulator with no complications. The persistence is handled as an overlay on top of the chunked data, we have a separate dirty chunks bitmask that stores which chunks have been modified. When we're about to read from a chunk, we first load it from the persisted disk. Similarly, when a change to a chunk is made, we mark it as dirty and write it to the persisted disk. This does mean that there is some write amplification (any change writes a whole 256K chunk), but in practice performance appears to be OK (the native file system cache kicks in presumably). Since the disk image is mostly empty, we stored it compressed in Git (to avoid having a large file). We also optimize empty chunks, so that we don't need to store a signature for them, or load them over the network. To feature detect if the browser supports the origin private file system and synchronous file handles, we need to create a temporary worker (FileSystemSyncAccessHandle is only ever defined in workers). We cache the result of this check in localStorage so that we don't need to do it too often. Currently disabled by default, but accessible via the custom instance dialog or the ?saved_hd=true query parameter Updates #152
1 parent bbb3178 commit 717e26c

21 files changed

+416
-12
lines changed

.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ public/Covers
3333

3434
# Cloudflare Worker wrangler CLI state
3535
.wrangler
36+
37+
# We only commit the compressed version, but having the uncompressed version
38+
# around is useful for local development.
39+
Images/Saved HD.dsk

Art/Saved HD Icon.acorn

64 KB
Binary file not shown.

Images/Saved HD.dsk.zip

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:48e273bbffd601335ea6d1b0f5d98dc6b87726ed2a11646f40afe04f2e227317
3+
size 1126897

scripts/import-disks.py

+42
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,9 @@ def write_image_def(image: bytes, name: str, dest_dir: str) -> ImageDef:
384384
return ImageDef(name, image_path)
385385

386386

387+
ZERO_CHUNK = b"\0" * CHUNK_SIZE
388+
389+
387390
def write_chunked_image(image: ImageDef) -> None:
388391
total_size = 0
389392
chunks = []
@@ -397,6 +400,11 @@ def write_chunked_image(image: ImageDef) -> None:
397400
(image.name, ((i + CHUNK_SIZE) / disk_size) * 100))
398401
chunk = image_bytes[i:i + CHUNK_SIZE]
399402
total_size += len(chunk)
403+
# Don't bother storing zero-ed out chunks (common for the saved HD),
404+
# the signature takes up space, and we don't need to load them
405+
if chunk == ZERO_CHUNK:
406+
chunks.append("")
407+
continue
400408
chunk_signature = hashlib.blake2b(chunk, digest_size=16,
401409
salt=salt).hexdigest()
402410
chunks.append(chunk_signature)
@@ -519,6 +527,39 @@ def build_passthrough_image(base_name: str, dest_dir: str) -> ImageDef:
519527
return write_image_def(image_data, base_name, dest_dir)
520528

521529

530+
def build_saved_hd_image(base_name: str, dest_dir: str) -> ImageDef:
531+
# The disk image is compressed since it's mostly empty space and we don't
532+
# want to pay for a lot of Git LFS storage.
533+
with zipfile.ZipFile(os.path.join(paths.IMAGES_DIR, base_name + ".zip"),
534+
"r") as zip:
535+
image_data = zip.read(base_name)
536+
537+
# Also use the Stickies placeholder file to inject the Saved HD Read Me
538+
readme_placeholder = stickies.generate_ttxt_placeholder()
539+
readme_index = image_data.find(readme_placeholder)
540+
541+
if readme_index != -1:
542+
readme_data = "TODO: explain what this is for".encode("macroman")
543+
544+
if len(readme_data) > len(readme_placeholder):
545+
logging.warning(
546+
"Read Me file is too large (%d, placeholder is only %d), "
547+
"skipping customization for %s", len(readme_data),
548+
len(readme_placeholder), base_name)
549+
else:
550+
# Replace the leftover placeholder data, so that TextText does not
551+
# render.
552+
image_data = image_data[:readme_index] + readme_data + \
553+
b"\x00" * (len(readme_placeholder) - len(readme_data)) + \
554+
image_data[readme_index + len(readme_placeholder):]
555+
else:
556+
logging.warning(
557+
"Placeholder file not found in disk image %s, skipping Read Me",
558+
base_name)
559+
560+
return write_image_def(image_data, base_name, dest_dir)
561+
562+
522563
def build_desktop_db(images: typing.List[ImageDef]) -> bytes:
523564
sys.stderr.write("Rebuilding Desktop DB for %s...\n" %
524565
",".join([i.name for i in images]))
@@ -635,6 +676,7 @@ def build_desktop_db(images: typing.List[ImageDef]) -> bytes:
635676
images.append(
636677
build_passthrough_image("Infinite HD (MFS).dsk",
637678
dest_dir=temp_dir))
679+
images.append(build_saved_hd_image("Saved HD.dsk", dest_dir=temp_dir))
638680

639681
for image in images:
640682
write_chunked_image(image)

src/Browser.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ function DiskContents({disk, onRun, setBezelStyle}: DiskContentsProps) {
240240
const runDef = {
241241
disks: [disk],
242242
includeInfiniteHD: true,
243+
includeSavedHD: true,
243244
machine: disk.machines[0],
244245
cdromURLs: [],
245246
debugAudio: false,

src/Custom.css

+5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
display: inline;
1616
}
1717

18+
.Custom-Dialog-Row label.disabled {
19+
color: #999;
20+
pointer-events: none;
21+
}
22+
1823
.Custom-Dialog-Row label:last-of-type {
1924
margin-bottom: 4px;
2025
}

src/Custom.tsx

+28
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {Select} from "./controls/Select";
2121
import {Checkbox} from "./controls/Checkbox";
2222
import {emulatorSupportsAppleTalk} from "./emulator/emulator-common-emulators";
2323
import {CloudflareWorkerEthernetProvider} from "./CloudflareWorkerEthernetProvider";
24+
import classNames from "classnames";
25+
import {canSaveDisks} from "./canSaveDisks";
2426

2527
export function Custom({
2628
defaultDisk = SYSTEM_DISKS_BY_NAME["System 7.1"],
@@ -41,6 +43,7 @@ export function Custom({
4143
disks: [defaultDisk],
4244
cdromURLs: [],
4345
includeInfiniteHD: true,
46+
includeSavedHD: canSaveDisks() && false, // TODO: enable by default
4447
debugFallback: false,
4548
debugAudio: false,
4649
});
@@ -285,6 +288,31 @@ export function Custom({
285288
</div>
286289
</div>
287290

291+
<div className="Custom-Dialog-Row">
292+
<span className="Custom-Dialog-Label" />
293+
<label className={classNames({"disabled": !canSaveDisks()})}>
294+
<Checkbox
295+
appearance={appearance}
296+
disabled={!canSaveDisks()}
297+
checked={runDef.includeSavedHD}
298+
onChange={e =>
299+
setRunDef({
300+
...runDef,
301+
includeSavedHD: e.target.checked,
302+
})
303+
}
304+
/>
305+
Saved HD
306+
</label>
307+
<div className="Custom-Dialog-Description Dialog-Description">
308+
Include the Saved HD disk, an empty 1 GB disk whose contents
309+
are preserved across visits to this site (best effort).
310+
{!canSaveDisks() && (
311+
<div>Not supported by this browser</div>
312+
)}
313+
</div>
314+
</div>
315+
288316
{appleTalkSupported && (
289317
<div className="Custom-Dialog-Row">
290318
<span className="Custom-Dialog-Label" />

src/Mac.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type SystemDiskDef,
3232
INFINITE_HD,
3333
INFINITE_HD_MFS,
34+
SAVED_HD,
3435
} from "./disks";
3536
import {type MachineDefRAMSize, type MachineDef} from "./machines";
3637
import classNames from "classnames";
@@ -39,10 +40,12 @@ import {type Appearance} from "./controls/Appearance";
3940
import {Select} from "./controls/Select";
4041
import {Checkbox} from "./controls/Checkbox";
4142
import {fetchCDROMInfo} from "./cdroms";
43+
import {canSaveDisks} from "./canSaveDisks";
4244

4345
export type MacProps = {
4446
disks: SystemDiskDef[];
4547
includeInfiniteHD: boolean;
48+
includeSavedHD: boolean;
4649
cdroms: EmulatorCDROM[];
4750
initialErrorText?: string;
4851
machine: MachineDef;
@@ -57,6 +60,7 @@ export type MacProps = {
5760
export default function Mac({
5861
disks,
5962
includeInfiniteHD,
63+
includeSavedHD,
6064
cdroms,
6165
initialErrorText,
6266
machine,
@@ -124,6 +128,9 @@ export default function Mac({
124128
emulatorDisks.push(infiniteHd);
125129
}
126130
}
131+
if (includeSavedHD && canSaveDisks()) {
132+
emulatorDisks.push(SAVED_HD);
133+
}
127134
const useSharedMemory =
128135
typeof SharedArrayBuffer !== "undefined" && !debugFallback;
129136
const emulator = new Emulator(
@@ -182,6 +189,8 @@ export default function Mac({
182189
emulatorDidHaveError(emulator: Emulator, error: string) {
183190
if (error.includes("load") && error.includes("/CD-ROM")) {
184191
varz.increment("emulator_error:cdrom_chunk_load");
192+
} else if (error.includes("saved disk")) {
193+
varz.increment("emulator_error:saved_disk");
185194
} else {
186195
varz.increment("emulator_error:other");
187196
}

src/RunDefMac.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export default function RunDefMac({
3636
<Mac
3737
disks={runDef.disks}
3838
includeInfiniteHD={runDef.includeInfiniteHD}
39+
includeSavedHD={runDef.includeSavedHD}
3940
cdroms={cdroms}
4041
initialErrorText={cdromErrorText}
4142
machine={runDef.machine}

src/canSaveDisks-worker.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const supported =
2+
typeof FileSystemFileHandle.prototype.createSyncAccessHandle ===
3+
"function" && typeof FileSystemSyncAccessHandle === "function";
4+
5+
postMessage(supported);

src/canSaveDisks.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Worker from "./canSaveDisks-worker?worker&inline";
2+
3+
export function canSaveDisks() {
4+
if (!navigator.storage?.getDirectory) {
5+
return false;
6+
}
7+
if (typeof FileSystemFileHandle !== "function") {
8+
return false;
9+
}
10+
11+
if (workerSyncFileSupport !== "unknown") {
12+
return workerSyncFileSupport === "supported";
13+
}
14+
function update(support: "supported" | "unsupported") {
15+
if (workerSyncFileSupport === "unsupported") {
16+
// Ignore updates if one mechanism determined that it's unsupported.
17+
return;
18+
}
19+
workerSyncFileSupport = support;
20+
localStorage["workerSyncFileSupport"] = support;
21+
}
22+
23+
// We can't check for the presence of FileSystemSyncAccessHandle in the
24+
// main window, it's only available in workers. So we have to create a
25+
// temporary one to check, but we cache the result in localStorage so that
26+
// we can avoid doing this every time.
27+
if (!worker) {
28+
worker = new Worker();
29+
worker.addEventListener("message", e => {
30+
update(e.data ? "supported" : "unsupported");
31+
worker?.terminate();
32+
});
33+
}
34+
35+
// In private contexts Safari exposes the storage API, but calls to it will
36+
// fail, check for that too.
37+
navigator.storage.getDirectory().catch(() => update("unsupported"));
38+
39+
// Assume supported until we hear otherwise.
40+
return true;
41+
}
42+
43+
let workerSyncFileSupport: "unknown" | "supported" | "unsupported" =
44+
localStorage["workerSyncFileSupport"] ?? "unknown";
45+
46+
let worker: Worker | undefined;

src/disks.ts

+9
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export type EmulatorDiskDef = {
2525
// prefetchChunks are semi-automatically generated -- we will get a
2626
// warning via validateSpecPrefetchChunks() if these are incorrect.
2727
prefetchChunks: number[];
28+
// Changes to this file will be persisted in the origin private file
29+
// system.
30+
persistent?: boolean;
2831
};
2932

3033
export type SystemDiskDef = EmulatorDiskDef & {
@@ -765,3 +768,9 @@ export const INFINITE_HD_MFS: EmulatorDiskDef = {
765768
prefetchChunks: [0, 1, 2],
766769
generatedSpec: () => import("./Data/Infinite HD (MFS).dsk.json"),
767770
};
771+
772+
export const SAVED_HD: EmulatorDiskDef = {
773+
prefetchChunks: [0],
774+
generatedSpec: () => import("./Data/Saved HD.dsk.json"),
775+
persistent: true,
776+
};

src/emulator/emulator-common.ts

+10-4
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export type EmulatorChunkedFileSpec = {
7474
chunks: string[];
7575
chunkSize: number;
7676
prefetchChunks: number[];
77+
persistent?: boolean;
7778
};
7879

7980
export function generateChunkUrl(
@@ -97,16 +98,21 @@ export function generateNextChunkUrl(
9798
return undefined;
9899
}
99100
const [chunkSignature, chunkStr] = match.slice(1);
100-
const chunk = parseInt(chunkStr, 10);
101-
const spec = specs.find(spec => spec.chunks[chunk] === chunkSignature);
101+
const chunkIndex = parseInt(chunkStr, 10);
102+
const spec = specs.find(spec => spec.chunks[chunkIndex] === chunkSignature);
102103
if (!spec) {
103104
console.warn(`Could not find spec that served ${url}`);
104105
return undefined;
105106
}
106-
if (chunk + 1 >= spec.chunks.length) {
107+
if (chunkIndex + 1 >= spec.chunks.length) {
107108
return undefined;
108109
}
109-
return generateChunkUrl(spec, chunk + 1);
110+
const nextChunkSignature = spec.chunks[chunkIndex + 1];
111+
if (!nextChunkSignature) {
112+
// Zero-ed out chunk, we don't need to load it.
113+
return undefined;
114+
}
115+
return generateChunkUrl(spec, chunkIndex + 1);
110116
}
111117

112118
export type EmulatorWorkerConfig = {

src/emulator/emulator-service-worker.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,13 @@ self.addEventListener("message", event => {
3333
(async function () {
3434
const cache = await caches.open(DISK_CACHE_NAME);
3535
const prefetchChunkUrls = [];
36-
for (const chunk of diskFileSpec.prefetchChunks) {
37-
const chunkUrl = generateChunkUrl(diskFileSpec, chunk);
36+
for (const chunkIndex of diskFileSpec.prefetchChunks) {
37+
const chunkSignature = diskFileSpec.chunks[chunkIndex];
38+
if (!chunkSignature) {
39+
// Zero-ed out chunk, skip it.
40+
continue;
41+
}
42+
const chunkUrl = generateChunkUrl(diskFileSpec, chunkIndex);
3843
const cachedResponse = await cache.match(
3944
new Request(chunkUrl)
4045
);

src/emulator/emulator-ui.ts

+1
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export class Emulator {
287287
...diskSpecs[i],
288288
baseUrl: "/Disk",
289289
prefetchChunks: d.prefetchChunks,
290+
persistent: d.persistent,
290291
}));
291292
}
292293
const disks = await loadDisks(this.#config.disks);

0 commit comments

Comments
 (0)