diff --git a/.cursor/rules/codec-packages.mdc b/.cursor/rules/codec-packages.mdc new file mode 100644 index 0000000..4f40fe5 --- /dev/null +++ b/.cursor/rules/codec-packages.mdc @@ -0,0 +1,25 @@ +--- +description: Split Macintosh codecs from Finder UI; keep SIT/rez/dcmp pluggable +alwaysApply: true +--- + +# Codec packages (ClassicStack-web) + +When splitting ClassicStack-web into packages, **do not** put StuffIt, BinHex, MacBinary, ZIP, resource-fork parsing, `dcmp` decompress, icon/BNDL/vers decoders, or rez in the Finder UI package. + +Intended packages (names are indicative): + +- `@classicstack/finder-ui` — FinderWindow, dialogs, tokens +- `@classicstack/catalog` — Catalog / VNode +- `@classicstack/expand` — expander registry + `expandIncoming` +- `@classicstack/stuffit` — bundled SIT (replaceable) +- `@classicstack/resource-fork` — map parse, SparseBytes +- `@classicstack/resource-types` — ICON/icl8/BNDL/vers +- `@classicstack/appledouble` — AppleSingle / AppleDouble / zip sidecars + +Rules: + +- Finder depends on `Catalog` + codec **registries** (`src/fs/codecs.ts`), not on a specific SIT or rez implementation. +- Bundled ids: `sit`, `binhex`, `macbinary`, `zip`, `applesingle`, `dcmp`. Re-register the same id to replace the default. +- Third parties add formats via `registerArchiveCodec`, `registerResourceDecompressor`, `registerResourceTypeDecoder`, `registerRezCodec`. +- New archive or resource formats register a codec; do not add `if (ext === …)` branches in `finder-window.ts`. diff --git a/README.md b/README.md index 831825e..48c4904 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ -# ClassicStackWeb +# ClassicStack-Web +AppleTalk / AFP stack over **WebSerial → TashTalk → LocalTalk** in your **browser**. -Browser AppleTalk / AFP stack over **WebSerial → TashTalk → LocalTalk**. +> ## Need more features? +> Checkout [ClassicStack](https://github.com/ObsoleteMadness/ClassicStack) - a full-featured Apple File Server, +> IPX/NetBeui SMB and Netware Server and Client for Windows, MacOS and Linux. ## Features @@ -15,7 +18,9 @@ Browser AppleTalk / AFP stack over **WebSerial → TashTalk → LocalTalk**. - Advanced → **Extension editor…** maps filename suffixes to Macintosh creator/type plus a comment (saved in localStorage; used on import when there is no AppleDouble metadata) - Dropped BinHex (`.hqx`), MacBinary (`.bin`), StuffIt (`.sit`), and ZIP (`.zip`) are decoded into the inner Macintosh files (name, forks, type/creator); ZIP merges `._` and `__MACOSX` AppleDouble. Toggle via Advanced → **Auto-expand files** - Resource-fork icons (BNDL / ICN# / icl8) with `./icons` system fallbacks and a clearable local type-icon cache -- Advanced → **Resource Fork…** (or Get Info / context menu **Resources…**) lists every resource type, id, and BNDL mapping in the selected file +- Windows `.ico` plus icons embedded in PE/NE `.exe` / `.scr` (and `.dll` / `.cpl`) +- Advanced → **Resource Fork…** (or Get Info / context menu **Resources…**) lists every Macintosh resource type, id, and BNDL mapping — hidden when the volume has no resource forks. Get Info type/creator editors hide when the volume has no Finder info +- View → **Windows Resources…** (or Get Info / context **Windows Resources…** on `.exe` / `.dll` / `.ico`) lists PE/NE RT_* types with icon, bitmap, version, string, and hex previews ## Requirements @@ -44,6 +49,14 @@ See the plan: TashTalk → LLAP/DDP → NBP → ATP → ASP → AFP, with a Virt Protocol codecs mirror [ClassicStack](https://github.com/ObsoleteMadness/ClassicStack). +Finder UI (`src/ui/finder-window.ts`) must stay independent of archive and resource-fork codecs. StuffIt, BinHex, MacBinary, ZIP, Apple compressed resources (`dcmp`), icon/BNDL decoders, Windows ICO/PE/NE icon extractors, and any future **rez** decompiler live under `src/fs/` and register through `src/fs/codecs.ts` (`classicstack-web/fs/codecs`). When this repo splits into packages, those modules become their own packages (`@classicstack/finder-ui`, `@classicstack/expand`, `@classicstack/stuffit`, `@classicstack/resource-fork`, `@classicstack/winicon`, …) so a third party can ship a replacement SIT expander or rez decoder without forking the PWA. + +The Finder sidebar layout is owned by the host: set `RemoteEndpoint.group` / `badge` and implement `FinderHost.sidebarGroups()`. ClassicStack groups local shares vs AppleTalk / SMB / NetWare / EtherDFS clients; the TashTalk PWA keeps a single LocalTalk list. + +The extension→type/creator editor is shared (`ExtensionEditorDialog`). Persistence is a pluggable `ExtensionMapStore`: the PWA uses browser localStorage; ClassicStack’s SPA uses the Go `/extmap` API (Netatalk `extmap.conf`). + +Register with `registerArchiveCodec`, `registerResourceDecompressor`, `registerResourceTypeDecoder`, or `registerRezCodec`. Re-registering the bundled ids (`sit`, `binhex`, `macbinary`, `zip`, `applesingle`, `dcmp`) replaces the default implementation. + ## Credits ClassicStack is indebted to the following source code and authors: @@ -53,12 +66,14 @@ ClassicStack is indebted to the following source code and authors: - [XADMaster](https://github.com/MacPaw/XADMaster) / The Unarchiver — [Dag Ågren](https://github.com/DagAgren), [Dirk Stöcker](https://www.dstoecker.eu/xadmaster.html), and the xadmaster library — StuffIt archive formats (also via [stuffit-rs](https://github.com/benletchford/stuffit-rs)) - [Matthias Wiesmann](https://github.com/wiesmann) for [QuickDrawViewer](https://github.com/wiesmann/QuickDrawViewer) (PICT / QuickDraw opcode layout and PackBits; Apache 2.0) - [Harald Kuhr](https://github.com/haraldk) for [TwelveMonkeys](https://github.com/haraldk/TwelveMonkeys) [imageio-pict](https://github.com/haraldk/TwelveMonkeys/tree/master/imageio/imageio-pict) (PICT bitmap packing; BSD-3-Clause) +- Icons by [Icons8](https://icons8.com/) ## Notes - Guest login (`No User Authent`) plus `Cleartxt Passwrd` and Randnum UAMs when connecting as a client -- Resource-fork icon decoder (ICN# / icl8 / BNDL) for Finder icons; system glyphs from `./icons`; application type icons cached locally (Advanced → Clear icon cache) -- Resource Fork explorer (Advanced → Resource Fork…) dumps types, ids, BNDL/FREF mappings, and decoded icon previews from the selected file +- Resource-fork icon decoder (ICN# / icl8 / BNDL) plus Windows ICO/PE/NE `.exe`/`.scr` icons; system glyphs from `./icons`; type icons cached locally (View → Clear icon cache) +- Resource Fork explorer (View → Resource Fork…) dumps types, ids, BNDL/FREF mappings, and decoded icon previews — omitted when the volume has no resource forks +- Windows resource explorer (View → Windows Resources…) dumps PE/NE RT_* types from the data fork (icons, bitmaps, version info, string tables, manifests) with a hex dump, modeled on the Macintosh viewer - Desktop DB Add/GetIcon is implemented on the server - AFP Write uses a simplified path; full WriteContinue parity may need tuning against specific Mac OS versions - Netboot ChainBoot keeps the selected HFS image in browser memory for the session (writes are not persisted back to the file) diff --git a/icons/classic/AppleShare.gif b/icons/classic/AppleShare.gif new file mode 100644 index 0000000..f8016bb Binary files /dev/null and b/icons/classic/AppleShare.gif differ diff --git a/icons/classic/MacTCP Prep.gif b/icons/classic/MacTCP Prep.gif new file mode 100644 index 0000000..ddc723a Binary files /dev/null and b/icons/classic/MacTCP Prep.gif differ diff --git a/icons/classic/apple-ip-gw.png b/icons/classic/apple-ip-gw.png new file mode 100644 index 0000000..bf8eaae Binary files /dev/null and b/icons/classic/apple-ip-gw.png differ diff --git a/icons/classic/appletalk-cp.png b/icons/classic/appletalk-cp.png new file mode 100644 index 0000000..4b5ad39 Binary files /dev/null and b/icons/classic/appletalk-cp.png differ diff --git a/icons/classic/appletalk-mac.png b/icons/classic/appletalk-mac.png new file mode 100644 index 0000000..d8e291f Binary files /dev/null and b/icons/classic/appletalk-mac.png differ diff --git a/icons/classic/bios.png b/icons/classic/bios.png new file mode 100644 index 0000000..289e4cf Binary files /dev/null and b/icons/classic/bios.png differ diff --git a/icons/classic/dos-prompt.png b/icons/classic/dos-prompt.png new file mode 100644 index 0000000..c033798 Binary files /dev/null and b/icons/classic/dos-prompt.png differ diff --git a/icons/classic/ethernet gateway.png b/icons/classic/ethernet gateway.png new file mode 100644 index 0000000..8a37ed3 Binary files /dev/null and b/icons/classic/ethernet gateway.png differ diff --git a/icons/classic/ethertalk.png b/icons/classic/ethertalk.png new file mode 100644 index 0000000..a4b1606 Binary files /dev/null and b/icons/classic/ethertalk.png differ diff --git a/icons/classic/gateway-settings.png b/icons/classic/gateway-settings.png new file mode 100644 index 0000000..d6cdf32 Binary files /dev/null and b/icons/classic/gateway-settings.png differ diff --git a/icons/classic/group.png b/icons/classic/group.png new file mode 100644 index 0000000..f993e7c Binary files /dev/null and b/icons/classic/group.png differ diff --git a/icons/classic/ibm-share.png b/icons/classic/ibm-share.png new file mode 100644 index 0000000..5610469 Binary files /dev/null and b/icons/classic/ibm-share.png differ diff --git a/icons/classic/ip-tunnel.png b/icons/classic/ip-tunnel.png new file mode 100644 index 0000000..b00183b Binary files /dev/null and b/icons/classic/ip-tunnel.png differ diff --git a/icons/classic/ipx-cp.png b/icons/classic/ipx-cp.png new file mode 100644 index 0000000..e8bdb59 Binary files /dev/null and b/icons/classic/ipx-cp.png differ diff --git a/icons/classic/localtalk-bridge-cp.png b/icons/classic/localtalk-bridge-cp.png new file mode 100644 index 0000000..cb3d295 Binary files /dev/null and b/icons/classic/localtalk-bridge-cp.png differ diff --git a/icons/classic/localtalk-bridge.png b/icons/classic/localtalk-bridge.png new file mode 100644 index 0000000..7977118 Binary files /dev/null and b/icons/classic/localtalk-bridge.png differ diff --git a/icons/classic/mac-group.png b/icons/classic/mac-group.png new file mode 100644 index 0000000..09316e2 Binary files /dev/null and b/icons/classic/mac-group.png differ diff --git a/icons/classic/macipx-cp.png b/icons/classic/macipx-cp.png new file mode 100644 index 0000000..67338e8 Binary files /dev/null and b/icons/classic/macipx-cp.png differ diff --git a/icons/classic/macipx.png b/icons/classic/macipx.png new file mode 100644 index 0000000..714d608 Binary files /dev/null and b/icons/classic/macipx.png differ diff --git a/icons/classic/mactcp.png b/icons/classic/mactcp.png new file mode 100644 index 0000000..9fb7845 Binary files /dev/null and b/icons/classic/mactcp.png differ diff --git a/icons/classic/macx.png b/icons/classic/macx.png new file mode 100644 index 0000000..5a82f21 Binary files /dev/null and b/icons/classic/macx.png differ diff --git a/icons/classic/monitor.png b/icons/classic/monitor.png new file mode 100644 index 0000000..f0629cb Binary files /dev/null and b/icons/classic/monitor.png differ diff --git a/icons/classic/novell.png b/icons/classic/novell.png new file mode 100644 index 0000000..8b8778f Binary files /dev/null and b/icons/classic/novell.png differ diff --git a/icons/classic/open-transport-cp.png b/icons/classic/open-transport-cp.png new file mode 100644 index 0000000..d48d57b Binary files /dev/null and b/icons/classic/open-transport-cp.png differ diff --git a/icons/classic/osi-cp.png b/icons/classic/osi-cp.png new file mode 100644 index 0000000..ac04750 Binary files /dev/null and b/icons/classic/osi-cp.png differ diff --git a/icons/classic/pc-cp.png b/icons/classic/pc-cp.png new file mode 100644 index 0000000..4d7d3fa Binary files /dev/null and b/icons/classic/pc-cp.png differ diff --git a/icons/classic/pc-disk.png b/icons/classic/pc-disk.png new file mode 100644 index 0000000..d612837 Binary files /dev/null and b/icons/classic/pc-disk.png differ diff --git a/icons/classic/phonenet.png b/icons/classic/phonenet.png new file mode 100644 index 0000000..438fe01 Binary files /dev/null and b/icons/classic/phonenet.png differ diff --git a/icons/classic/prompt-cp.png b/icons/classic/prompt-cp.png new file mode 100644 index 0000000..83e0677 Binary files /dev/null and b/icons/classic/prompt-cp.png differ diff --git a/icons/classic/serial-switch.png b/icons/classic/serial-switch.png new file mode 100644 index 0000000..439ba34 Binary files /dev/null and b/icons/classic/serial-switch.png differ diff --git a/icons/classic/tcp-cp.png b/icons/classic/tcp-cp.png new file mode 100644 index 0000000..4d4c269 Binary files /dev/null and b/icons/classic/tcp-cp.png differ diff --git a/icons/classic/user.png b/icons/classic/user.png new file mode 100644 index 0000000..1f72a14 Binary files /dev/null and b/icons/classic/user.png differ diff --git a/icons/classic/users.png b/icons/classic/users.png new file mode 100644 index 0000000..c313bd5 Binary files /dev/null and b/icons/classic/users.png differ diff --git a/icons/classic/windows-mac.png b/icons/classic/windows-mac.png new file mode 100644 index 0000000..971c4da Binary files /dev/null and b/icons/classic/windows-mac.png differ diff --git a/icons/classic/windows-share.png b/icons/classic/windows-share.png new file mode 100644 index 0000000..f8b1d8c Binary files /dev/null and b/icons/classic/windows-share.png differ diff --git a/icons/classic/windows-share2.png b/icons/classic/windows-share2.png new file mode 100644 index 0000000..4b59e96 Binary files /dev/null and b/icons/classic/windows-share2.png differ diff --git a/icons/classic/windows-share3.png b/icons/classic/windows-share3.png new file mode 100644 index 0000000..c4f6c3d Binary files /dev/null and b/icons/classic/windows-share3.png differ diff --git a/icons/ui/NetwareBridge.png b/icons/ui/NetwareBridge.png new file mode 100644 index 0000000..22611f0 Binary files /dev/null and b/icons/ui/NetwareBridge.png differ diff --git a/icons/ui/ShareApple.png b/icons/ui/ShareApple.png new file mode 100644 index 0000000..00dd398 Binary files /dev/null and b/icons/ui/ShareApple.png differ diff --git a/icons/ui/ShareDos.png b/icons/ui/ShareDos.png new file mode 100644 index 0000000..45a7866 Binary files /dev/null and b/icons/ui/ShareDos.png differ diff --git a/icons/ui/ShareFolder.png b/icons/ui/ShareFolder.png new file mode 100644 index 0000000..d4575b0 Binary files /dev/null and b/icons/ui/ShareFolder.png differ diff --git a/icons/ui/ShareNetware.png b/icons/ui/ShareNetware.png new file mode 100644 index 0000000..37fad8a Binary files /dev/null and b/icons/ui/ShareNetware.png differ diff --git a/icons/ui/ShareWindows.png b/icons/ui/ShareWindows.png new file mode 100644 index 0000000..0058f8c Binary files /dev/null and b/icons/ui/ShareWindows.png differ diff --git a/icons/ui/icons8-25-de-abril-bridge-100.png b/icons/ui/icons8-25-de-abril-bridge-100.png new file mode 100644 index 0000000..59c5685 Binary files /dev/null and b/icons/ui/icons8-25-de-abril-bridge-100.png differ diff --git a/icons/ui/icons8-25-de-abril-bridge-50.png b/icons/ui/icons8-25-de-abril-bridge-50.png new file mode 100644 index 0000000..1f9fdd7 Binary files /dev/null and b/icons/ui/icons8-25-de-abril-bridge-50.png differ diff --git a/icons/ui/icons8-apple-computer-logo-rainbow-100.png b/icons/ui/icons8-apple-computer-logo-rainbow-100.png new file mode 100644 index 0000000..d1018b9 Binary files /dev/null and b/icons/ui/icons8-apple-computer-logo-rainbow-100.png differ diff --git a/icons/ui/icons8-apple-computer-logo-rainbow-50.png b/icons/ui/icons8-apple-computer-logo-rainbow-50.png new file mode 100644 index 0000000..3cfef9f Binary files /dev/null and b/icons/ui/icons8-apple-computer-logo-rainbow-50.png differ diff --git a/icons/ui/icons8-apple-inc-100.png b/icons/ui/icons8-apple-inc-100.png new file mode 100644 index 0000000..11ab8e4 Binary files /dev/null and b/icons/ui/icons8-apple-inc-100.png differ diff --git a/icons/ui/icons8-apple-inc-50.png b/icons/ui/icons8-apple-inc-50.png new file mode 100644 index 0000000..5fd6db9 Binary files /dev/null and b/icons/ui/icons8-apple-inc-50.png differ diff --git a/icons/ui/icons8-application-window-32.png b/icons/ui/icons8-application-window-32.png new file mode 100644 index 0000000..24fea79 Binary files /dev/null and b/icons/ui/icons8-application-window-32.png differ diff --git a/icons/ui/icons8-application-window-48.png b/icons/ui/icons8-application-window-48.png new file mode 100644 index 0000000..078899f Binary files /dev/null and b/icons/ui/icons8-application-window-48.png differ diff --git a/icons/ui/icons8-application-window-96.png b/icons/ui/icons8-application-window-96.png new file mode 100644 index 0000000..b1f8546 Binary files /dev/null and b/icons/ui/icons8-application-window-96.png differ diff --git a/icons/ui/icons8-archive-32.png b/icons/ui/icons8-archive-32.png new file mode 100644 index 0000000..04e875e Binary files /dev/null and b/icons/ui/icons8-archive-32.png differ diff --git a/icons/ui/icons8-archive-48.png b/icons/ui/icons8-archive-48.png new file mode 100644 index 0000000..67c4edb Binary files /dev/null and b/icons/ui/icons8-archive-48.png differ diff --git a/icons/ui/icons8-archive-96.png b/icons/ui/icons8-archive-96.png new file mode 100644 index 0000000..622a02a Binary files /dev/null and b/icons/ui/icons8-archive-96.png differ diff --git a/icons/ui/icons8-archive-folder-100.png b/icons/ui/icons8-archive-folder-100.png new file mode 100644 index 0000000..a372845 Binary files /dev/null and b/icons/ui/icons8-archive-folder-100.png differ diff --git a/icons/ui/icons8-archive-folder-32.png b/icons/ui/icons8-archive-folder-32.png new file mode 100644 index 0000000..714a558 Binary files /dev/null and b/icons/ui/icons8-archive-folder-32.png differ diff --git a/icons/ui/icons8-archive-folder-48.png b/icons/ui/icons8-archive-folder-48.png new file mode 100644 index 0000000..9c9431c Binary files /dev/null and b/icons/ui/icons8-archive-folder-48.png differ diff --git a/icons/ui/icons8-archive-folder-50.png b/icons/ui/icons8-archive-folder-50.png new file mode 100644 index 0000000..2232a57 Binary files /dev/null and b/icons/ui/icons8-archive-folder-50.png differ diff --git a/icons/ui/icons8-archive-folder-96.png b/icons/ui/icons8-archive-folder-96.png new file mode 100644 index 0000000..7c6afec Binary files /dev/null and b/icons/ui/icons8-archive-folder-96.png differ diff --git a/icons/ui/icons8-backend-development-100.png b/icons/ui/icons8-backend-development-100.png new file mode 100644 index 0000000..acd1811 Binary files /dev/null and b/icons/ui/icons8-backend-development-100.png differ diff --git a/icons/ui/icons8-backend-development-50.png b/icons/ui/icons8-backend-development-50.png new file mode 100644 index 0000000..4543f82 Binary files /dev/null and b/icons/ui/icons8-backend-development-50.png differ diff --git a/icons/ui/icons8-binary-file-32.png b/icons/ui/icons8-binary-file-32.png new file mode 100644 index 0000000..b7fd9fd Binary files /dev/null and b/icons/ui/icons8-binary-file-32.png differ diff --git a/icons/ui/icons8-binary-file-48.png b/icons/ui/icons8-binary-file-48.png new file mode 100644 index 0000000..92a2551 Binary files /dev/null and b/icons/ui/icons8-binary-file-48.png differ diff --git a/icons/ui/icons8-binary-file-96.png b/icons/ui/icons8-binary-file-96.png new file mode 100644 index 0000000..73e55b2 Binary files /dev/null and b/icons/ui/icons8-binary-file-96.png differ diff --git a/icons/ui/icons8-bios-100.png b/icons/ui/icons8-bios-100.png new file mode 100644 index 0000000..f1c0f67 Binary files /dev/null and b/icons/ui/icons8-bios-100.png differ diff --git a/icons/ui/icons8-bios-50.png b/icons/ui/icons8-bios-50.png new file mode 100644 index 0000000..d45ad73 Binary files /dev/null and b/icons/ui/icons8-bios-50.png differ diff --git a/icons/ui/icons8-broadcasting-100.png b/icons/ui/icons8-broadcasting-100.png new file mode 100644 index 0000000..74583d8 Binary files /dev/null and b/icons/ui/icons8-broadcasting-100.png differ diff --git a/icons/ui/icons8-broadcasting-50.png b/icons/ui/icons8-broadcasting-50.png new file mode 100644 index 0000000..cdbf7db Binary files /dev/null and b/icons/ui/icons8-broadcasting-50.png differ diff --git a/icons/ui/icons8-c-drive-2-100.png b/icons/ui/icons8-c-drive-2-100.png new file mode 100644 index 0000000..5bbe0c2 Binary files /dev/null and b/icons/ui/icons8-c-drive-2-100.png differ diff --git a/icons/ui/icons8-c-drive-2-50.png b/icons/ui/icons8-c-drive-2-50.png new file mode 100644 index 0000000..283722b Binary files /dev/null and b/icons/ui/icons8-c-drive-2-50.png differ diff --git a/icons/ui/icons8-code-file-32.png b/icons/ui/icons8-code-file-32.png new file mode 100644 index 0000000..3435175 Binary files /dev/null and b/icons/ui/icons8-code-file-32.png differ diff --git a/icons/ui/icons8-code-file-48.png b/icons/ui/icons8-code-file-48.png new file mode 100644 index 0000000..fca0c55 Binary files /dev/null and b/icons/ui/icons8-code-file-48.png differ diff --git a/icons/ui/icons8-code-file-96.png b/icons/ui/icons8-code-file-96.png new file mode 100644 index 0000000..9e27a27 Binary files /dev/null and b/icons/ui/icons8-code-file-96.png differ diff --git a/icons/ui/icons8-command-line-100.png b/icons/ui/icons8-command-line-100.png new file mode 100644 index 0000000..ed09f5f Binary files /dev/null and b/icons/ui/icons8-command-line-100.png differ diff --git a/icons/ui/icons8-command-line-32.png b/icons/ui/icons8-command-line-32.png new file mode 100644 index 0000000..6a53122 Binary files /dev/null and b/icons/ui/icons8-command-line-32.png differ diff --git a/icons/ui/icons8-command-line-48.png b/icons/ui/icons8-command-line-48.png new file mode 100644 index 0000000..ea0375f Binary files /dev/null and b/icons/ui/icons8-command-line-48.png differ diff --git a/icons/ui/icons8-command-line-50.png b/icons/ui/icons8-command-line-50.png new file mode 100644 index 0000000..b25e097 Binary files /dev/null and b/icons/ui/icons8-command-line-50.png differ diff --git a/icons/ui/icons8-command-line-96.png b/icons/ui/icons8-command-line-96.png new file mode 100644 index 0000000..971d926 Binary files /dev/null and b/icons/ui/icons8-command-line-96.png differ diff --git a/icons/ui/icons8-connected-100.png b/icons/ui/icons8-connected-100.png new file mode 100644 index 0000000..cf1bfd4 Binary files /dev/null and b/icons/ui/icons8-connected-100.png differ diff --git a/icons/ui/icons8-connected-50.png b/icons/ui/icons8-connected-50.png new file mode 100644 index 0000000..c022394 Binary files /dev/null and b/icons/ui/icons8-connected-50.png differ diff --git a/icons/ui/icons8-console-100.png b/icons/ui/icons8-console-100.png new file mode 100644 index 0000000..6f05e9c Binary files /dev/null and b/icons/ui/icons8-console-100.png differ diff --git a/icons/ui/icons8-console-50.png b/icons/ui/icons8-console-50.png new file mode 100644 index 0000000..278917f Binary files /dev/null and b/icons/ui/icons8-console-50.png differ diff --git a/icons/ui/icons8-disconnected-100.png b/icons/ui/icons8-disconnected-100.png new file mode 100644 index 0000000..45c6e61 Binary files /dev/null and b/icons/ui/icons8-disconnected-100.png differ diff --git a/icons/ui/icons8-disconnected-50.png b/icons/ui/icons8-disconnected-50.png new file mode 100644 index 0000000..afd3f91 Binary files /dev/null and b/icons/ui/icons8-disconnected-50.png differ diff --git a/icons/ui/icons8-dll-32.png b/icons/ui/icons8-dll-32.png new file mode 100644 index 0000000..88759c0 Binary files /dev/null and b/icons/ui/icons8-dll-32.png differ diff --git a/icons/ui/icons8-dll-48.png b/icons/ui/icons8-dll-48.png new file mode 100644 index 0000000..b43115b Binary files /dev/null and b/icons/ui/icons8-dll-48.png differ diff --git a/icons/ui/icons8-dll-96.png b/icons/ui/icons8-dll-96.png new file mode 100644 index 0000000..71f78cc Binary files /dev/null and b/icons/ui/icons8-dll-96.png differ diff --git a/icons/ui/icons8-dos-100.png b/icons/ui/icons8-dos-100.png new file mode 100644 index 0000000..9b2fd07 Binary files /dev/null and b/icons/ui/icons8-dos-100.png differ diff --git a/icons/ui/icons8-dos-50.png b/icons/ui/icons8-dos-50.png new file mode 100644 index 0000000..bcc4af9 Binary files /dev/null and b/icons/ui/icons8-dos-50.png differ diff --git a/icons/ui/icons8-edit-property-100.png b/icons/ui/icons8-edit-property-100.png new file mode 100644 index 0000000..c3f4718 Binary files /dev/null and b/icons/ui/icons8-edit-property-100.png differ diff --git a/icons/ui/icons8-edit-property-50.png b/icons/ui/icons8-edit-property-50.png new file mode 100644 index 0000000..f2e7d53 Binary files /dev/null and b/icons/ui/icons8-edit-property-50.png differ diff --git a/icons/ui/icons8-electronics-100.png b/icons/ui/icons8-electronics-100.png new file mode 100644 index 0000000..1033af2 Binary files /dev/null and b/icons/ui/icons8-electronics-100.png differ diff --git a/icons/ui/icons8-electronics-50.png b/icons/ui/icons8-electronics-50.png new file mode 100644 index 0000000..32dfe6f Binary files /dev/null and b/icons/ui/icons8-electronics-50.png differ diff --git a/icons/ui/icons8-environment-care-100.png b/icons/ui/icons8-environment-care-100.png new file mode 100644 index 0000000..5f5ed14 Binary files /dev/null and b/icons/ui/icons8-environment-care-100.png differ diff --git a/icons/ui/icons8-environment-care-50.png b/icons/ui/icons8-environment-care-50.png new file mode 100644 index 0000000..c384d7a Binary files /dev/null and b/icons/ui/icons8-environment-care-50.png differ diff --git a/icons/ui/icons8-exe-32.png b/icons/ui/icons8-exe-32.png new file mode 100644 index 0000000..0b0a863 Binary files /dev/null and b/icons/ui/icons8-exe-32.png differ diff --git a/icons/ui/icons8-exe-48.png b/icons/ui/icons8-exe-48.png new file mode 100644 index 0000000..2ab2d42 Binary files /dev/null and b/icons/ui/icons8-exe-48.png differ diff --git a/icons/ui/icons8-exe-96.png b/icons/ui/icons8-exe-96.png new file mode 100644 index 0000000..006c446 Binary files /dev/null and b/icons/ui/icons8-exe-96.png differ diff --git a/icons/ui/icons8-fork-100.png b/icons/ui/icons8-fork-100.png new file mode 100644 index 0000000..aac46f5 Binary files /dev/null and b/icons/ui/icons8-fork-100.png differ diff --git a/icons/ui/icons8-fork-50.png b/icons/ui/icons8-fork-50.png new file mode 100644 index 0000000..15231b8 Binary files /dev/null and b/icons/ui/icons8-fork-50.png differ diff --git a/icons/ui/icons8-gear-100.png b/icons/ui/icons8-gear-100.png new file mode 100644 index 0000000..3704442 Binary files /dev/null and b/icons/ui/icons8-gear-100.png differ diff --git a/icons/ui/icons8-gear-50.png b/icons/ui/icons8-gear-50.png new file mode 100644 index 0000000..98e7856 Binary files /dev/null and b/icons/ui/icons8-gear-50.png differ diff --git a/icons/ui/icons8-gif-32.png b/icons/ui/icons8-gif-32.png new file mode 100644 index 0000000..9632387 Binary files /dev/null and b/icons/ui/icons8-gif-32.png differ diff --git a/icons/ui/icons8-gif-48.png b/icons/ui/icons8-gif-48.png new file mode 100644 index 0000000..ed07bdc Binary files /dev/null and b/icons/ui/icons8-gif-48.png differ diff --git a/icons/ui/icons8-gif-96.png b/icons/ui/icons8-gif-96.png new file mode 100644 index 0000000..1cb4fe2 Binary files /dev/null and b/icons/ui/icons8-gif-96.png differ diff --git a/icons/ui/icons8-happy-mac-100.png b/icons/ui/icons8-happy-mac-100.png new file mode 100644 index 0000000..a150eb6 Binary files /dev/null and b/icons/ui/icons8-happy-mac-100.png differ diff --git a/icons/ui/icons8-happy-mac-50.png b/icons/ui/icons8-happy-mac-50.png new file mode 100644 index 0000000..a957ed1 Binary files /dev/null and b/icons/ui/icons8-happy-mac-50.png differ diff --git a/icons/ui/icons8-hdd-100.png b/icons/ui/icons8-hdd-100.png new file mode 100644 index 0000000..044e5d6 Binary files /dev/null and b/icons/ui/icons8-hdd-100.png differ diff --git a/icons/ui/icons8-hdd-50.png b/icons/ui/icons8-hdd-50.png new file mode 100644 index 0000000..24f9d2b Binary files /dev/null and b/icons/ui/icons8-hdd-50.png differ diff --git a/icons/ui/icons8-ibm-100.png b/icons/ui/icons8-ibm-100.png new file mode 100644 index 0000000..ae7fa3a Binary files /dev/null and b/icons/ui/icons8-ibm-100.png differ diff --git a/icons/ui/icons8-ibm-50.png b/icons/ui/icons8-ibm-50.png new file mode 100644 index 0000000..fbff1b1 Binary files /dev/null and b/icons/ui/icons8-ibm-50.png differ diff --git a/icons/ui/icons8-ibm-pc-dos-5.0-logo-100.png b/icons/ui/icons8-ibm-pc-dos-5.0-logo-100.png new file mode 100644 index 0000000..7a292a5 Binary files /dev/null and b/icons/ui/icons8-ibm-pc-dos-5.0-logo-100.png differ diff --git a/icons/ui/icons8-ibm-pc-dos-5.0-logo-50.png b/icons/ui/icons8-ibm-pc-dos-5.0-logo-50.png new file mode 100644 index 0000000..b41ad7b Binary files /dev/null and b/icons/ui/icons8-ibm-pc-dos-5.0-logo-50.png differ diff --git a/icons/ui/icons8-image-32.png b/icons/ui/icons8-image-32.png new file mode 100644 index 0000000..c5cb8a0 Binary files /dev/null and b/icons/ui/icons8-image-32.png differ diff --git a/icons/ui/icons8-image-48.png b/icons/ui/icons8-image-48.png new file mode 100644 index 0000000..8db6665 Binary files /dev/null and b/icons/ui/icons8-image-48.png differ diff --git a/icons/ui/icons8-image-96.png b/icons/ui/icons8-image-96.png new file mode 100644 index 0000000..8ef44be Binary files /dev/null and b/icons/ui/icons8-image-96.png differ diff --git a/icons/ui/icons8-image-file-32.png b/icons/ui/icons8-image-file-32.png new file mode 100644 index 0000000..aa08d8b Binary files /dev/null and b/icons/ui/icons8-image-file-32.png differ diff --git a/icons/ui/icons8-image-file-48.png b/icons/ui/icons8-image-file-48.png new file mode 100644 index 0000000..2953430 Binary files /dev/null and b/icons/ui/icons8-image-file-48.png differ diff --git a/icons/ui/icons8-image-file-96.png b/icons/ui/icons8-image-file-96.png new file mode 100644 index 0000000..8d58bc2 Binary files /dev/null and b/icons/ui/icons8-image-file-96.png differ diff --git a/icons/ui/icons8-integrated-circuit-100.png b/icons/ui/icons8-integrated-circuit-100.png new file mode 100644 index 0000000..2fad6fc Binary files /dev/null and b/icons/ui/icons8-integrated-circuit-100.png differ diff --git a/icons/ui/icons8-integrated-circuit-50.png b/icons/ui/icons8-integrated-circuit-50.png new file mode 100644 index 0000000..b6a9efd Binary files /dev/null and b/icons/ui/icons8-integrated-circuit-50.png differ diff --git a/icons/ui/icons8-jpg-32.png b/icons/ui/icons8-jpg-32.png new file mode 100644 index 0000000..170d20c Binary files /dev/null and b/icons/ui/icons8-jpg-32.png differ diff --git a/icons/ui/icons8-jpg-48.png b/icons/ui/icons8-jpg-48.png new file mode 100644 index 0000000..3e79f78 Binary files /dev/null and b/icons/ui/icons8-jpg-48.png differ diff --git a/icons/ui/icons8-jpg-96.png b/icons/ui/icons8-jpg-96.png new file mode 100644 index 0000000..509609a Binary files /dev/null and b/icons/ui/icons8-jpg-96.png differ diff --git a/icons/ui/icons8-layers-100.png b/icons/ui/icons8-layers-100.png new file mode 100644 index 0000000..f9ebbbc Binary files /dev/null and b/icons/ui/icons8-layers-100.png differ diff --git a/icons/ui/icons8-layers-50.png b/icons/ui/icons8-layers-50.png new file mode 100644 index 0000000..29b6fef Binary files /dev/null and b/icons/ui/icons8-layers-50.png differ diff --git a/icons/ui/icons8-mac-client-100.png b/icons/ui/icons8-mac-client-100.png new file mode 100644 index 0000000..2175831 Binary files /dev/null and b/icons/ui/icons8-mac-client-100.png differ diff --git a/icons/ui/icons8-mac-client-50.png b/icons/ui/icons8-mac-client-50.png new file mode 100644 index 0000000..f91cd02 Binary files /dev/null and b/icons/ui/icons8-mac-client-50.png differ diff --git a/icons/ui/icons8-mac-folder-100.png b/icons/ui/icons8-mac-folder-100.png new file mode 100644 index 0000000..a2b0a74 Binary files /dev/null and b/icons/ui/icons8-mac-folder-100.png differ diff --git a/icons/ui/icons8-mac-folder-50.png b/icons/ui/icons8-mac-folder-50.png new file mode 100644 index 0000000..6dae1eb Binary files /dev/null and b/icons/ui/icons8-mac-folder-50.png differ diff --git a/icons/ui/icons8-mac-logo-100.png b/icons/ui/icons8-mac-logo-100.png new file mode 100644 index 0000000..7794a80 Binary files /dev/null and b/icons/ui/icons8-mac-logo-100.png differ diff --git a/icons/ui/icons8-mac-logo-50.png b/icons/ui/icons8-mac-logo-50.png new file mode 100644 index 0000000..f4ed8b1 Binary files /dev/null and b/icons/ui/icons8-mac-logo-50.png differ diff --git a/icons/ui/icons8-moment-sharing-100.png b/icons/ui/icons8-moment-sharing-100.png new file mode 100644 index 0000000..ebd900f Binary files /dev/null and b/icons/ui/icons8-moment-sharing-100.png differ diff --git a/icons/ui/icons8-moment-sharing-50.png b/icons/ui/icons8-moment-sharing-50.png new file mode 100644 index 0000000..bd5351a Binary files /dev/null and b/icons/ui/icons8-moment-sharing-50.png differ diff --git a/icons/ui/icons8-monitor-100.png b/icons/ui/icons8-monitor-100.png new file mode 100644 index 0000000..847292e Binary files /dev/null and b/icons/ui/icons8-monitor-100.png differ diff --git a/icons/ui/icons8-monitor-50.png b/icons/ui/icons8-monitor-50.png new file mode 100644 index 0000000..4908597 Binary files /dev/null and b/icons/ui/icons8-monitor-50.png differ diff --git a/icons/ui/icons8-multicast-100.png b/icons/ui/icons8-multicast-100.png new file mode 100644 index 0000000..fe332a9 Binary files /dev/null and b/icons/ui/icons8-multicast-100.png differ diff --git a/icons/ui/icons8-multicast-50.png b/icons/ui/icons8-multicast-50.png new file mode 100644 index 0000000..4ff44bb Binary files /dev/null and b/icons/ui/icons8-multicast-50.png differ diff --git a/icons/ui/icons8-netware-logo-100.png b/icons/ui/icons8-netware-logo-100.png new file mode 100644 index 0000000..0465373 Binary files /dev/null and b/icons/ui/icons8-netware-logo-100.png differ diff --git a/icons/ui/icons8-netware-logo-50.png b/icons/ui/icons8-netware-logo-50.png new file mode 100644 index 0000000..0ba34b5 Binary files /dev/null and b/icons/ui/icons8-netware-logo-50.png differ diff --git a/icons/ui/icons8-network-card-100.png b/icons/ui/icons8-network-card-100.png new file mode 100644 index 0000000..a058678 Binary files /dev/null and b/icons/ui/icons8-network-card-100.png differ diff --git a/icons/ui/icons8-network-card-50.png b/icons/ui/icons8-network-card-50.png new file mode 100644 index 0000000..4f89001 Binary files /dev/null and b/icons/ui/icons8-network-card-50.png differ diff --git a/icons/ui/icons8-network-gateway-100.png b/icons/ui/icons8-network-gateway-100.png new file mode 100644 index 0000000..ab1f73f Binary files /dev/null and b/icons/ui/icons8-network-gateway-100.png differ diff --git a/icons/ui/icons8-network-gateway-50.png b/icons/ui/icons8-network-gateway-50.png new file mode 100644 index 0000000..ae3fe59 Binary files /dev/null and b/icons/ui/icons8-network-gateway-50.png differ diff --git a/icons/ui/icons8-notification-100.png b/icons/ui/icons8-notification-100.png new file mode 100644 index 0000000..e811ffb Binary files /dev/null and b/icons/ui/icons8-notification-100.png differ diff --git a/icons/ui/icons8-notification-50.png b/icons/ui/icons8-notification-50.png new file mode 100644 index 0000000..2ef8155 Binary files /dev/null and b/icons/ui/icons8-notification-50.png differ diff --git a/icons/ui/icons8-old-computer-100.png b/icons/ui/icons8-old-computer-100.png new file mode 100644 index 0000000..4985411 Binary files /dev/null and b/icons/ui/icons8-old-computer-100.png differ diff --git a/icons/ui/icons8-old-computer-50.png b/icons/ui/icons8-old-computer-50.png new file mode 100644 index 0000000..c3be1b2 Binary files /dev/null and b/icons/ui/icons8-old-computer-50.png differ diff --git a/icons/ui/icons8-online-100.png b/icons/ui/icons8-online-100.png new file mode 100644 index 0000000..e94bfe5 Binary files /dev/null and b/icons/ui/icons8-online-100.png differ diff --git a/icons/ui/icons8-online-50.png b/icons/ui/icons8-online-50.png new file mode 100644 index 0000000..972e2d3 Binary files /dev/null and b/icons/ui/icons8-online-50.png differ diff --git a/icons/ui/icons8-pdf-1-32.png b/icons/ui/icons8-pdf-1-32.png new file mode 100644 index 0000000..5bc7640 Binary files /dev/null and b/icons/ui/icons8-pdf-1-32.png differ diff --git a/icons/ui/icons8-pdf-1-48.png b/icons/ui/icons8-pdf-1-48.png new file mode 100644 index 0000000..654dfe7 Binary files /dev/null and b/icons/ui/icons8-pdf-1-48.png differ diff --git a/icons/ui/icons8-pdf-1-96.png b/icons/ui/icons8-pdf-1-96.png new file mode 100644 index 0000000..2602675 Binary files /dev/null and b/icons/ui/icons8-pdf-1-96.png differ diff --git a/icons/ui/icons8-pdf-32.png b/icons/ui/icons8-pdf-32.png new file mode 100644 index 0000000..3a11d2e Binary files /dev/null and b/icons/ui/icons8-pdf-32.png differ diff --git a/icons/ui/icons8-pdf-48.png b/icons/ui/icons8-pdf-48.png new file mode 100644 index 0000000..680836c Binary files /dev/null and b/icons/ui/icons8-pdf-48.png differ diff --git a/icons/ui/icons8-pdf-96.png b/icons/ui/icons8-pdf-96.png new file mode 100644 index 0000000..f99b54c Binary files /dev/null and b/icons/ui/icons8-pdf-96.png differ diff --git a/icons/ui/icons8-ps2-female-100.png b/icons/ui/icons8-ps2-female-100.png new file mode 100644 index 0000000..16db343 Binary files /dev/null and b/icons/ui/icons8-ps2-female-100.png differ diff --git a/icons/ui/icons8-ps2-female-50.png b/icons/ui/icons8-ps2-female-50.png new file mode 100644 index 0000000..ae16c38 Binary files /dev/null and b/icons/ui/icons8-ps2-female-50.png differ diff --git a/icons/ui/icons8-pseudo-minidin-8b-diagram-100.png b/icons/ui/icons8-pseudo-minidin-8b-diagram-100.png new file mode 100644 index 0000000..3958552 Binary files /dev/null and b/icons/ui/icons8-pseudo-minidin-8b-diagram-100.png differ diff --git a/icons/ui/icons8-pseudo-minidin-8b-diagram-50.png b/icons/ui/icons8-pseudo-minidin-8b-diagram-50.png new file mode 100644 index 0000000..9c8abaa Binary files /dev/null and b/icons/ui/icons8-pseudo-minidin-8b-diagram-50.png differ diff --git a/icons/ui/icons8-radio-tower-100.png b/icons/ui/icons8-radio-tower-100.png new file mode 100644 index 0000000..ddad200 Binary files /dev/null and b/icons/ui/icons8-radio-tower-100.png differ diff --git a/icons/ui/icons8-radio-tower-50.png b/icons/ui/icons8-radio-tower-50.png new file mode 100644 index 0000000..98f824c Binary files /dev/null and b/icons/ui/icons8-radio-tower-50.png differ diff --git a/icons/ui/icons8-resources-100.png b/icons/ui/icons8-resources-100.png new file mode 100644 index 0000000..da2cae5 Binary files /dev/null and b/icons/ui/icons8-resources-100.png differ diff --git a/icons/ui/icons8-resources-50.png b/icons/ui/icons8-resources-50.png new file mode 100644 index 0000000..9b7ae6d Binary files /dev/null and b/icons/ui/icons8-resources-50.png differ diff --git a/icons/ui/icons8-rj45-100.png b/icons/ui/icons8-rj45-100.png new file mode 100644 index 0000000..7fb9a3d Binary files /dev/null and b/icons/ui/icons8-rj45-100.png differ diff --git a/icons/ui/icons8-rj45-50.png b/icons/ui/icons8-rj45-50.png new file mode 100644 index 0000000..f2d8604 Binary files /dev/null and b/icons/ui/icons8-rj45-50.png differ diff --git a/icons/ui/icons8-router-symbol-100.png b/icons/ui/icons8-router-symbol-100.png new file mode 100644 index 0000000..5a42d10 Binary files /dev/null and b/icons/ui/icons8-router-symbol-100.png differ diff --git a/icons/ui/icons8-router-symbol-50.png b/icons/ui/icons8-router-symbol-50.png new file mode 100644 index 0000000..51cba94 Binary files /dev/null and b/icons/ui/icons8-router-symbol-50.png differ diff --git a/icons/ui/icons8-scroll-32.png b/icons/ui/icons8-scroll-32.png new file mode 100644 index 0000000..95f3fe8 Binary files /dev/null and b/icons/ui/icons8-scroll-32.png differ diff --git a/icons/ui/icons8-scroll-48.png b/icons/ui/icons8-scroll-48.png new file mode 100644 index 0000000..c327686 Binary files /dev/null and b/icons/ui/icons8-scroll-48.png differ diff --git a/icons/ui/icons8-scroll-96.png b/icons/ui/icons8-scroll-96.png new file mode 100644 index 0000000..a7379f2 Binary files /dev/null and b/icons/ui/icons8-scroll-96.png differ diff --git a/icons/ui/icons8-server-100.png b/icons/ui/icons8-server-100.png new file mode 100644 index 0000000..3daa97f Binary files /dev/null and b/icons/ui/icons8-server-100.png differ diff --git a/icons/ui/icons8-server-50.png b/icons/ui/icons8-server-50.png new file mode 100644 index 0000000..75f4f3d Binary files /dev/null and b/icons/ui/icons8-server-50.png differ diff --git a/icons/ui/icons8-service-100.png b/icons/ui/icons8-service-100.png new file mode 100644 index 0000000..2d50bee Binary files /dev/null and b/icons/ui/icons8-service-100.png differ diff --git a/icons/ui/icons8-service-50.png b/icons/ui/icons8-service-50.png new file mode 100644 index 0000000..c9e12f2 Binary files /dev/null and b/icons/ui/icons8-service-50.png differ diff --git a/icons/ui/icons8-services-100.png b/icons/ui/icons8-services-100.png new file mode 100644 index 0000000..e0baf83 Binary files /dev/null and b/icons/ui/icons8-services-100.png differ diff --git a/icons/ui/icons8-services-50.png b/icons/ui/icons8-services-50.png new file mode 100644 index 0000000..a9f36f1 Binary files /dev/null and b/icons/ui/icons8-services-50.png differ diff --git a/icons/ui/icons8-settings-1-100.png b/icons/ui/icons8-settings-1-100.png new file mode 100644 index 0000000..e958997 Binary files /dev/null and b/icons/ui/icons8-settings-1-100.png differ diff --git a/icons/ui/icons8-settings-1-50.png b/icons/ui/icons8-settings-1-50.png new file mode 100644 index 0000000..4effe62 Binary files /dev/null and b/icons/ui/icons8-settings-1-50.png differ diff --git a/icons/ui/icons8-settings-100.png b/icons/ui/icons8-settings-100.png new file mode 100644 index 0000000..b265de4 Binary files /dev/null and b/icons/ui/icons8-settings-100.png differ diff --git a/icons/ui/icons8-settings-50.png b/icons/ui/icons8-settings-50.png new file mode 100644 index 0000000..baad32e Binary files /dev/null and b/icons/ui/icons8-settings-50.png differ diff --git a/icons/ui/icons8-shutdown-100.png b/icons/ui/icons8-shutdown-100.png new file mode 100644 index 0000000..19e7b78 Binary files /dev/null and b/icons/ui/icons8-shutdown-100.png differ diff --git a/icons/ui/icons8-shutdown-50.png b/icons/ui/icons8-shutdown-50.png new file mode 100644 index 0000000..d281d96 Binary files /dev/null and b/icons/ui/icons8-shutdown-50.png differ diff --git a/icons/ui/icons8-switch-100.png b/icons/ui/icons8-switch-100.png new file mode 100644 index 0000000..b3d1231 Binary files /dev/null and b/icons/ui/icons8-switch-100.png differ diff --git a/icons/ui/icons8-switch-50.png b/icons/ui/icons8-switch-50.png new file mode 100644 index 0000000..82c68c0 Binary files /dev/null and b/icons/ui/icons8-switch-50.png differ diff --git a/icons/ui/icons8-unicast-100.png b/icons/ui/icons8-unicast-100.png new file mode 100644 index 0000000..db2915e Binary files /dev/null and b/icons/ui/icons8-unicast-100.png differ diff --git a/icons/ui/icons8-unicast-50.png b/icons/ui/icons8-unicast-50.png new file mode 100644 index 0000000..10105a0 Binary files /dev/null and b/icons/ui/icons8-unicast-50.png differ diff --git a/icons/ui/icons8-usb-2-100.png b/icons/ui/icons8-usb-2-100.png new file mode 100644 index 0000000..b32e042 Binary files /dev/null and b/icons/ui/icons8-usb-2-100.png differ diff --git a/icons/ui/icons8-usb-2-50.png b/icons/ui/icons8-usb-2-50.png new file mode 100644 index 0000000..3709306 Binary files /dev/null and b/icons/ui/icons8-usb-2-50.png differ diff --git a/icons/ui/icons8-usb-connector-100.png b/icons/ui/icons8-usb-connector-100.png new file mode 100644 index 0000000..194ed51 Binary files /dev/null and b/icons/ui/icons8-usb-connector-100.png differ diff --git a/icons/ui/icons8-usb-connector-50.png b/icons/ui/icons8-usb-connector-50.png new file mode 100644 index 0000000..6a612dc Binary files /dev/null and b/icons/ui/icons8-usb-connector-50.png differ diff --git a/icons/ui/icons8-user-groups-100.png b/icons/ui/icons8-user-groups-100.png new file mode 100644 index 0000000..63fb942 Binary files /dev/null and b/icons/ui/icons8-user-groups-100.png differ diff --git a/icons/ui/icons8-user-groups-50.png b/icons/ui/icons8-user-groups-50.png new file mode 100644 index 0000000..9d5c2d3 Binary files /dev/null and b/icons/ui/icons8-user-groups-50.png differ diff --git a/icons/ui/icons8-vpn-100.png b/icons/ui/icons8-vpn-100.png new file mode 100644 index 0000000..f46f1f5 Binary files /dev/null and b/icons/ui/icons8-vpn-100.png differ diff --git a/icons/ui/icons8-vpn-50.png b/icons/ui/icons8-vpn-50.png new file mode 100644 index 0000000..3cd91c3 Binary files /dev/null and b/icons/ui/icons8-vpn-50.png differ diff --git a/icons/ui/icons8-wi-fi-100.png b/icons/ui/icons8-wi-fi-100.png new file mode 100644 index 0000000..4e6cbe0 Binary files /dev/null and b/icons/ui/icons8-wi-fi-100.png differ diff --git a/icons/ui/icons8-wi-fi-50.png b/icons/ui/icons8-wi-fi-50.png new file mode 100644 index 0000000..a20d1f5 Binary files /dev/null and b/icons/ui/icons8-wi-fi-50.png differ diff --git a/icons/ui/icons8-windows-95-100.png b/icons/ui/icons8-windows-95-100.png new file mode 100644 index 0000000..812826f Binary files /dev/null and b/icons/ui/icons8-windows-95-100.png differ diff --git a/icons/ui/icons8-windows-95-50.png b/icons/ui/icons8-windows-95-50.png new file mode 100644 index 0000000..6fb52ff Binary files /dev/null and b/icons/ui/icons8-windows-95-50.png differ diff --git a/icons/ui/icons8-wired-network-100.png b/icons/ui/icons8-wired-network-100.png new file mode 100644 index 0000000..bed633b Binary files /dev/null and b/icons/ui/icons8-wired-network-100.png differ diff --git a/icons/ui/icons8-wired-network-50.png b/icons/ui/icons8-wired-network-50.png new file mode 100644 index 0000000..482d8ee Binary files /dev/null and b/icons/ui/icons8-wired-network-50.png differ diff --git a/icons/ui/icons8-work-boot-100.png b/icons/ui/icons8-work-boot-100.png new file mode 100644 index 0000000..2b0b8d4 Binary files /dev/null and b/icons/ui/icons8-work-boot-100.png differ diff --git a/icons/ui/icons8-work-boot-50.png b/icons/ui/icons8-work-boot-50.png new file mode 100644 index 0000000..5cb80d1 Binary files /dev/null and b/icons/ui/icons8-work-boot-50.png differ diff --git a/package.json b/package.json index 96bb2eb..6c82ec0 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,71 @@ "version": "0.1.0", "license": "GPL-3.0", "type": "module", + "exports": { + "./ui/finder": "./src/ui/finder-window.ts", + "./ui/menu-bar-track": "./src/ui/menu-bar-track.ts", + "./ui/finder-host": "./src/ui/finder-host.ts", + "./ui/finder-sidebar": "./src/ui/finder-sidebar.ts", + "./ui/extension-editor": "./src/ui/extension-editor-dialog.ts", + "./ui/styles": "./src/ui/styles/tokens.css", + "./ui/login": "./src/ui/login-dialog.ts", + "./ui/alert": "./src/ui/alert-dialog.ts", + "./ui/name-conflict": "./src/ui/name-conflict-dialog.ts", + "./ui/get-info": "./src/ui/get-info-window.ts", + "./ui/resource-explorer": "./src/ui/resource-fork-explorer.ts", + "./ui/win-resource-explorer": "./src/ui/win-resource-explorer.ts", + "./finder": "./src/finder/index.ts", + "./finder/api": "./src/finder/api.ts", + "./finder/api-catalog": "./src/finder/api-catalog.ts", + "./finder/afp-finder-api": "./src/finder/afp-finder-api.ts", + "./finder/afp-finder-host": "./src/finder/afp-finder-host.ts", + "./finder/bind-catalog": "./src/finder/bind-catalog.ts", + "./finder/progress": "./src/finder/progress.ts", + "./finder/types": "./src/finder/types.ts", + "./protocol/afp": "./src/protocol/afp/constants.ts", + "./protocol/asp": "./src/protocol/asp.ts", + "./protocol/atp": "./src/protocol/atp.ts", + "./protocol/ddp": "./src/protocol/ddp.ts", + "./protocol/nbp": "./src/protocol/nbp.ts", + "./protocol/llap": "./src/protocol/llap.ts", + "./protocol/abp": "./src/protocol/abp.ts", + "./protocol/macroman": "./src/protocol/macroman.ts", + "./protocol/binary": "./src/protocol/binary.ts", + "./protocol/host-filename": "./src/protocol/host-filename.ts", + "./protocol/crc16": "./src/protocol/crc16.ts", + "./protocol/crc32": "./src/protocol/crc32.ts", + "./services/afp-client": "./src/services/afp-client/client.ts", + "./services/afp-client/commands": "./src/services/afp-client/commands.ts", + "./services/afp-server": "./src/services/afp-server/server.ts", + "./services/asp-client": "./src/services/asp-client.ts", + "./services/atp-client": "./src/services/atp-client.ts", + "./services/atp-server": "./src/services/atp-server.ts", + "./services/nbp": "./src/services/nbp.ts", + "./transport/tashtalk": "./src/transport/tashtalk.ts", + "./transport/webserial": "./src/transport/webserial.ts", + "./net/stack": "./src/net/stack.ts", + "./fs/catalog": "./src/fs/virtual-fs.ts", + "./fs/virtual-fs": "./src/fs/virtual-fs.ts", + "./fs/remote-vfs": "./src/fs/remote-vfs.ts", + "./fs/catalog-caps": "./src/fs/catalog-caps.ts", + "./fs/volume-chrome": "./src/fs/volume-chrome.ts", + "./fs/capability-catalog": "./src/fs/capability-catalog.ts", + "./fs/extension-map": "./src/fs/extension-map.ts", + "./fs/extension-map-netatalk": "./src/fs/extension-map-netatalk.ts", + "./fs/codecs": "./src/fs/codecs.ts", + "./fs/expand": "./src/fs/expand-incoming.ts", + "./fs/stuffit": "./src/fs/stuffit.ts", + "./fs/binhex": "./src/fs/binhex.ts", + "./fs/macbinary": "./src/fs/macbinary.ts", + "./fs/zip": "./src/fs/zip.ts", + "./fs/mac-file": "./src/fs/mac-file.ts", + "./fs/finder-info": "./src/fs/finder-info.ts", + "./fs/name-conflict": "./src/fs/name-conflict.ts", + "./fs/winicon": "./src/fs/winicon/index.ts", + "./fs/resource-fork": "./src/fs/resource-fork.ts", + "./fs/resource-compress": "./src/fs/resource-compress.ts", + "./fs/appledouble": "./src/fs/appledouble.ts" + }, "scripts": { "dev": "vite", "build": "tsc && vite build", diff --git a/src/finder/afp-finder-api.ts b/src/finder/afp-finder-api.ts new file mode 100644 index 0000000..e93f17f --- /dev/null +++ b/src/finder/afp-finder-api.ts @@ -0,0 +1,185 @@ +import type { CatalogWithBackend, FinderAPI } from './api'; +import type { FinderNodeDto, FinderSessionDto, OpProgress, CrossTransferRequest, CnidNodeDto } from './types'; +import { bindCatalog } from './bind-catalog'; +import { copyBetweenCatalogs, expandOnCatalog, moveBetweenCatalogs } from './catalog-copy'; +import type { Catalog, VNode } from '../fs/virtual-fs'; +import type { NodeRef } from '../fs/catalog-caps'; + +const LOCAL_SESSION = 'local'; + +/** + * In-browser FinderAPI over VirtualFS / RemoteVfs. Copy, move, and expand stay + * in the client (TashTalk owns the AFP session). Same-server files use FPCopyFile. + */ +export class AfpFinderAPI implements FinderAPI { + readonly backendId = 'afp'; + private readonly catalogs = new Map(); + + bindLocal(vfs: Catalog): CatalogWithBackend { + return this.register(LOCAL_SESSION, vfs); + } + + bindRemote(sessionId: string, cat: Catalog): CatalogWithBackend { + return this.register(sessionId, cat); + } + + unbind(sessionId: string): void { + this.catalogs.delete(sessionId); + } + + localCatalog(): CatalogWithBackend | undefined { + const cat = this.catalogs.get(LOCAL_SESSION); + return cat ? bindCatalog(cat, this, LOCAL_SESSION) : undefined; + } + + register(sessionId: string, cat: Catalog): CatalogWithBackend { + this.catalogs.set(sessionId, cat); + return bindCatalog(cat, this, sessionId); + } + + openCatalog(session: FinderSessionDto): Catalog { + const cat = this.catalogs.get(session.sessionId); + if (!cat) throw new Error(`no catalog for session ${session.sessionId}`); + return bindCatalog(cat, this, session.sessionId); + } + + async getNode(sessionId: string, ref: NodeRef): Promise { + const node = await this.catalogFor(sessionId).get(ref); + if (!node) throw new Error('not found'); + return this.toNode(node); + } + async children(sessionId: string, parent: NodeRef): Promise { + return (await this.catalogFor(sessionId).children(parent)).map((n) => this.toNode(n)); + } + async lookup(sessionId: string, parent: NodeRef, name: string): Promise { + const node = await this.catalogFor(sessionId).lookup(parent, name); + return node ? this.toNode(node) : null; + } + async mkdir(sessionId: string, parent: NodeRef, name: string): Promise { + return this.toNode(await this.catalogFor(sessionId).mkdir(parent, name)); + } + async create( + sessionId: string, + parent: NodeRef, + name: string, + body?: { data?: Uint8Array; resource?: Uint8Array; finderInfo?: Uint8Array }, + ): Promise { + return this.toNode( + await this.catalogFor(sessionId).createFile( + parent, + name, + body?.data ?? new Uint8Array(), + body?.resource ?? new Uint8Array(), + body?.finderInfo, + ), + ); + } + async rename(sessionId: string, ref: NodeRef, name: string): Promise { + await this.catalogFor(sessionId).rename(ref, name); + } + async move(sessionId: string, ref: NodeRef, parent: NodeRef): Promise { + await this.catalogFor(sessionId).move(ref, parent); + } + async remove(sessionId: string, ref: NodeRef): Promise { + await this.catalogFor(sessionId).remove(ref); + } + async readFork(sessionId: string, ref: NodeRef, resource: boolean): Promise { + const node = await this.catalogFor(sessionId).ensureContent(ref); + if (!node) throw new Error('not found'); + return resource ? node.resource : node.data; + } + async writeFork(sessionId: string, ref: NodeRef, resource: boolean, _off: number, data: Uint8Array): Promise { + const cat = this.catalogFor(sessionId); + const node = await cat.ensureContent(ref); + if (!node || node.isDir) throw new Error('not found'); + if (resource) node.resource = data; + else node.data = data; + await cat.put(node); + } + async writeFinderInfo(sessionId: string, ref: NodeRef, finderInfo: Uint8Array): Promise { + const cat = this.catalogFor(sessionId); + const node = await cat.get(ref); + if (!node) throw new Error('not found'); + node.finderInfo = finderInfo; + await cat.put(node); + } + async writeAttrs(sessionId: string, ref: NodeRef, patch: Record): Promise { + const cat = this.catalogFor(sessionId); + if (cat.setAttrs) { + await cat.setAttrs(ref, patch); + return; + } + throw new Error('setAttrs not supported'); + } + async resolvePath(sessionId: string, path: string): Promise { + const node = await this.catalogFor(sessionId).resolvePath(path); + return node ? this.toNode(node) : null; + } + async pathOf(sessionId: string, ref: NodeRef): Promise { + return this.catalogFor(sessionId).pathOf(ref); + } + + copy(req: CrossTransferRequest, signal?: AbortSignal): AsyncIterable { + return this.runCopy(req, signal); + } + moveAcross(req: CrossTransferRequest, signal?: AbortSignal): AsyncIterable { + return this.runMove(req, signal); + } + expand(sessionId: string, ref: NodeRef, signal?: AbortSignal): AsyncIterable { + return expandOnCatalog(this.catalogFor(sessionId), ref, signal); + } + + private async *runCopy(req: CrossTransferRequest, signal?: AbortSignal): AsyncGenerator { + yield* copyBetweenCatalogs(this.catalogFor(req.srcSession), this.catalogFor(req.destSession), req, signal); + yield { phase: 'copying', destName: req.destName, destParentId: req.destParentId, done: true }; + } + + private async *runMove(req: CrossTransferRequest, signal?: AbortSignal): AsyncGenerator { + yield* moveBetweenCatalogs(this.catalogFor(req.srcSession), this.catalogFor(req.destSession), req, signal); + yield { phase: 'moving', destName: req.destName, destParentId: req.destParentId, done: true }; + } + + private catalogFor(sessionId: string): Catalog { + const cat = this.catalogs.get(sessionId); + if (!cat) throw new Error(sessionId === LOCAL_SESSION ? 'no local catalog' : 'no AFP session'); + return cat; + } + + private toNode(node: VNode): FinderNodeDto { + if (node.addr === 'path') { + return { + addr: 'path', + path: node.path, + parentPath: node.parentPath, + name: node.name, + isDir: node.isDir, + dataBytes: node.dataBytes ?? node.data.length, + resourceBytes: node.resourceBytes ?? node.resource.length, + createDate: node.createDate, + modDate: node.modDate, + accessDate: node.accessDate, + backupDate: node.backupDate, + shortName: node.shortName, + mediumName: node.mediumName, + attrs: node.attrs, + }; + } + const cnid: CnidNodeDto & FinderNodeDto = { + addr: 'cnid', + id: node.id, + parentId: node.parentId, + name: node.name, + isDir: node.isDir, + dataBytes: node.dataBytes ?? node.data.length, + resourceBytes: node.resourceBytes ?? node.resource.length, + createDate: node.createDate, + modDate: node.modDate, + accessDate: node.accessDate, + backupDate: node.backupDate, + shortName: node.shortName, + mediumName: node.mediumName, + attrs: node.attrs, + }; + return cnid; + } +} diff --git a/src/finder/afp-finder-host.test.ts b/src/finder/afp-finder-host.test.ts new file mode 100644 index 0000000..8495d55 --- /dev/null +++ b/src/finder/afp-finder-host.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AfpFinderHost, type AfpFinderUi } from './afp-finder-host'; +import type { AfpClient } from '../services/afp-client/client'; +import type { AtpClient } from '../services/atp-client'; +import type { VirtualFS } from '../fs/virtual-fs'; +import type { RemoteEndpoint } from '../ui/finder-host'; + +const ep: RemoteEndpoint = { id: 'Mac Classic', kind: 'afp', title: 'Mac Classic' }; + +function fakeClient(over?: Partial): AfpClient { + return { + loggedIn: true, + serverName: 'Mac Classic', + uams: ['Cleartxt passwrd', 'Randnum exchange'], + volumes: [{ flags: 0, name: 'OpenRetroSCSI 7.5.3' }], + close: vi.fn(async () => undefined), + ...over, + } as unknown as AfpClient; +} + +function host(): AfpFinderHost { + const ui = { + finder: { + setStatus: vi.fn(), + setServers: vi.fn(), + setNetworkScanning: vi.fn(), + unmountRemote: vi.fn(), + }, + login: {} as AfpFinderUi['login'], + alert: {} as AfpFinderUi['alert'], + nameConflict: {} as AfpFinderUi['nameConflict'], + } satisfies AfpFinderUi; + const h = new AfpFinderHost({} as never, {} as VirtualFS, ui); + h.atp = {} as AtpClient; + return h; +} + +describe('AfpFinderHost.beginRemote', () => { + it('reuses a live authenticated session instead of logging it out', async () => { + const h = host(); + const client = fakeClient(); + h.remote = client; + h.remoteNbpName = 'Mac Classic'; + const lookup = vi.fn(async () => []); + h.nbp = { lookup } as never; + + const info = await h.beginRemote(ep); + + expect(client.close).not.toHaveBeenCalled(); + expect(lookup).not.toHaveBeenCalled(); + expect(h.remote).toBe(client); + expect(info.serverName).toBe('Mac Classic'); + expect(info.volumes).toEqual(['OpenRetroSCSI 7.5.3']); + expect(info.uams).toEqual(['Cleartxt passwrd', 'Randnum exchange']); + }); + + it('matches the open session by endpoint title as well as id', async () => { + const h = host(); + h.remote = fakeClient(); + h.remoteNbpName = 'Mac Classic'; + const lookup = vi.fn(async () => []); + h.nbp = { lookup } as never; + + await h.beginRemote({ id: '0.2:252', kind: 'afp', title: 'mac classic' }); + + expect(lookup).not.toHaveBeenCalled(); + }); + + it('reconnects when the session was never authenticated', async () => { + const h = host(); + const client = fakeClient({ loggedIn: false }); + h.remote = client; + h.remoteNbpName = 'Mac Classic'; + h.nbp = { lookup: vi.fn(async () => []) } as never; + + await expect(h.beginRemote(ep)).rejects.toThrow(/is not on the network/); + }); + + it('reconnects when the endpoint is a different server', async () => { + const h = host(); + const client = fakeClient(); + h.remote = client; + h.remoteNbpName = 'Mac Classic'; + h.nbp = { lookup: vi.fn(async () => []) } as never; + + await expect( + h.beginRemote({ id: 'Other Mac', kind: 'afp', title: 'Other Mac' }), + ).rejects.toThrow(/is not on the network/); + }); +}); diff --git a/src/finder/afp-finder-host.ts b/src/finder/afp-finder-host.ts new file mode 100644 index 0000000..7816030 --- /dev/null +++ b/src/finder/afp-finder-host.ts @@ -0,0 +1,317 @@ +/** FinderHost over TashTalk + in-browser AFP (ClassicStack-web PWA). */ + +import { WebSerialPort } from '../transport/webserial'; +import { LocalTalkStack } from '../net/stack'; +import { NbpService, type LookupResult } from '../services/nbp'; +import { AtpClient } from '../services/atp-client'; +import { AfpServer } from '../services/afp-server/server'; +import { AfpClient, type AfpServerNotice } from '../services/afp-client/client'; +import { VirtualFS, type Catalog } from '../fs/virtual-fs'; +import { RemoteVfs } from '../fs/remote-vfs'; +import { addWelcomePack, seedWelcomePackIfNeeded } from '../fs/welcome-pack'; +import type { WelcomePackProgress } from '../fs/welcome-pack'; +import type { + Credentials, + FinderHost, + RemoteEndpoint, + SessionInfo, +} from '../ui/finder-host'; +import type { LoginDialog } from '../ui/login-dialog'; +import type { AlertDialog } from '../ui/alert-dialog'; +import type { NameConflictDialog } from '../ui/name-conflict-dialog'; +import type { NameConflictChoice } from '../fs/name-conflict'; +import { log } from '../util/logger'; +import * as asp from '../protocol/asp'; +import { AfpFinderAPI } from './afp-finder-api'; + +export const WEB_SERIAL_HELP = + 'ClassicStack needs the Web Serial API to connect a TashTalk adaptor. Use Google Chrome (desktop or Android) or Microsoft Edge, over HTTPS or localhost. On a phone, plug the adaptor in with USB-C / OTG; 1 Mbaud with hardware flow control may fail on some Android USB stacks.'; + +const AFP_SCAN_MS = 10_000; + +export type AfpFinderUi = { + finder: { + setStatus(msg: string, opts?: { busy?: boolean }): void; + setServers(list: RemoteEndpoint[]): void; + setNetworkScanning(v: boolean): void; + unmountRemote(status?: string): void; + }; + login: LoginDialog; + alert: AlertDialog; + nameConflict: NameConflictDialog; + /** After a LocalTalk node is claimed (netboot, status). */ + onClaimed?: (net: number, node: number) => void; + /** After serial disconnect (stop netboot, reset traffic). */ + onDisconnect?: () => void; +}; + +/** + * Composition root for the PWA: serial → LocalTalk → AFP client/server, and a + * FinderAPI that copies between Browser Share and remote volumes in-process. + */ +export class AfpFinderHost implements FinderHost { + readonly api = new AfpFinderAPI(); + stack: LocalTalkStack | null = null; + nbp: NbpService | null = null; + atp: AtpClient | null = null; + afpServer: AfpServer | null = null; + remote: AfpClient | null = null; + remoteNbpName = ''; + + private afpScanTimer: ReturnType | null = null; + private afpScanBusy = false; + private lastAfpScanKey = ''; + + constructor( + readonly serial: WebSerialPort, + readonly vfs: VirtualFS, + private readonly ui: AfpFinderUi, + ) { + this.api.bindLocal(vfs); + } + + isConnected(): boolean { + return this.serial.connected; + } + + nodeLabel(): string { + return this.stack && this.stack.node + ? `node ${this.stack.node.toString(16).padStart(2, '0').toUpperCase()} net ${this.stack.network}` + : ''; + } + + localTitle(): string { + return 'Browser Share'; + } + + async connectTransport(): Promise { + if (!WebSerialPort.supported()) { + this.ui.alert.show('Web Serial is not supported', WEB_SERIAL_HELP); + throw new Error('Web Serial is not supported'); + } + await this.serial.connect(); + this.stack = new LocalTalkStack(this.serial); + this.nbp = new NbpService(this.stack); + this.atp = new AtpClient(this.stack); + this.afpServer = new AfpServer(this.stack, this.vfs, { volumeName: 'Browser Share', serverName: 'ClassicStack' }); + this.nbp.register('ClassicStack', 'AFPServer', this.afpServer.socket()); + this.stack.onClaimed((net, node) => { + log.info(`LocalTalk node claimed: ${node} (net ${net})`, 'stack'); + this.ui.finder.setStatus(`LocalTalk node claimed: ${node} (net ${net}). Sharing “Browser Share”.`); + this.ui.onClaimed?.(net, node); + this.startAfpServerScan(); + }); + log.info('Serial connected; starting node claim', 'serial'); + await this.stack.startClaim(); + } + + async disconnectTransport(): Promise { + this.stopAfpServerScan(); + await this.remote?.close().catch(() => undefined); + this.remote = null; + this.remoteNbpName = ''; + this.stack?.stop(); + this.stack = null; + this.nbp = null; + this.atp = null; + this.afpServer = null; + await this.serial.disconnect(); + this.ui.onDisconnect?.(); + log.info('Serial disconnected', 'serial'); + } + + async refreshNetwork(): Promise { + const list = await this.scanAfpServers('manual'); + return list.map((s) => this.toEndpoint(s)); + } + + async beginRemote(ep: RemoteEndpoint): Promise { + if (!this.atp) throw new Error('not connected'); + // Callers re-enter this to refresh a server’s name and volumes. Opening a + // second ASP session would log the first one out, so a live authenticated + // session answers from what it already knows — otherwise the next FPOpenVol + // lands on a fresh unauthenticated session and comes back UserNotAuth. + if (this.remote?.loggedIn && this.matchesRemote(ep)) { + log.trace(`AFP session to “${this.remoteNbpName}” still open; reusing it`, 'afp'); + return this.sessionInfo(this.remote); + } + const list = this.nbp ? await this.nbp.lookup('=', 'AFPServer') : []; + const h = + list.find((x) => x.object === ep.id || x.object === ep.title) ?? + list.find((x) => x.object.toLowerCase() === ep.title.toLowerCase()); + if (!h) throw new Error(`AFP server “${ep.title}” is not on the network`); + log.info(`AFP GetStatus/OpenSess ${h.object} (${h.network}.${h.node}:${h.socket || asp.DefaultSLS})`, 'afp'); + await this.remote?.close().catch(() => undefined); + this.remote = await AfpClient.openSession(this.atp, h.network, h.node, h.socket || asp.DefaultSLS); + this.remoteNbpName = h.object; + this.attachRemoteNotices(this.remote); + return this.sessionInfo(this.remote); + } + + /** Does `ep` name the server the current session is open to? */ + private matchesRemote(ep: RemoteEndpoint): boolean { + const name = this.remoteNbpName; + if (!name) return false; + return name === ep.id || name === ep.title || name.toLowerCase() === ep.title.toLowerCase(); + } + + private sessionInfo(client: AfpClient): SessionInfo { + return { + serverName: client.serverName, + volumes: client.volumes.map((v) => v.name), + allowGuest: client.uams.some((u) => /no user authent/i.test(u)), + uams: client.uams, + }; + } + + async loginRemote(creds: Credentials): Promise { + if (!this.remote) throw new Error('no AFP session'); + await this.remote.login(creds); + return this.remote.volumes.map((v) => v.name); + } + + async openVolume(name: string): Promise { + if (!this.remote) throw new Error('not logged in'); + const volId = await this.remote.openVolume(name); + log.info(`Mounted remote ${this.remote.serverName || this.remoteNbpName}:${name} (vol ${volId})`, 'afp'); + const sessionId = `${this.remoteNbpName}:${name}`; + return this.api.bindRemote(sessionId, new RemoteVfs(this.remote, name, volId)); + } + + localCatalog(): Catalog { + return this.api.localCatalog() ?? this.vfs; + } + + installWelcomePack(opts?: WelcomePackProgress) { + return addWelcomePack(this.vfs, opts); + } + + seedWelcomePack(opts?: WelcomePackProgress) { + return seedWelcomePackIfNeeded(this.vfs, opts); + } + + promptCredentials(opts: Parameters[0]): Promise { + return this.ui.login.prompt(opts); + } + + dismissLogin(): void { + this.ui.login.close(); + } + + showAlert(title: string, text: string): void { + this.ui.alert.show(title, text); + } + + promptNameConflict(opts: { name: string; isDir: boolean; suggestedName: string }): Promise { + return this.ui.nameConflict.prompt(opts); + } + + async closeRemote(): Promise { + await this.remote?.close().catch(() => undefined); + this.remote = null; + this.remoteNbpName = ''; + log.info('Disconnected from AFP server', 'afp'); + } + + async closeVolume(name: string): Promise { + await this.remote?.closeVolume(name); + this.api.unbind(`${this.remoteNbpName}:${name}`); + log.info(`Released AFP volume “${name}” (session still logged in)`, 'afp'); + } + + private attachRemoteNotices(client: AfpClient): void { + client.onNotice = (n: AfpServerNotice) => { + if (n.kind === 'closed') { + void this.remote?.close().catch(() => undefined); + if (this.remote === client) { + this.remote = null; + this.remoteNbpName = ''; + } + this.ui.finder.unmountRemote(n.text || 'The AFP server closed this session.'); + if (n.text) this.ui.alert.show(n.title, n.text); + log.info(`Remote AFP session closed: ${n.text || n.title}`, 'afp'); + return; + } + if (n.text) this.ui.alert.show(n.title, n.text); + log.info(`AFP ${n.kind} from ${n.title}: ${n.text.replace(/\n/g, ' ')}`, 'afp'); + }; + } + + private toEndpoint(s: LookupResult): RemoteEndpoint { + return { + id: s.object, + kind: 'afp', + title: s.object, + subtitle: s.zone && s.zone !== '*' ? s.zone : `${s.network}.${s.node}`, + badge: 'NBP', + transport: 'nbp', + }; + } + + private afpServerKey(s: LookupResult): string { + return `${s.object}\0${s.network}.${s.node}:${s.socket}`; + } + + private afpServerListKey(list: LookupResult[]): string { + return list.map((s) => this.afpServerKey(s)).sort().join('|'); + } + + stopAfpServerScan(): void { + if (this.afpScanTimer != null) { + clearInterval(this.afpScanTimer); + this.afpScanTimer = null; + } + this.afpScanBusy = false; + this.lastAfpScanKey = ''; + this.ui.finder.setNetworkScanning(false); + } + + private startAfpServerScan(): void { + this.stopAfpServerScan(); + void (async () => { + this.ui.finder.setStatus('Looking up AFPServer…'); + const list = await this.scanAfpServers('auto'); + if (!this.nbp) return; + this.ui.finder.setStatus( + list.length + ? `Found ${list.length} AFP server(s)` + : 'No AFP servers found — scanning every 10s', + ); + })(); + this.afpScanTimer = setInterval(() => { + void this.scanAfpServers('auto'); + }, AFP_SCAN_MS); + } + + private async scanAfpServers(kind: 'auto' | 'manual'): Promise { + if (!this.nbp) return []; + while (this.afpScanBusy) { + if (kind === 'auto' || !this.nbp) return []; + await new Promise((r) => setTimeout(r, 50)); + } + if (!this.nbp) return []; + this.afpScanBusy = true; + this.ui.finder.setNetworkScanning(true); + try { + const list = await this.nbp.lookup('=', 'AFPServer'); + if (!this.nbp) return []; + const prevKey = this.lastAfpScanKey; + const key = this.afpServerListKey(list); + const changed = key !== prevKey; + this.lastAfpScanKey = key; + this.ui.finder.setServers(list.map((s) => this.toEndpoint(s))); + if (kind === 'manual' || !prevKey || changed) { + log.info(`NBP found ${list.length} AFPServer(s)`, 'nbp'); + } + if (kind === 'auto' && prevKey && changed) { + this.ui.finder.setStatus( + list.length ? `Found ${list.length} AFP server(s)` : 'No AFP servers on the network', + ); + } + return list; + } finally { + this.afpScanBusy = false; + this.ui.finder.setNetworkScanning(false); + } + } +} diff --git a/src/finder/api-catalog.ts b/src/finder/api-catalog.ts new file mode 100644 index 0000000..05c820c --- /dev/null +++ b/src/finder/api-catalog.ts @@ -0,0 +1,482 @@ +import { CNIDRoot } from '../protocol/afp/constants'; +import type { VNode, VfsChangeListener, ChildrenBatchListener } from '../fs/virtual-fs'; +import { nodeRef, parentRef } from '../fs/virtual-fs'; +import { asCnid, catalogCapsForSession, refKey, toUnixMs, type CatalogCapabilities, type NodeRef } from '../fs/catalog-caps'; +import { importDataTransferInto, type ImportProgress } from '../fs/import-transfer'; +import { finderInfoFromName } from '../fs/extension-map'; +import { bufferRangeReader, type ByteRangeReader } from '../fs/byte-range'; +import { loadFinderIconFork, ResourceFork, type ResourceForkLoadOpts } from '../fs/resource-fork'; +import { iconForkLoadOptions } from '../fs/icon-cache'; +import { throwIfAborted, isAbortError } from '../util/abort'; +import { parseAppleDouble, parseAppleSingle, AS_MAGIC, AD_MAGIC } from '../fs/appledouble'; +import { be32 } from '../protocol/binary'; +import { unescapeHostFilename } from '../protocol/host-filename'; +import type { CatalogWithBackend, FinderAPI } from './api'; +import type { FinderNodeDto, FinderSessionDto, TransferOptions } from './types'; +import { consumeProgress } from './progress'; + +const EMPTY = new Uint8Array(); + +function b64ToBytes(s = ''): Uint8Array { + if (!s) return new Uint8Array(); + const raw = atob(s); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return out; +} + +export class ApiCatalog implements CatalogWithBackend { + readonly reportsChunkedBytes = true; + readonly sessionId: string; + readonly api: FinderAPI; + private caps: CatalogCapabilities; + private root: NodeRef; + private nodes = new Map(); + private forksLoaded = new Set(); + private listeners = new Set(); + private batchDepth = 0; + private batchParents = new Set(); + + constructor(api: FinderAPI, session: FinderSessionDto) { + this.api = api; + this.sessionId = session.sessionId; + this.caps = catalogCapsForSession(session); + this.root = this.caps.addressBy === 'path' ? (session.rootPath ?? '') : (session.rootId || CNIDRoot); + } + + capabilities(): CatalogCapabilities { return this.caps; } + rootId(): NodeRef { return this.root; } + subscribe(fn: VfsChangeListener): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } + beginBatch(): void { this.batchDepth++; } + endBatch(): void { + if (this.batchDepth <= 0) return; + this.batchDepth--; + if (this.batchDepth === 0 && this.batchParents.size) { + const parentIds = [...this.batchParents].map((k) => (k.startsWith('p:') ? k.slice(2) : Number(k.slice(2)))); + this.batchParents.clear(); + this.emit(parentIds); + } + } + + async get(ref: NodeRef): Promise { + const cached = this.nodes.get(refKey(ref)); + if (cached) return cached; + try { + return this.adopt(await this.api.getNode(this.sessionId, ref)); + } catch { + return ref === this.root || ref === '' || ref === this.rootId() ? this.ensureRoot() : undefined; + } + } + + async ensureContent(ref: NodeRef, onBytes?: (n: number) => void, signal?: AbortSignal): Promise { + const node = await this.get(ref); + if (!node || node.isDir) return node; + const key = refKey(nodeRef(node)); + if (!this.forksLoaded.has(key)) await this.hydrateForks(node, onBytes, signal); + return node; + } + + async children(parent: NodeRef, onBatch?: ChildrenBatchListener, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const raw = await this.api.children(this.sessionId, parent); + const kids = raw.map((n) => this.adopt(n)); + await onBatch?.(kids); + return kids; + } + + async lookup(parent: NodeRef, name: string, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const lower = name.toLowerCase(); + for (const n of this.nodes.values()) { + if (parentRef(n) === parent && n.name.toLowerCase() === lower) return n; + } + const raw = await this.api.lookup(this.sessionId, parent, name); + return raw ? this.adopt(raw) : undefined; + } + + async resolvePath(path: string): Promise { + if (this.caps.addressBy === 'path') return this.get(path); + try { + const raw = await this.api.resolvePath(this.sessionId, path); + return raw ? this.adopt(raw) : undefined; + } catch { + const parts = path.split('/').filter(Boolean); + let cur: VNode | undefined = await this.get(this.root); + for (const part of parts) { + if (!cur) return undefined; + cur = await this.lookup(nodeRef(cur), part); + } + return cur; + } + } + + async pathOf(ref: NodeRef): Promise { + if (this.caps.addressBy === 'path') return String(ref); + try { + return await this.api.pathOf(this.sessionId, ref); + } catch { + return ''; + } + } + + async setAttrs(ref: NodeRef, patch: Record): Promise { + if (this.api.writeAttrs) await this.api.writeAttrs(this.sessionId, ref, patch); + const n = await this.get(ref); + if (n) { + n.attrs = { ...(n.attrs ?? {}), ...patch }; + this.nodes.set(refKey(nodeRef(n)), n); + } + } + + async loadResourceFork(node: VNode, opts?: ResourceForkLoadOpts): Promise { + throwIfAborted(opts?.signal); + const resource = opts?.fork !== 'data'; + const loaded = resource ? node.resource.length : node.data.length; + const hinted = resource ? (node.resourceBytes ?? loaded) : (node.dataBytes ?? loaded); + if (Math.max(loaded, hinted) < 16) return null; + try { + const rangeOpts = { resource, signal: opts?.signal, priority: opts?.finderIcons ? 0 : 1 }; + const rf = await this.withRangeReader( + node, + (read) => + opts?.finderIcons + ? loadFinderIconFork(read, iconForkLoadOptions(node)) + : ResourceFork.fromReader(read, opts?.want), + rangeOpts, + ); + rf?.bindFill((fn) => this.withRangeReader(node, fn, rangeOpts)); + return rf; + } catch (err) { + if (isAbortError(err)) throw err; + return null; + } + } + + async loadIconResources(node: VNode, signal?: AbortSignal): Promise { + return this.loadResourceFork(node, { finderIcons: true, signal }); + } + + async withRangeReader( + node: VNode, + fn: (read: ByteRangeReader) => Promise, + opts?: { resource?: boolean; signal?: AbortSignal; priority?: number }, + ): Promise { + throwIfAborted(opts?.signal); + if (this.forksLoaded.has(refKey(nodeRef(node)))) { + const bytes = opts?.resource ? node.resource : node.data; + return fn(bufferRangeReader(bytes)); + } + const read: ByteRangeReader = (offset, count) => + this.api.readFork(this.sessionId, nodeRef(node), !!opts?.resource, offset, count); + return fn(read); + } + + async mkdir(parent: NodeRef, name: string): Promise { + const existing = await this.lookup(parent, name); + if (existing) throw new Error('exists'); + const node = this.adopt(await this.api.mkdir(this.sessionId, parent, name)); + this.notify(parent); + return node; + } + + async ensureDir(parent: NodeRef, name: string): Promise { + const existing = await this.lookup(parent, name); + if (existing?.isDir) return existing; + if (existing) throw new Error('exists'); + return this.mkdir(parent, name); + } + + async createFile( + parent: NodeRef, + name: string, + data: Uint8Array, + resource = new Uint8Array(), + finderInfo = finderInfoFromName(name), + onBytes?: (n: number) => void, + signal?: AbortSignal, + ): Promise { + throwIfAborted(signal); + const raw = await this.api.create(this.sessionId, parent, name, { finderInfo }); + const node = this.adopt(raw); + const id = nodeRef(node); + if (data.length) await this.writeFork(id, false, data, onBytes, signal); + throwIfAborted(signal); + if (resource.length) await this.writeFork(id, true, resource, onBytes, signal); + node.data = data; + node.resource = resource; + node.finderInfo = finderInfo; + node.dataBytes = data.length; + node.resourceBytes = resource.length; + this.forksLoaded.add(refKey(id)); + this.nodes.set(refKey(id), node); + this.notify(parent); + return node; + } + + async put(node: VNode): Promise { + const id = nodeRef(node); + this.nodes.set(refKey(id), node); + await this.api.writeFinderInfo(this.sessionId, id, node.finderInfo); + this.notify(parentRef(node)); + } + + async rename(ref: NodeRef, newName: string): Promise { + const n = this.nodes.get(refKey(ref)) ?? (await this.get(ref)); + if (!n) throw new Error('not found'); + if (nodeRef(n) === this.rootId()) throw new Error('cannot rename volume root'); + await this.api.rename(this.sessionId, ref, newName); + n.name = newName; + this.nodes.set(refKey(nodeRef(n)), n); + this.notify(parentRef(n)); + } + + async move(ref: NodeRef, newParent: NodeRef): Promise { + const n = this.nodes.get(refKey(ref)) ?? (await this.get(ref)); + if (!n) throw new Error('not found'); + if (nodeRef(n) === this.rootId()) throw new Error('cannot move volume root'); + if (parentRef(n) === newParent) return; + await this.api.move(this.sessionId, ref, newParent); + const oldParent = parentRef(n); + if (n.addr === 'path') n.parentPath = String(newParent); + else n.parentId = Number(newParent); + this.nodes.set(refKey(nodeRef(n)), n); + this.notify(oldParent, newParent); + } + + async remove(ref: NodeRef): Promise { + const n = this.nodes.get(refKey(ref)) ?? (await this.get(ref)); + if (!n) return; + if (nodeRef(n) === this.rootId()) throw new Error('cannot delete volume root'); + if (n.isDir) { + const kids = await this.children(ref); + for (const k of kids) await this.remove(nodeRef(k)); + } + await this.api.remove(this.sessionId, ref); + this.nodes.delete(refKey(ref)); + this.forksLoaded.delete(refKey(ref)); + this.notify(parentRef(n)); + } + + async importDataTransfer(parent: NodeRef, dt: DataTransfer, opts?: ImportProgress): Promise { + if (typeof parent !== 'number') throw new Error('host import requires a CNID parent'); + return importDataTransferInto( + this, + parent, + dt, + (p, file, onBytes, resource, signal) => this.importBlob(p, file, onBytes, resource, signal), + opts, + ); + } + + async copyFrom(src: CatalogWithBackend, srcId: NodeRef, destParent: NodeRef, opts: TransferOptions): Promise { + await consumeProgress( + this.api.copy( + { + srcSession: src.sessionId, + destSession: this.sessionId, + srcId, + destParentId: destParent, + destName: opts.destName, + replace: !!opts.replace, + }, + opts.signal, + ), + opts.onProgress, + opts.signal, + ); + this.notify(destParent); + } + + async moveFrom(src: CatalogWithBackend, srcId: NodeRef, destParent: NodeRef, opts: TransferOptions): Promise { + if (src.api.backendId === this.api.backendId && src.sessionId === this.sessionId) { + if (opts.replaceId != null) await this.remove(opts.replaceId); + if (opts.destName) await src.rename(srcId, opts.destName); + await src.move(srcId, destParent); + this.notify(destParent); + return; + } + await consumeProgress( + this.api.moveAcross( + { + srcSession: src.sessionId, + destSession: this.sessionId, + srcId, + destParentId: destParent, + destName: opts.destName, + replace: !!opts.replace, + }, + opts.signal, + ), + opts.onProgress, + opts.signal, + ); + this.notify(destParent); + } + + async expandNode(ref: NodeRef, opts?: Pick): Promise { + await consumeProgress(this.api.expand(this.sessionId, ref, opts?.signal), opts?.onProgress, opts?.signal); + const node = await this.get(ref); + if (node) this.notify(parentRef(node)); + } + + private async importBlob( + parent: NodeRef, + file: File, + onBytes?: (n: number) => void, + resource?: Uint8Array, + signal?: AbortSignal, + ): Promise { + const parentId = asCnid(parent); + throwIfAborted(signal); + const buf = new Uint8Array(await file.arrayBuffer()); + const name = unescapeHostFilename(file.name); + if (name.startsWith('._') && name.length > 2) { + const ad = parseAppleDouble(buf); + if (ad) { + const target = name.slice(2); + const existing = await this.lookup(parentId, target); + if (existing && !existing.isDir) { + const data = this.forksLoaded.has(refKey(nodeRef(existing))) + ? existing.data + : await this.readWholeFork(nodeRef(existing), false, onBytes, signal); + return this.createFile(parentId, target, data, ad.resource, ad.finderInfo, onBytes, signal); + } + return this.createFile(parentId, target, new Uint8Array(), ad.resource, ad.finderInfo, onBytes, signal); + } + } + if (buf.length >= 4 && be32(buf, 0) === AS_MAGIC) { + const as = parseAppleSingle(buf); + if (as) return this.createFile(parentId, name, as.data, as.resource, as.finderInfo, onBytes, signal); + } + if (buf.length >= 4 && be32(buf, 0) === AD_MAGIC) { + const ad = parseAppleDouble(buf); + if (ad) return this.createFile(parentId, name, new Uint8Array(), ad.resource, ad.finderInfo, onBytes, signal); + } + return this.createFile(parentId, name, buf, resource ?? EMPTY, undefined, onBytes, signal); + } + + private async hydrateForks(node: VNode, onBytes?: (n: number) => void, signal?: AbortSignal): Promise { + throwIfAborted(signal); + try { + node.data = await this.readWholeFork(nodeRef(node), false, onBytes, signal); + } catch (err) { + if (isAbortError(err)) throw err; + node.data = EMPTY; + } + try { + node.resource = await this.readWholeFork(nodeRef(node), true, onBytes, signal); + } catch (err) { + if (isAbortError(err)) throw err; + node.resource = EMPTY; + } + node.dataBytes = node.data.length; + node.resourceBytes = node.resource.length; + this.forksLoaded.add(refKey(nodeRef(node))); + this.nodes.set(refKey(nodeRef(node)), node); + } + + private async writeFork( + id: NodeRef, + resource: boolean, + data: Uint8Array, + onBytes?: (n: number) => void, + signal?: AbortSignal, + ): Promise { + const chunk = 256 * 1024; + for (let off = 0; off < data.length; off += chunk) { + throwIfAborted(signal); + const slice = data.subarray(off, Math.min(data.length, off + chunk)); + await this.api.writeFork(this.sessionId, id, resource, off, slice); + onBytes?.(slice.length); + } + } + + private async readWholeFork( + id: NodeRef, + resource: boolean, + onBytes?: (n: number) => void, + signal?: AbortSignal, + ): Promise { + const buf = await this.api.readFork(this.sessionId, id, resource); + const chunk = 256 * 1024; + if (onBytes) for (let i = 0; i < buf.length; i += chunk) onBytes(Math.min(chunk, buf.length - i)); + throwIfAborted(signal); + return buf; + } + + private adopt(raw: FinderNodeDto): VNode { + const key = raw.addr === 'path' ? refKey(raw.path) : refKey(raw.id); + const prev = this.nodes.get(key); + const loaded = this.forksLoaded.has(key); + const finderInfo = b64ToBytes(raw.finderInfo); + const common = { + name: raw.name, + isDir: raw.isDir, + data: loaded && prev ? prev.data : EMPTY, + resource: loaded && prev ? prev.resource : EMPTY, + finderInfo: finderInfo.length ? finderInfo : new Uint8Array(32), + createDate: toUnixMs(raw.createDate ?? 0), + modDate: toUnixMs(raw.modDate ?? 0), + accessDate: raw.accessDate != null ? toUnixMs(raw.accessDate) : undefined, + dataBytes: raw.isDir ? 0 : (raw.dataBytes ?? 0), + resourceBytes: raw.isDir ? 0 : (raw.resourceBytes ?? 0), + shortName: raw.shortName, + mediumName: raw.mediumName, + attrs: raw.attrs, + }; + const node: VNode = raw.addr === 'path' + ? { addr: 'path', path: raw.path, parentPath: raw.parentPath, ...common } + : { addr: 'cnid', id: raw.id, parentId: raw.parentId, ...common }; + if (!raw.isDir && loaded && prev) { + node.dataBytes = prev.data.length; + node.resourceBytes = prev.resource.length; + } + this.nodes.set(refKey(nodeRef(node)), node); + return node; + } + + private ensureRoot(): VNode { + const existing = this.nodes.get(refKey(this.root)); + if (existing) return existing; + const root: VNode = this.caps.addressBy === 'path' + ? { + addr: 'path', + path: '', + parentPath: '', + name: '', + isDir: true, + data: EMPTY, + resource: EMPTY, + finderInfo: new Uint8Array(32), + createDate: 0, + modDate: 0, + } + : { + addr: 'cnid', + id: typeof this.root === 'number' ? this.root : CNIDRoot, + parentId: 1, + name: '', + isDir: true, + data: EMPTY, + resource: EMPTY, + finderInfo: new Uint8Array(32), + createDate: 0, + modDate: 0, + }; + this.nodes.set(refKey(nodeRef(root)), root); + return root; + } + + private notify(...parentIds: NodeRef[]): void { + if (this.batchDepth > 0) { + for (const id of parentIds) this.batchParents.add(refKey(id)); + return; + } + this.emit(parentIds); + } + + private emit(parentIds: NodeRef[]): void { + const change = { parentIds }; + for (const fn of this.listeners) fn(change); + } +} diff --git a/src/finder/api.ts b/src/finder/api.ts new file mode 100644 index 0000000..75f363b --- /dev/null +++ b/src/finder/api.ts @@ -0,0 +1,60 @@ +/** Protocol-neutral Finder backend (ClassicStack-web AFP or ClassicStack-go HTTP). */ + +import type { Catalog } from '../fs/virtual-fs'; +import type { NodeRef } from '../fs/catalog-caps'; +import type { FinderNodeDto, FinderSessionDto, OpProgress, TransferOptions, CrossTransferRequest } from './types'; + +export type ConnectRequest = { + kind: string; + id: string; + target?: string; + user?: string; + password?: string; + guest?: boolean; +}; + +export interface FinderAPI { + readonly backendId: string; + + getNode(sessionId: string, ref: NodeRef): Promise; + children(sessionId: string, parent: NodeRef): Promise; + lookup(sessionId: string, parent: NodeRef, name: string): Promise; + mkdir(sessionId: string, parent: NodeRef, name: string): Promise; + create( + sessionId: string, + parent: NodeRef, + name: string, + body?: { data?: Uint8Array; resource?: Uint8Array; finderInfo?: Uint8Array }, + ): Promise; + rename(sessionId: string, ref: NodeRef, name: string): Promise; + move(sessionId: string, ref: NodeRef, parent: NodeRef): Promise; + remove(sessionId: string, ref: NodeRef): Promise; + readFork(sessionId: string, ref: NodeRef, resource: boolean, off?: number, len?: number): Promise; + writeFork(sessionId: string, ref: NodeRef, resource: boolean, off: number, data: Uint8Array): Promise; + writeFinderInfo(sessionId: string, ref: NodeRef, finderInfo: Uint8Array): Promise; + writeAttrs?(sessionId: string, ref: NodeRef, patch: Record): Promise; + resolvePath(sessionId: string, path: string): Promise; + pathOf(sessionId: string, ref: NodeRef): Promise; + + copy(req: CrossTransferRequest, signal?: AbortSignal): AsyncIterable; + moveAcross(req: CrossTransferRequest, signal?: AbortSignal): AsyncIterable; + expand(sessionId: string, ref: NodeRef, signal?: AbortSignal): AsyncIterable; + + openCatalog(session: FinderSessionDto): Catalog; + connect?(req: ConnectRequest): Promise; + openVolume?(sessionId: string, volume: string): Promise; + close?(sessionId: string): Promise; + closeVolume?(sessionId: string, volume: string): Promise; +} + +export type CatalogWithBackend = Catalog & { + readonly sessionId: string; + readonly api: FinderAPI; + copyFrom(src: CatalogWithBackend, srcId: NodeRef, destParent: NodeRef, opts: TransferOptions): Promise; + moveFrom(src: CatalogWithBackend, srcId: NodeRef, destParent: NodeRef, opts: TransferOptions): Promise; + expandNode(id: NodeRef, opts?: Pick): Promise; +}; + +export function isCatalogWithBackend(c: Catalog): c is CatalogWithBackend { + return 'sessionId' in c && 'api' in c && typeof (c as CatalogWithBackend).copyFrom === 'function'; +} diff --git a/src/finder/bind-catalog.ts b/src/finder/bind-catalog.ts new file mode 100644 index 0000000..1a23430 --- /dev/null +++ b/src/finder/bind-catalog.ts @@ -0,0 +1,57 @@ +/** Attach FinderAPI copy/move/expand onto an existing Catalog (VirtualFS / RemoteVfs). */ + +import type { Catalog } from '../fs/virtual-fs'; +import { consumeProgress } from './progress'; +import type { CatalogWithBackend, FinderAPI } from './api'; +import type { TransferOptions } from './types'; + +/** Wrap `cat` so FinderWindow can call copyFrom / moveFrom / expandNode. */ +export function bindCatalog(cat: T, api: FinderAPI, sessionId: string): T & CatalogWithBackend { + const bound = cat as T & CatalogWithBackend; + Object.defineProperty(bound, 'sessionId', { value: sessionId, enumerable: true, configurable: true }); + Object.defineProperty(bound, 'api', { value: api, enumerable: true, configurable: true }); + bound.copyFrom = async (src, srcId, destParent, opts: TransferOptions) => { + await consumeProgress( + api.copy( + { + srcSession: src.sessionId, + destSession: sessionId, + srcId, + destParentId: destParent, + destName: opts.destName, + replace: !!opts.replace, + }, + opts.signal, + ), + opts.onProgress, + opts.signal, + ); + }; + bound.moveFrom = async (src, srcId, destParent, opts: TransferOptions) => { + if (src.api.backendId === api.backendId && src.sessionId === sessionId) { + if (opts.replaceId != null) await bound.remove(opts.replaceId); + if (opts.destName) await src.rename(srcId, opts.destName); + await src.move(srcId, destParent); + return; + } + await consumeProgress( + api.moveAcross( + { + srcSession: src.sessionId, + destSession: sessionId, + srcId, + destParentId: destParent, + destName: opts.destName, + replace: !!opts.replace, + }, + opts.signal, + ), + opts.onProgress, + opts.signal, + ); + }; + bound.expandNode = async (id, opts) => { + await consumeProgress(api.expand(sessionId, id, opts?.signal), opts?.onProgress, opts?.signal); + }; + return bound; +} diff --git a/src/finder/catalog-copy.test.ts b/src/finder/catalog-copy.test.ts new file mode 100644 index 0000000..94a3af4 --- /dev/null +++ b/src/finder/catalog-copy.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import type { Catalog, VNode, VfsChangeListener } from '../fs/virtual-fs'; +import { AfpFinderAPI } from './afp-finder-api'; +import { consumeProgress } from './progress'; + +const EMPTY = new Uint8Array(); + +/** Minimal in-memory Catalog for copy/move tests (no IndexedDB). */ +class MemCatalog implements Catalog { + private next = 10; + private nodes = new Map(); + constructor() { + this.nodes.set(2, { + id: 2, + parentId: 1, + name: '', + isDir: true, + data: EMPTY, + resource: EMPTY, + finderInfo: new Uint8Array(32), + createDate: 0, + modDate: 0, + }); + } + rootId(): number { + return 2; + } + subscribe(_fn: VfsChangeListener): () => void { + return () => undefined; + } + beginBatch(): void {} + endBatch(): void {} + async get(id: number): Promise { + return this.nodes.get(id); + } + async ensureContent(id: number): Promise { + return this.nodes.get(id); + } + async children(parentId: number): Promise { + return [...this.nodes.values()].filter((n) => n.parentId === parentId && n.id !== 2); + } + async lookup(parentId: number, name: string): Promise { + return (await this.children(parentId)).find((n) => n.name === name); + } + async loadResourceFork(): Promise { + return null; + } + async loadIconResources(): Promise { + return null; + } + async withRangeReader(node: VNode, fn: (read: (o: number, n: number) => Promise) => Promise): Promise { + return fn(async (o, n) => node.data.subarray(o, o + n)); + } + async mkdir(parentId: number, name: string): Promise { + const n: VNode = { + id: this.next++, + parentId, + name, + isDir: true, + data: EMPTY, + resource: EMPTY, + finderInfo: new Uint8Array(32), + createDate: 0, + modDate: 0, + }; + this.nodes.set(n.id, n); + return n; + } + async ensureDir(parentId: number, name: string): Promise { + return (await this.lookup(parentId, name)) ?? this.mkdir(parentId, name); + } + async createFile( + parentId: number, + name: string, + data: Uint8Array, + resource = EMPTY, + finderInfo = new Uint8Array(32), + ): Promise { + const n: VNode = { + id: this.next++, + parentId, + name, + isDir: false, + data, + resource, + finderInfo, + createDate: 0, + modDate: 0, + dataBytes: data.length, + resourceBytes: resource.length, + }; + this.nodes.set(n.id, n); + return n; + } + async put(node: VNode): Promise { + this.nodes.set(node.id, node); + } + async rename(id: number, newName: string): Promise { + const n = this.nodes.get(id); + if (n) n.name = newName; + } + async move(id: number, newParent: number): Promise { + const n = this.nodes.get(id); + if (n) n.parentId = newParent; + } + async remove(id: number): Promise { + this.nodes.delete(id); + } + async importDataTransfer(): Promise { + return 0; + } +} + +describe('AfpFinderAPI copy/move', () => { + it('copies a file between catalogs without inspecting kind', async () => { + const src = new MemCatalog(); + const dest = new MemCatalog(); + const api = new AfpFinderAPI(); + const srcCat = api.bindLocal(src); + const destCat = api.bindRemote('vol:A', dest); + const file = await src.createFile(src.rootId(), 'Hello', new Uint8Array([1, 2, 3]), new Uint8Array([9])); + await destCat.copyFrom(srcCat, file.id, dest.rootId(), { destName: 'Hello' }); + const got = await dest.lookup(dest.rootId(), 'Hello'); + expect(got?.data).toEqual(new Uint8Array([1, 2, 3])); + expect(got?.resource).toEqual(new Uint8Array([9])); + expect(await src.get(file.id)).toBeDefined(); + }); + + it('moveAcross copies then deletes when catalogs differ', async () => { + const src = new MemCatalog(); + const dest = new MemCatalog(); + const api = new AfpFinderAPI(); + api.bindLocal(src); + api.bindRemote('vol:A', dest); + const file = await src.createFile(src.rootId(), 'X', new Uint8Array([7])); + await consumeProgress( + api.moveAcross({ + srcSession: 'local', + destSession: 'vol:A', + srcId: file.id, + destParentId: dest.rootId(), + destName: 'X', + }), + ); + expect(await dest.lookup(dest.rootId(), 'X')).toMatchObject({ name: 'X' }); + expect(await src.get(file.id)).toBeUndefined(); + }); +}); diff --git a/src/finder/catalog-copy.ts b/src/finder/catalog-copy.ts new file mode 100644 index 0000000..7dcf065 --- /dev/null +++ b/src/finder/catalog-copy.ts @@ -0,0 +1,204 @@ +/** In-browser copy/move/expand between Finder catalogs (AFP PWA). */ + +import type { Catalog, VNode } from '../fs/virtual-fs'; +import { nodeRef, parentRef } from '../fs/virtual-fs'; +import { RemoteVfs } from '../fs/remote-vfs'; +import { expandArchiveFile } from '../fs/expand-incoming'; +import { expandSitInPlace } from '../fs/expand-inplace'; +import { importExpandedTree } from '../fs/import-transfer'; +import { throwIfAborted } from '../util/abort'; +import type { NodeRef } from '../fs/catalog-caps'; +import { asCnid } from '../fs/catalog-caps'; +import type { CrossTransferRequest, OpProgress } from './types'; + +type CopyCtx = { + destName: string; + destParentId: NodeRef; + bytesDone: number; + bytesTotal?: number; + signal?: AbortSignal; +}; + +function sameAfpClient(a: Catalog, b: Catalog): boolean { + return a instanceof RemoteVfs && b instanceof RemoteVfs && a.client === b.client; +} + +function nodeBytes(node: VNode): number { + if (node.isDir) return 0; + return (node.dataBytes ?? node.data.length) + (node.resourceBytes ?? node.resource.length); +} + +async function* copyNode( + src: Catalog, + dest: Catalog, + node: VNode, + destParent: NodeRef, + destName: string, + ctx: CopyCtx, +): AsyncGenerator { + throwIfAborted(ctx.signal); + yield { + phase: 'copying', + path: destName, + destName: ctx.destName, + destParentId: ctx.destParentId, + bytesDone: ctx.bytesDone, + bytesTotal: ctx.bytesTotal, + }; + if (node.isDir) { + const dir = await dest.mkdir(destParent, destName); + for (const child of await src.children(nodeRef(node), undefined, ctx.signal)) { + yield* copyNode(src, dest, child, nodeRef(dir), child.name, ctx); + } + return; + } + if (sameAfpClient(src, dest) && src instanceof RemoteVfs && dest instanceof RemoteVfs) { + await src.client.copyFile(asCnid(parentRef(node)), node.name, dest.volId, asCnid(destParent), destName, src.volId); + ctx.bytesDone += nodeBytes(node); + yield { + phase: 'copying', + path: destName, + destName: ctx.destName, + bytesDone: ctx.bytesDone, + bytesTotal: ctx.bytesTotal, + }; + return; + } + const creditRead = !!src.reportsChunkedBytes && !dest.reportsChunkedBytes; + const creditWrite = !!dest.reportsChunkedBytes || !creditRead; + const onRead = creditRead + ? (n: number) => { + ctx.bytesDone += n; + } + : undefined; + const onWrite = creditWrite + ? (n: number) => { + ctx.bytesDone += n; + } + : undefined; + const full = (await src.ensureContent(nodeRef(node), onRead, ctx.signal)) ?? node; + throwIfAborted(ctx.signal); + await dest.createFile( + destParent, + destName, + full.data, + full.resource, + full.finderInfo, + onWrite, + ctx.signal, + ); + yield { + phase: 'copying', + path: destName, + destName: ctx.destName, + destParentId: ctx.destParentId, + bytesDone: ctx.bytesDone, + bytesTotal: ctx.bytesTotal, + }; +} + +/** Copy a node between catalogs, yielding OpProgress. Same-server AFP files use FPCopyFile. */ +export async function* copyBetweenCatalogs( + src: Catalog, + dest: Catalog, + req: CrossTransferRequest, + signal?: AbortSignal, +): AsyncGenerator { + const node = await src.get(req.srcId); + if (!node) { + yield { error: 'not found', done: true }; + return; + } + if (req.replace) { + const existing = await dest.lookup(req.destParentId, req.destName); + if (existing) await dest.remove(nodeRef(existing)); + } + const ctx: CopyCtx = { + destName: req.destName, + destParentId: req.destParentId, + bytesDone: 0, + bytesTotal: node.isDir ? undefined : nodeBytes(node), + signal, + }; + dest.beginBatch(); + try { + yield* copyNode(src, dest, node, req.destParentId, req.destName, ctx); + yield { + phase: 'copying', + destName: req.destName, + destParentId: req.destParentId, + bytesDone: ctx.bytesDone, + bytesTotal: ctx.bytesTotal, + }; + } finally { + dest.endBatch(); + } +} + +/** Move across catalogs: same AFP volume uses FPMoveAndRename; otherwise copy then delete. */ +export async function* moveBetweenCatalogs( + src: Catalog, + dest: Catalog, + req: CrossTransferRequest, + signal?: AbortSignal, +): AsyncGenerator { + const node = await src.get(req.srcId); + if (!node) { + yield { error: 'not found', done: true }; + return; + } + if (src === dest || (src instanceof RemoteVfs && dest instanceof RemoteVfs && src.client === dest.client && src.volId === dest.volId)) { + if (req.replace) { + const existing = await dest.lookup(req.destParentId, req.destName); + if (existing && nodeRef(existing) !== req.srcId) await dest.remove(nodeRef(existing)); + } + if (req.destName && req.destName !== node.name) await src.rename(req.srcId, req.destName); + await src.move(req.srcId, req.destParentId); + yield { phase: 'moving', destName: req.destName }; + return; + } + yield* copyBetweenCatalogs(src, dest, req, signal); + await src.remove(req.srcId); + yield { phase: 'moving', destName: req.destName }; +} + +/** Expand an archive next to itself on a catalog (StuffIt in-place, else load + expand). */ +export async function* expandOnCatalog( + cat: Catalog, + ref: NodeRef, + signal?: AbortSignal, +): AsyncGenerator { + const node = await cat.get(ref); + if (!node || node.isDir) { + yield { error: 'not an archive', done: true }; + return; + } + let bytesDone = 0; + const bytesTotal = nodeBytes(node); + yield { phase: 'expanding', path: node.name, bytesTotal }; + const track = { + signal, + onBytes: (n: number) => { + bytesDone += n; + }, + }; + cat.beginBatch(); + try { + const inPlace = await expandSitInPlace(cat, node, { + fileSize: node.dataBytes ?? node.data.length, + track, + resolveConflict: async () => 'rename', + }); + if (!inPlace) { + const full = (await cat.ensureContent(ref, track.onBytes, signal)) ?? node; + throwIfAborted(signal); + const tree = expandArchiveFile(full.name, full.data); + const parent = parentRef(node); + if (typeof parent !== 'number') throw new Error('expand: AFP parent required'); + await importExpandedTree(cat, parent, tree, track); + } + yield { phase: 'expanding', path: node.name, bytesDone, bytesTotal, done: true }; + } finally { + cat.endBatch(); + } +} diff --git a/src/finder/index.ts b/src/finder/index.ts new file mode 100644 index 0000000..ed499c0 --- /dev/null +++ b/src/finder/index.ts @@ -0,0 +1,28 @@ +/** Public Finder VFS contract: AFP (PWA) and HTTP (ClassicStack-go SPA) share these types. */ + +export type { FinderAPI, CatalogWithBackend, ConnectRequest } from './api'; +export { isCatalogWithBackend } from './api'; +export { ApiCatalog } from './api-catalog'; +export { AfpFinderAPI } from './afp-finder-api'; +export { bindCatalog } from './bind-catalog'; +export { consumeProgress, readSSEProgress } from './progress'; +export type { + OpProgress, + OpPhase, + FinderNodeDto, + FinderSessionDto, + TransferOptions, + CrossTransferRequest, +} from './types'; +export type { CatalogCapabilities, NodeRef, VolumeIdentity } from '../fs/catalog-caps'; +export { + afpVolumeCaps, + smbVolumeCaps, + ncpVolumeCaps, + edfsVolumeCaps, + localShareCaps, + catalogCapsForSession, + parseRefKey, + refKey, +} from '../fs/catalog-caps'; +export { volumeChrome, formatStorePath, clientVolumeURI, isClientURI } from '../fs/volume-chrome'; diff --git a/src/finder/progress.ts b/src/finder/progress.ts new file mode 100644 index 0000000..8e210ee --- /dev/null +++ b/src/finder/progress.ts @@ -0,0 +1,53 @@ +/** Helpers for Finder job progress streams. */ + +import type { OpProgress } from './types'; + +export async function consumeProgress( + stream: AsyncIterable, + onProgress?: (p: OpProgress) => void, + signal?: AbortSignal, +): Promise { + for await (const p of stream) { + if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError'); + onProgress?.(p); + if (p.error) throw new Error(p.error); + if (p.done) return; + } +} + +export async function* readSSEProgress(r: Response): AsyncIterable { + if (!r.ok) { + const j = (await r.json().catch(() => null)) as { error?: string } | null; + yield { error: j?.error ?? `HTTP ${r.status}`, done: true }; + return; + } + const reader = r.body?.getReader(); + if (!reader) { + yield { done: true }; + return; + } + const dec = new TextDecoder(); + let buf = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + for (;;) { + const idx = buf.indexOf('\n\n'); + if (idx < 0) break; + const block = buf.slice(0, idx); + buf = buf.slice(idx + 2); + for (const line of block.split('\n')) { + if (!line.startsWith('data: ')) continue; + try { + const p = JSON.parse(line.slice(6)) as OpProgress; + yield p; + if (p.done || p.error) return; + } catch { + /* ignore malformed */ + } + } + } + } + yield { done: true }; +} diff --git a/src/finder/types.ts b/src/finder/types.ts new file mode 100644 index 0000000..2e070dd --- /dev/null +++ b/src/finder/types.ts @@ -0,0 +1,78 @@ +/** Shared Finder VFS job progress (Go SSE and in-browser AFP). */ + +import type { CatalogCapabilities, NodeRef } from '../fs/catalog-caps'; + +export type OpPhase = 'copying' | 'moving' | 'expanding' | 'listing'; + +export type OpProgress = { + phase?: OpPhase; + path?: string; + bytesDone?: number; + bytesTotal?: number; + destName?: string; + destParentId?: NodeRef; + done?: boolean; + error?: string; +}; + +export type CnidNodeDto = { + addr: 'cnid'; + id: number; + parentId: number; +}; + +export type PathNodeDto = { + addr: 'path'; + path: string; + parentPath: string; +}; + +export type FinderNodeDto = (CnidNodeDto | PathNodeDto) & { + name: string; + isDir: boolean; + dataBytes?: number; + resourceBytes?: number; + finderInfo?: string; + createDate?: number; + modDate?: number; + accessDate?: number; + backupDate?: number; + shortName?: string; + mediumName?: string; + attrs?: Record; +}; + +export type FinderSessionDto = { + sessionId: string; + serverName: string; + kind: string; + volumes: string[]; + allowGuest: boolean; + uams?: string[]; + rootId?: number; + rootPath?: string; + volume?: string; + target?: string; + transport?: string; + protocol?: string; + os?: string; + dialect?: string; + capabilities?: CatalogCapabilities; +}; + +export type TransferOptions = { + destName: string; + replace?: boolean; + replaceId?: NodeRef | null; + signal?: AbortSignal; + onProgress?: (p: OpProgress) => void; +}; + +export type CrossTransferRequest = { + srcSession: string; + destSession: string; + srcId: NodeRef; + destParentId: NodeRef; + destName: string; + replace?: boolean; +}; diff --git a/src/fs/capability-catalog.test.ts b/src/fs/capability-catalog.test.ts new file mode 100644 index 0000000..356731d --- /dev/null +++ b/src/fs/capability-catalog.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { CapabilityCatalog } from './capability-catalog'; +import { + afpVolumeCaps, + catalogCapsForSession, + edfsVolumeCaps, + smbVolumeCaps, + showsResourceFork, + showsTypeCreator, +} from './catalog-caps'; +import { formatStorePath, volumeChrome } from './volume-chrome'; + +describe('CapabilityCatalog CNID vs path', () => { + it('AFP preset is CNID-addressed with no path identity fields', async () => { + const cat = new CapabilityCatalog(afpVolumeCaps); + expect(cat.capabilities().addressBy).toBe('cnid'); + expect(cat.rootId()).toBe(2); + + const foo = await cat.mkdir(2, 'FOO'); + expect(foo.addr).toBe('cnid'); + if (foo.addr !== 'cnid') return; + expect(foo.id).toBeGreaterThan(2); + expect(foo.parentId).toBe(2); + expect('path' in foo).toBe(false); + + const bar = await cat.createFile(foo.id, 'BAR', new Uint8Array([1])); + expect(bar.addr).toBe('cnid'); + const resolved = await cat.resolvePath('FOO/BAR'); + expect(resolved?.addr).toBe('cnid'); + if (resolved?.addr !== 'cnid') return; + expect(resolved.id).toBe(bar.addr === 'cnid' ? bar.id : -1); + expect(await cat.pathOf(resolved.id)).toBe('FOO/BAR'); + }); + + it('SMB preset is path-addressed with no CNID identity fields', async () => { + const cat = new CapabilityCatalog(smbVolumeCaps); + expect(cat.capabilities().addressBy).toBe('path'); + expect(cat.rootId()).toBe(''); + + const foo = await cat.mkdir('', 'FOO'); + expect(foo.addr).toBe('path'); + if (foo.addr !== 'path') return; + expect(foo.path).toBe('FOO'); + expect(foo.parentPath).toBe(''); + expect('id' in foo).toBe(false); + + const bar = await cat.createFile('FOO', 'BAR.TXT', new Uint8Array()); + expect(bar.addr).toBe('path'); + if (bar.addr !== 'path') return; + expect(bar.path).toBe('FOO/BAR.TXT'); + expect(bar.parentPath).toBe('FOO'); + + const resolved = await cat.resolvePath('FOO/BAR.TXT'); + expect(resolved?.addr).toBe('path'); + if (resolved?.addr !== 'path') return; + expect(resolved.path).toBe('FOO/BAR.TXT'); + expect(await cat.pathOf('FOO/BAR.TXT')).toBe('FOO/BAR.TXT'); + await expect(cat.get(2)).resolves.toBeUndefined(); + }); + + it('EtherDFS stub uses short names and DOS attrs, still path-addressed', async () => { + const cat = new CapabilityCatalog(edfsVolumeCaps); + expect(cat.capabilities().names).toEqual(['short']); + expect(cat.capabilities().hideAttribute).toBe('hidden'); + expect(cat.capabilities().addressBy).toBe('path'); + const file = await cat.createFile('', 'README.TXT', new Uint8Array()); + expect(file.addr).toBe('path'); + await cat.setAttrs('README.TXT', { hidden: true, readonly: true }); + const got = await cat.get('README.TXT'); + expect(got?.attrs).toEqual({ hidden: true, readonly: true }); + }); +}); + +describe('volume chrome', () => { + it('is identity-driven, not shareKind for Get Info fields', () => { + expect(volumeChrome(smbVolumeCaps).volumeIcon).toBe('windows'); + expect(volumeChrome(afpVolumeCaps).volumeIcon).toBe('appleshare'); + expect(volumeChrome(edfsVolumeCaps).volumeIcon).toBe('dos'); + expect(formatStorePath('FOO/BAR', 'dos', 'C:')).toBe('C:\\FOO\\BAR'); + expect(formatStorePath('FOO/BAR', 'mac', 'HD')).toBe('HD:FOO:BAR'); + expect(formatStorePath('FOO/BAR', 'ncp', 'SYS')).toBe('SYS:FOO/BAR'); + }); + + it('local+smb still looks like a Windows share', () => { + const chrome = volumeChrome({ + ...smbVolumeCaps, + identity: { shareKind: 'local', protocol: 'smb' }, + }); + expect(chrome.volumeIcon).toBe('windows'); + expect(chrome.kindLabel).toMatch(/Windows/i); + }); +}); + +describe('Get Info feature flags', () => { + it('hides type/creator and resource fork on SMB', () => { + expect(showsTypeCreator(smbVolumeCaps, false)).toBe(false); + expect(showsResourceFork(smbVolumeCaps)).toBe(false); + }); + + it('shows type/creator and resource fork on AFP files', () => { + expect(showsTypeCreator(afpVolumeCaps, false)).toBe(true); + expect(showsTypeCreator(afpVolumeCaps, true)).toBe(false); + expect(showsResourceFork(afpVolumeCaps)).toBe(true); + }); + + it('does not default SMB sessions to AFP Mac metadata', () => { + const caps = catalogCapsForSession({ kind: 'smb' }); + expect(showsTypeCreator(caps, false)).toBe(false); + expect(showsResourceFork(caps)).toBe(false); + }); + + it('keeps server-declared capabilities', () => { + const caps = catalogCapsForSession({ kind: 'smb', capabilities: afpVolumeCaps }); + expect(showsResourceFork(caps)).toBe(true); + }); +}); diff --git a/src/fs/capability-catalog.ts b/src/fs/capability-catalog.ts new file mode 100644 index 0000000..e080747 --- /dev/null +++ b/src/fs/capability-catalog.ts @@ -0,0 +1,329 @@ +/** In-memory Catalog parameterized by capabilities (tests + SMB/NCP/EDFS stubs). */ + +import { bufferRangeReader, type ByteRangeReader } from './byte-range'; +import { importDataTransferInto, type ImportProgress } from './import-transfer'; +import type { ResourceFork } from './resource-fork'; +import { + joinStorePath, + refKey, + type CatalogCapabilities, + type NodeRef, +} from './catalog-caps'; +import type { + Catalog, + ChildrenBatchListener, + CnidVNode, + PathVNode, + VfsChangeListener, + VNode, +} from './virtual-fs'; +import { nodeRef, parentRef } from './virtual-fs'; + +const EMPTY = new Uint8Array(); +const ROOT_CNID = 2; + +function emptyForks(): Pick { + return { data: EMPTY, resource: EMPTY, finderInfo: new Uint8Array(32) }; +} + +export class CapabilityCatalog implements Catalog { + readonly reportsChunkedBytes = false; + private caps: CatalogCapabilities; + private cnidNodes = new Map(); + private pathNodes = new Map(); + private nextId = 100; + private listeners = new Set(); + private batchDepth = 0; + private batchParents = new Set(); + + constructor(caps: CatalogCapabilities) { + this.caps = caps; + if (caps.addressBy === 'cnid') { + this.cnidNodes.set(ROOT_CNID, { + addr: 'cnid', + id: ROOT_CNID, + parentId: 1, + name: '', + isDir: true, + createDate: Date.now(), + modDate: Date.now(), + ...emptyForks(), + }); + } else { + this.pathNodes.set('', { + addr: 'path', + path: '', + parentPath: '', + name: '', + isDir: true, + createDate: Date.now(), + modDate: Date.now(), + ...emptyForks(), + }); + } + } + + capabilities(): CatalogCapabilities { + return this.caps; + } + + rootId(): NodeRef { + return this.caps.addressBy === 'path' ? '' : ROOT_CNID; + } + + subscribe(fn: VfsChangeListener): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + beginBatch(): void { + this.batchDepth++; + } + + endBatch(): void { + if (this.batchDepth <= 0) return; + this.batchDepth--; + if (this.batchDepth === 0 && this.batchParents.size) { + const parentIds = [...this.batchParents].map((k) => (k.startsWith('p:') ? k.slice(2) : Number(k.slice(2)))); + this.batchParents.clear(); + this.emit(parentIds); + } + } + + async get(ref: NodeRef): Promise { + if (this.caps.addressBy === 'path') { + if (typeof ref !== 'string') return undefined; + return this.pathNodes.get(ref); + } + if (typeof ref === 'string') return this.resolvePath(ref); + return this.cnidNodes.get(ref); + } + + async ensureContent(ref: NodeRef): Promise { + return this.get(ref); + } + + async children(parent: NodeRef, onBatch?: ChildrenBatchListener): Promise { + const kids: VNode[] = []; + if (this.caps.addressBy === 'path') { + const p = String(parent); + for (const n of this.pathNodes.values()) { + if (n.path !== p && n.parentPath === p) kids.push(n); + } + } else { + const id = Number(parent); + for (const n of this.cnidNodes.values()) { + if (n.parentId === id && n.id !== id) kids.push(n); + } + } + await onBatch?.(kids); + return kids; + } + + async lookup(parent: NodeRef, name: string): Promise { + const kids = await this.children(parent); + const lower = name.toLowerCase(); + return kids.find((n) => n.name.toLowerCase() === lower); + } + + async resolvePath(path: string): Promise { + if (this.caps.addressBy === 'path') return this.get(path); + const parts = path.split('/').filter(Boolean); + let cur: VNode | undefined = await this.get(ROOT_CNID); + for (const part of parts) { + if (!cur) return undefined; + cur = await this.lookup(nodeRef(cur), part); + } + return cur; + } + + async pathOf(ref: NodeRef): Promise { + if (this.caps.addressBy === 'path') return String(ref); + if (typeof ref === 'string') return ref; + const names: string[] = []; + let cur = await this.get(ref); + while (cur && cur.addr === 'cnid' && cur.id !== ROOT_CNID) { + if (cur.name) names.unshift(cur.name); + if (!cur.parentId || cur.parentId === cur.id) break; + cur = await this.get(cur.parentId); + } + return names.join('/'); + } + + async setAttrs(ref: NodeRef, patch: Record): Promise { + const n = await this.get(ref); + if (!n) throw new Error('not found'); + n.attrs = { ...(n.attrs ?? {}), ...patch }; + this.store(n); + } + + async loadResourceFork(): Promise { + return null; + } + + async loadIconResources(): Promise { + return null; + } + + async withRangeReader(node: VNode, fn: (read: ByteRangeReader) => Promise): Promise { + return fn(bufferRangeReader(node.data)); + } + + async mkdir(parent: NodeRef, name: string): Promise { + const existing = await this.lookup(parent, name); + if (existing) throw new Error('exists'); + const n = this.makeChild(parent, name, true); + this.store(n); + this.notify(parent); + return n; + } + + async ensureDir(parent: NodeRef, name: string): Promise { + const existing = await this.lookup(parent, name); + if (existing?.isDir) return existing; + if (existing) throw new Error('exists'); + return this.mkdir(parent, name); + } + + async createFile( + parent: NodeRef, + name: string, + data: Uint8Array, + resource = EMPTY, + finderInfo?: Uint8Array, + ): Promise { + const existing = await this.lookup(parent, name); + if (existing) { + existing.data = data; + existing.resource = resource; + if (finderInfo) existing.finderInfo = finderInfo; + existing.modDate = Date.now(); + this.store(existing); + return existing; + } + const n = this.makeChild(parent, name, false); + n.data = data; + n.resource = resource; + if (finderInfo) n.finderInfo = finderInfo; + this.store(n); + this.notify(parent); + return n; + } + + async put(node: VNode): Promise { + this.store(node); + this.notify(parentRef(node)); + } + + async rename(ref: NodeRef, newName: string): Promise { + const n = await this.get(ref); + if (!n) throw new Error('not found'); + n.name = newName; + n.modDate = Date.now(); + if (n.addr === 'path') { + const dest = joinStorePath(n.parentPath, newName); + this.pathNodes.delete(n.path); + n.path = dest; + this.pathNodes.set(dest, n); + this.notify(n.parentPath); + return; + } + this.store(n); + this.notify(n.parentId); + } + + async move(ref: NodeRef, newParent: NodeRef): Promise { + const n = await this.get(ref); + if (!n) throw new Error('not found'); + const oldParent = parentRef(n); + if (n.addr === 'path') { + const destParent = String(newParent); + const dest = joinStorePath(destParent, n.name); + this.pathNodes.delete(n.path); + n.parentPath = destParent; + n.path = dest; + this.pathNodes.set(dest, n); + } else { + n.parentId = Number(newParent); + this.store(n); + } + this.notify(oldParent, newParent); + } + + async remove(ref: NodeRef): Promise { + const n = await this.get(ref); + if (!n) return; + for (const k of await this.children(ref)) await this.remove(nodeRef(k)); + if (n.addr === 'path') this.pathNodes.delete(n.path); + else this.cnidNodes.delete(n.id); + this.notify(parentRef(n)); + } + + async importDataTransfer(parent: NodeRef, dt: DataTransfer, opts?: ImportProgress): Promise { + if (typeof parent !== 'number') { + throw new Error('capability-catalog: host import uses cnid parent'); + } + return importDataTransferInto( + this, + parent, + dt, + async (_pp, file) => { + const buf = new Uint8Array(await file.arrayBuffer()); + return this.createFile(parent, file.name, buf); + }, + opts, + ); + } + + private makeChild(parent: NodeRef, name: string, isDir: boolean): VNode { + const now = Date.now(); + if (this.caps.addressBy === 'path') { + const parentPath = String(parent); + const path = joinStorePath(parentPath, name); + return { + addr: 'path', + path, + parentPath, + name, + isDir, + createDate: now, + modDate: now, + ...emptyForks(), + data: new Uint8Array(), + resource: new Uint8Array(), + finderInfo: new Uint8Array(32), + }; + } + return { + addr: 'cnid', + id: this.nextId++, + parentId: Number(parent), + name, + isDir, + createDate: now, + modDate: now, + ...emptyForks(), + data: new Uint8Array(), + resource: new Uint8Array(), + finderInfo: new Uint8Array(32), + }; + } + + private store(n: VNode): void { + if (n.addr === 'path') this.pathNodes.set(n.path, n); + else this.cnidNodes.set(n.id, n); + } + + private notify(...parents: NodeRef[]): void { + if (this.batchDepth > 0) { + for (const p of parents) this.batchParents.add(refKey(p)); + return; + } + this.emit(parents); + } + + private emit(parentIds: NodeRef[]): void { + for (const fn of this.listeners) fn({ parentIds }); + } +} + diff --git a/src/fs/catalog-caps.ts b/src/fs/catalog-caps.ts new file mode 100644 index 0000000..37113a9 --- /dev/null +++ b/src/fs/catalog-caps.ts @@ -0,0 +1,267 @@ +/** Volume-declared Finder catalog capabilities (shared by PWA and Go SPA). */ + +import type { ShareKind } from '../ui/finder-host'; + +/** Native catalog address: CNID number or store path (`''` = volume root). */ +export type NodeRef = number | string; + +export type AddressBy = 'cnid' | 'path'; +export type NameKind = 'long' | 'medium' | 'short'; +export type DateField = 'created' | 'modified' | 'accessed' | 'backup'; +export type NameCase = 'preserve' | 'upper' | 'insensitive'; +export type PathFormat = 'posix' | 'mac' | 'dos' | 'ncp'; + +export type AttrField = { + id: string; + label: string; + type: 'bool'; + editable?: boolean; +}; + +export type VolumeIdentity = { + shareKind: ShareKind; + protocol?: ShareKind | 'afp' | 'smb' | 'ncp' | 'etherdfs'; + filesystem?: string; + transport?: string; + forkBackend?: string; + dialect?: string; + os?: string; +}; + +export type CatalogCapabilities = { + identity: VolumeIdentity; + addressBy: AddressBy; + readOnly: boolean; + resourceFork: boolean; + finderInfo: boolean; + desktopIcons: boolean; + resourceIcons: boolean; + names: NameKind[]; + maxNameBytes: Partial>; + nameCase: NameCase; + dates: DateField[]; + attributes: AttrField[]; + hideAttribute?: string; + pathFormat: PathFormat; +}; + +const DOS_ATTRS: AttrField[] = [ + { id: 'readonly', label: 'Read only', type: 'bool', editable: true }, + { id: 'hidden', label: 'Hidden', type: 'bool', editable: true }, + { id: 'system', label: 'System', type: 'bool', editable: true }, + { id: 'archive', label: 'Archive', type: 'bool', editable: true }, +]; + +const AFP_ATTRS: AttrField[] = [ + { id: 'invisible', label: 'Invisible', type: 'bool', editable: true }, + { id: 'locked', label: 'Locked', type: 'bool', editable: true }, +]; + +const AFP_DATES: DateField[] = ['created', 'modified', 'backup']; +const DOS_DATES: DateField[] = ['created', 'modified', 'accessed']; + +export const afpVolumeCaps: CatalogCapabilities = { + identity: { shareKind: 'afp', protocol: 'afp' }, + addressBy: 'cnid', + readOnly: false, + resourceFork: true, + finderInfo: true, + desktopIcons: true, + resourceIcons: true, + names: ['long'], + maxNameBytes: { long: 31 }, + nameCase: 'preserve', + dates: AFP_DATES, + attributes: AFP_ATTRS, + hideAttribute: 'invisible', + pathFormat: 'mac', +}; + +export const smbVolumeCaps: CatalogCapabilities = { + identity: { shareKind: 'smb', protocol: 'smb' }, + addressBy: 'path', + readOnly: false, + resourceFork: false, + finderInfo: false, + desktopIcons: false, + resourceIcons: false, + names: ['long', 'short'], + maxNameBytes: { long: 255, short: 12 }, + nameCase: 'preserve', + dates: DOS_DATES, + attributes: DOS_ATTRS, + hideAttribute: 'hidden', + pathFormat: 'dos', +}; + +export const ncpVolumeCaps: CatalogCapabilities = { + identity: { shareKind: 'ncp', protocol: 'ncp' }, + addressBy: 'path', + readOnly: false, + resourceFork: false, + finderInfo: false, + desktopIcons: false, + resourceIcons: false, + names: ['long', 'short'], + maxNameBytes: { long: 255, short: 12 }, + nameCase: 'insensitive', + dates: DOS_DATES, + attributes: DOS_ATTRS, + hideAttribute: 'hidden', + pathFormat: 'ncp', +}; + +export const edfsVolumeCaps: CatalogCapabilities = { + identity: { shareKind: 'etherdfs', protocol: 'etherdfs' }, + addressBy: 'path', + readOnly: false, + resourceFork: false, + finderInfo: false, + desktopIcons: false, + resourceIcons: false, + names: ['short'], + maxNameBytes: { short: 12 }, + nameCase: 'upper', + dates: DOS_DATES, + attributes: DOS_ATTRS, + hideAttribute: 'hidden', + pathFormat: 'dos', +}; + +/** Local ClassicStack share: union of fork + DOS engines; addressing follows protocol. */ +export const localShareCaps: CatalogCapabilities = { + identity: { shareKind: 'local', protocol: 'afp', filesystem: 'local_fs' }, + addressBy: 'cnid', + readOnly: false, + resourceFork: true, + finderInfo: true, + desktopIcons: false, + resourceIcons: true, + names: ['long', 'medium', 'short'], + maxNameBytes: { long: 255, medium: 31, short: 12 }, + nameCase: 'preserve', + dates: ['created', 'modified', 'accessed'], + attributes: [...AFP_ATTRS, ...DOS_ATTRS], + hideAttribute: 'invisible', + pathFormat: 'posix', +}; + +const AFP_EPOCH_MS = Date.UTC(2000, 0, 1); + +/** Catalog timestamps are Unix ms. Convert AFP Mac time or Unix seconds if needed. */ +export function toUnixMs(t: number): number { + if (!t) return 0; + if (t > 1e11) return t; + if (t > 1e9) return t * 1000; + return AFP_EPOCH_MS + t * 1000; +} + +/** Unix milliseconds → AFP Mac time (seconds since 2000-01-01 UTC). */ +export function fromUnixMs(ms: number): number { + if (!ms) return 0; + return Math.round((ms - AFP_EPOCH_MS) / 1000); +} + +/** Get Info type/creator editors — only when the volume stores Finder info. */ +export function showsTypeCreator(caps: CatalogCapabilities, isDir = false): boolean { + return !!caps.finderInfo && !isDir; +} + +/** Resource Fork action — only when the volume stores a resource fork. */ +export function showsResourceFork(caps: CatalogCapabilities): boolean { + return !!caps.resourceFork; +} + +/** Catalog capabilities for a Finder session. Prefer the server-declared set. */ +export function catalogCapsForSession(session: { + kind?: string; + protocol?: string; + capabilities?: CatalogCapabilities | null; +}): CatalogCapabilities { + if (session.capabilities) return session.capabilities; + const kind = (session.kind || '').toLowerCase(); + const proto = (session.protocol || kind).toLowerCase(); + const asLocal = (caps: CatalogCapabilities, protocol: VolumeIdentity['protocol']): CatalogCapabilities => + kind === 'local' + ? { ...caps, identity: { shareKind: 'local', protocol, filesystem: 'local_fs' } } + : caps; + switch (proto) { + case 'smb': + return asLocal(smbVolumeCaps, 'smb'); + case 'ncp': + return asLocal(ncpVolumeCaps, 'ncp'); + case 'etherdfs': + return asLocal(edfsVolumeCaps, 'etherdfs'); + case 'local': + return localShareCaps; + default: + return kind === 'local' ? localShareCaps : afpVolumeCaps; + } +} + +export function isPathRef(ref: NodeRef): ref is string { + return typeof ref === 'string'; +} + +export function refsEqual(a: NodeRef, b: NodeRef): boolean { + return a === b; +} + +/** Map key that distinguishes CNID `2` from path `"2"`. */ +export function refKey(ref: NodeRef): string { + return typeof ref === 'string' ? `p:${ref}` : `c:${ref}`; +} + +export function parentPathOf(path: string): string { + const i = path.lastIndexOf('/'); + return i < 0 ? '' : path.slice(0, i); +} + +export function joinStorePath(parent: string, name: string): string { + const n = name.replace(/^\/+|\/+$/g, ''); + if (!n) return parent; + return parent ? `${parent}/${n}` : n; +} + +export function leafName(path: string): string { + const i = path.lastIndexOf('/'); + return i < 0 ? path : path.slice(i + 1); +} + +/** Parse a `refKey` (or a legacy numeric `data-id`) back to a NodeRef. */ +export function parseRefKey(key: string | null | undefined): NodeRef | null { + if (key == null || key === '') return null; + if (key.startsWith('p:')) return key.slice(2); + if (key.startsWith('c:')) { + const n = Number(key.slice(2)); + return Number.isFinite(n) ? n : null; + } + const n = Number(key); + if (key !== '' && Number.isFinite(n) && String(n) === key) return n; + return key; +} + +/** Narrow a NodeRef to a CNID. Path catalogs must not call this. */ +export function asCnid(ref: NodeRef): number { + if (typeof ref !== 'number') throw new Error('cnid catalog requires numeric id'); + return ref; +} + +/** Narrow a NodeRef to a store path. CNID catalogs must not call this. */ +export function asPath(ref: NodeRef): string { + if (typeof ref !== 'string') throw new Error('path catalog requires store path'); + return ref; +} + +/** Query params for a native catalog ref (`id` xor `path`). */ +export function refQuery(ref: NodeRef): { id?: string; path?: string } { + return typeof ref === 'string' ? { path: ref } : { id: String(ref) }; +} + +export function refBody(ref: NodeRef): { id?: number; path?: string } { + return typeof ref === 'string' ? { path: ref } : { id: ref }; +} + +export function parentBody(parent: NodeRef): { parentId?: number; parentPath?: string } { + return typeof parent === 'string' ? { parentPath: parent } : { parentId: parent }; +} diff --git a/src/fs/codecs.test.ts b/src/fs/codecs.test.ts new file mode 100644 index 0000000..32c604b --- /dev/null +++ b/src/fs/codecs.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + ARCHIVE_CODEC_SIT, + registerArchiveCodec, + registeredArchiveCodecs, + sniffArchiveCodec, +} from './codecs'; +import { expandIncoming, isExpandableArchive } from './expand-incoming'; +import type { ExpandedNode } from './expand-incoming'; + +describe('codec registry', () => { + it('registers bundled archive codecs so the same id can replace them', () => { + const ids = registeredArchiveCodecs().map((c) => c.id); + expect(ids).toEqual(expect.arrayContaining([ARCHIVE_CODEC_SIT, 'binhex', 'macbinary', 'zip', 'applesingle'])); + }); + + it('lets a later archive codec replace an earlier one with the same id', () => { + const first: ExpandedNode[] = [{ kind: 'file', name: 'a', data: new Uint8Array(), resource: new Uint8Array(), finderInfo: new Uint8Array(32) }]; + const second: ExpandedNode[] = [{ kind: 'file', name: 'b', data: new Uint8Array(), resource: new Uint8Array(), finderInfo: new Uint8Array(32) }]; + registerArchiveCodec({ + id: 'test-sit', + sniff: ({ name }) => name === 'codec-registry-replace.sitx', + expand: () => first, + }); + registerArchiveCodec({ + id: 'test-sit', + sniff: ({ name }) => name === 'codec-registry-replace.sitx', + expand: () => second, + }); + expect(registeredArchiveCodecs().filter((c) => c.id === 'test-sit')).toHaveLength(1); + expect(sniffArchiveCodec({ name: 'codec-registry-replace.sitx' })?.expand('codec-registry-replace.sitx', new Uint8Array())).toBe(second); + }); + + it('lets a replacement codec claim a name the bundled expanders would ignore', () => { + registerArchiveCodec({ + id: 'vendor-sea', + sniff: ({ name }) => name.endsWith('.sea'), + expand: () => [ + { kind: 'file', name: 'Read Me', data: new Uint8Array([1]), resource: new Uint8Array(), finderInfo: new Uint8Array(32) }, + ], + }); + expect(isExpandableArchive('Install.sea')).toBe(true); + const out = expandIncoming('Install.sea', new Uint8Array([0])); + expect(out?.[0]?.kind === 'file' && out[0].name).toBe('Read Me'); + }); +}); diff --git a/src/fs/codecs.ts b/src/fs/codecs.ts new file mode 100644 index 0000000..f2f94c6 --- /dev/null +++ b/src/fs/codecs.ts @@ -0,0 +1,136 @@ +/** + * Pluggable Macintosh file codecs. + * + * When ClassicStack-web splits into packages, these registries are the public + * seams — not FinderWindow. Third parties register their own StuffIt expander, + * rez decoder, dcmp method, or resource-type viewer without forking the PWA. + * + * Later registrations win on sniff (unshift), so an app can replace the bundled + * SIT implementation. + */ + +import type { ExpandedNode } from './expand-incoming'; + +export type ArchiveSniff = { + name: string; + finderInfo?: Uint8Array; + data?: Uint8Array; + resource?: Uint8Array; +}; + +/** Bundled archive codec ids. Re-register the same id to replace the default expander. */ +export const ARCHIVE_CODEC_SIT = 'sit'; +export const ARCHIVE_CODEC_BINHEX = 'binhex'; +export const ARCHIVE_CODEC_MACBINARY = 'macbinary'; +export const ARCHIVE_CODEC_ZIP = 'zip'; +export const ARCHIVE_CODEC_APPLESINGLE = 'applesingle'; + +/** Bundled Apple compressed-resource decompressor. Re-register to replace dcmp 0/1/2. */ +export const RESOURCE_DECOMPRESSOR_DCMP = 'dcmp'; + +/** BinHex / MacBinary / StuffIt / ZIP / a third-party archive format. */ +export interface ArchiveCodec { + /** Stable id (`sit`, `binhex`, `zip`, or a vendor name). */ + id: string; + /** + * When false, sniffing this codec does not offer Finder Expand + * (AppleSingle is unwrapped while expanding other archives). + */ + expandable?: boolean; + sniff(input: ArchiveSniff): boolean; + expand(name: string, data: Uint8Array): ExpandedNode[] | null; +} + +/** Apple compressed-resource ('dcmp' 0/1/2) or a replacement decompressor. */ +export interface ResourceDecompressor { + id: string; + sniff(data: Uint8Array, attributes?: number): boolean; + decompress(data: Uint8Array): Uint8Array; +} + +/** + * Decode one resource type (ICN#, cicn, vers, or a rez-style text dump). + * `type` is a four-character OSType, or `*` for a catch-all. + */ +export interface ResourceTypeDecoder { + type: string; + decode(type: string, id: number, payload: Uint8Array): unknown | null; +} + +/** + * Decompile / compile ResEdit-style `.r` (rez) text. No bundled implementation; + * register one to teach the Resource Fork explorer a text view. + */ +export interface RezCodec { + id: string; + decompile?(type: string, id: number, payload: Uint8Array): string | null; + compile?(source: string): { type: string; id: number; payload: Uint8Array }[] | null; +} + +const archives: ArchiveCodec[] = []; +const decompressors: ResourceDecompressor[] = []; +const typeDecoders: ResourceTypeDecoder[] = []; +const rezCodecs: RezCodec[] = []; + +function unshiftUnique(list: T[], item: T, key: keyof T): void { + const id = item[key]; + if (id != null) { + const i = list.findIndex((x) => x[key] === id); + if (i >= 0) list.splice(i, 1); + } + list.unshift(item); +} + +/** Register an archive expander. Re-registering the same `id` replaces the previous codec. */ +export function registerArchiveCodec(codec: ArchiveCodec): void { + unshiftUnique(archives, codec, 'id'); +} + +export function registeredArchiveCodecs(): readonly ArchiveCodec[] { + return archives; +} + +export function registerResourceDecompressor(codec: ResourceDecompressor): void { + unshiftUnique(decompressors, codec, 'id'); +} + +export function registeredResourceDecompressors(): readonly ResourceDecompressor[] { + return decompressors; +} + +export function registerResourceTypeDecoder(decoder: ResourceTypeDecoder): void { + unshiftUnique(typeDecoders, decoder, 'type'); +} + +export function registeredResourceTypeDecoders(): readonly ResourceTypeDecoder[] { + return typeDecoders; +} + +export function registerRezCodec(codec: RezCodec): void { + unshiftUnique(rezCodecs, codec, 'id'); +} + +export function registeredRezCodecs(): readonly RezCodec[] { + return rezCodecs; +} + +export function sniffArchiveCodec(input: ArchiveSniff): ArchiveCodec | undefined { + return archives.find((c) => c.sniff(input)); +} + +export function decodeResourceType(type: string, id: number, payload: Uint8Array): unknown | null { + for (const d of typeDecoders) { + if (d.type !== '*' && d.type !== type) continue; + const out = d.decode(type, id, payload); + if (out != null) return out; + } + return null; +} + +export function decompileRez(type: string, id: number, payload: Uint8Array): string | null { + for (const c of rezCodecs) { + const out = c.decompile?.(type, id, payload); + if (out != null) return out; + } + return null; +} diff --git a/src/fs/empty-catalog.ts b/src/fs/empty-catalog.ts new file mode 100644 index 0000000..2aded06 --- /dev/null +++ b/src/fs/empty-catalog.ts @@ -0,0 +1,128 @@ +/** No-op Catalog used when the Finder has no local share (ClassicStack Go SPA). */ + +import type { ByteRangeReader } from './byte-range'; +import type { ResourceFork } from './resource-fork'; +import { afpVolumeCaps, type CatalogCapabilities, type NodeRef } from './catalog-caps'; +import type { + Catalog, + ChildrenBatchListener, + CnidVNode, + VfsChangeListener, + VNode, +} from './virtual-fs'; + +const ROOT_ID = 2; + +function emptyRoot(): CnidVNode { + return { + addr: 'cnid', + id: ROOT_ID, + parentId: 1, + name: '', + isDir: true, + data: new Uint8Array(), + resource: new Uint8Array(), + finderInfo: new Uint8Array(32), + createDate: 0, + modDate: 0, + }; +} + +function unsupported(op: string): never { + throw new Error(`empty catalog: ${op} is not supported`); +} + +/** Catalog with an empty root; mutations throw. */ +export class EmptyCatalog implements Catalog { + capabilities(): CatalogCapabilities { + return { + ...afpVolumeCaps, + readOnly: true, + resourceFork: false, + finderInfo: false, + desktopIcons: false, + resourceIcons: false, + }; + } + + rootId(): NodeRef { + return ROOT_ID; + } + + subscribe(_fn: VfsChangeListener): () => void { + return () => undefined; + } + + beginBatch(): void {} + endBatch(): void {} + + async get(ref: NodeRef): Promise { + return ref === ROOT_ID ? emptyRoot() : undefined; + } + + async ensureContent(ref: NodeRef): Promise { + return this.get(ref); + } + + async children( + _parent: NodeRef, + onBatch?: ChildrenBatchListener, + _signal?: AbortSignal, + ): Promise { + onBatch?.([]); + return []; + } + + async lookup(): Promise { + return undefined; + } + + async resolvePath(path: string): Promise { + return path === '' || path === '/' ? emptyRoot() : undefined; + } + + async pathOf(): Promise { + return ''; + } + + async loadResourceFork(): Promise { + return null; + } + + async loadIconResources(): Promise { + return null; + } + + async withRangeReader( + _node: VNode, + fn: (read: ByteRangeReader) => Promise, + ): Promise { + const read: ByteRangeReader = async () => new Uint8Array(); + return fn(read); + } + + mkdir(): Promise { + return unsupported('mkdir'); + } + ensureDir(): Promise { + return unsupported('ensureDir'); + } + createFile(): Promise { + return unsupported('createFile'); + } + put(): Promise { + return unsupported('put'); + } + rename(): Promise { + return unsupported('rename'); + } + move(): Promise { + return unsupported('move'); + } + remove(): Promise { + return unsupported('remove'); + } + importDataTransfer(): Promise { + return unsupported('importDataTransfer'); + } +} diff --git a/src/fs/expand-incoming.ts b/src/fs/expand-incoming.ts index ed8d174..673eb62 100644 --- a/src/fs/expand-incoming.ts +++ b/src/fs/expand-incoming.ts @@ -9,6 +9,15 @@ import { parseMacBinary } from './macbinary'; import { SitError } from './stuffit-codec'; import { isStuffItArchive, parseStuffIt, stuffItExpandError, type SitEntry } from './stuffit'; import { isZipArchive, parseZip, type ZipMember } from './zip'; +import { + ARCHIVE_CODEC_APPLESINGLE, + ARCHIVE_CODEC_BINHEX, + ARCHIVE_CODEC_MACBINARY, + ARCHIVE_CODEC_SIT, + ARCHIVE_CODEC_ZIP, + registerArchiveCodec, + sniffArchiveCodec, +} from './codecs'; const MAX_DEPTH = 8; @@ -26,21 +35,16 @@ export type ExpandedNode = ExpandedFile | ExpandedDir; export { isStuffItArchive, isZipArchive }; const SIT_TYPES = new Set(['SIT!', 'SIT5', 'SITD']); -const EXPANDABLE_EXTS = new Set(['sit', 'hqx', 'bin', 'zip']); /** * True when the Finder should offer Expand (`.sit` / `.hqx` / `.bin` / `.zip`, StuffIt type, ZIP, * or BinHex stored as TEXT/SITx). Pass `data` when loaded: Expander Read Me files are also - * TEXT/SITx but are not archives. + * TEXT/SITx but are not archives. Registered archive codecs are consulted first so a replacement + * SIT expander can opt files in or out. */ export function isExpandableArchive(name: string, finderInfo?: Uint8Array, data?: Uint8Array): boolean { - if (EXPANDABLE_EXTS.has(filenameExtension(name))) return true; - if (!finderInfo || finderInfo.length < 8) return false; - const type = ostypeFromBytes(finderInfo, 0); - const creator = ostypeFromBytes(finderInfo, 4); - if (SIT_TYPES.has(type) || type === 'ZIP ') return true; - if (type === 'TEXT' && creator === 'SITx') return !data?.length || parseBinHex(data) != null; - return false; + const codec = sniffArchiveCodec({ name, finderInfo, data }); + return codec != null && codec.expandable !== false; } /** Modal body when Expand cannot unpack a file. Never uses Finder type/creator. */ @@ -92,40 +96,14 @@ export function expandIncoming(name: string, bytes: Uint8Array): ExpandedNode[] function expandBytes(name: string, data: Uint8Array, resource: Uint8Array, depth: number): ExpandedNode[] | null { if (depth > MAX_DEPTH) return null; - const hqx = parseBinHex(data); - if (hqx) return finishMacFile(hqx, depth + 1); - - const mb = parseMacBinary(data); - if (mb) return finishMacFile(mb, depth + 1); - - if (resource.length === 0) { - const as = parseAppleSingle(data); - if (as) { - return finishMacFile( - { - name, - data: as.data, - resource: as.resource, - finderInfo: as.finderInfo, - }, - depth + 1, - ); - } - } - - if (isStuffItArchive(data)) { - const entries = parseStuffIt(data); - if (entries && entries.length) return expandNodes(sitEntriesToTree(entries), depth + 1); - } - - if (isZipArchive(data)) { - const entries = parseZip(data); - if (entries && entries.length) return expandNodes(zipMembersToTree(entries), depth + 1); - } - return null; + const codec = sniffArchiveCodec({ name, data, resource }); + if (!codec) return null; + const out = codec.expand(name, data); + if (!out?.length) return null; + return expandNodes(out, depth + 1); } -function finishMacFile(file: MacFile, depth: number): ExpandedNode[] { +function macFileNode(file: MacFile): ExpandedFile { let current = file; if (current.resource.length === 0) { const as = parseAppleSingle(current.data); @@ -140,9 +118,7 @@ function finishMacFile(file: MacFile, depth: number): ExpandedNode[] { }; } } - const nested = tryExpandBytes(current.name, current.data, current.resource, depth); - if (nested) return nested; - return [{ kind: 'file', ...current }]; + return { kind: 'file', ...current }; } /** Nested members that cannot be unpacked stay packed instead of aborting the parent archive. */ @@ -244,3 +220,76 @@ function membersToTree( } return root; } + +function finderType(finderInfo?: Uint8Array): string | undefined { + if (!finderInfo || finderInfo.length < 4) return undefined; + return ostypeFromBytes(finderInfo, 0); +} + +function finderCreator(finderInfo?: Uint8Array): string | undefined { + if (!finderInfo || finderInfo.length < 8) return undefined; + return ostypeFromBytes(finderInfo, 4); +} + +/** Register bundled expanders. Later `registerArchiveCodec` with the same id replaces one. */ +function registerBuiltinArchiveCodecs(): void { + // Last registered is sniffed first, matching the previous BinHex → MacBinary → AppleSingle → SIT → ZIP order. + registerArchiveCodec({ + id: ARCHIVE_CODEC_ZIP, + sniff: ({ name, finderInfo, data }) => + filenameExtension(name) === 'zip' || finderType(finderInfo) === 'ZIP ' || (!!data?.length && isZipArchive(data)), + expand: (_name, data) => { + const entries = parseZip(data); + return entries?.length ? zipMembersToTree(entries) : null; + }, + }); + registerArchiveCodec({ + id: ARCHIVE_CODEC_SIT, + sniff: ({ name, finderInfo, data }) => { + const type = finderType(finderInfo); + return ( + filenameExtension(name) === 'sit' || + (!!type && SIT_TYPES.has(type)) || + (!!data?.length && isStuffItArchive(data)) + ); + }, + expand: (_name, data) => { + const entries = parseStuffIt(data); + return entries?.length ? sitEntriesToTree(entries) : null; + }, + }); + registerArchiveCodec({ + id: ARCHIVE_CODEC_APPLESINGLE, + expandable: false, + sniff: ({ data, resource }) => !resource?.length && !!data?.length && parseAppleSingle(data) != null, + expand: (name, data) => { + const as = parseAppleSingle(data); + if (!as) return null; + return [{ kind: 'file', name, data: as.data, resource: as.resource, finderInfo: as.finderInfo }]; + }, + }); + registerArchiveCodec({ + id: ARCHIVE_CODEC_MACBINARY, + sniff: ({ name, data }) => filenameExtension(name) === 'bin' || (!!data?.length && parseMacBinary(data) != null), + expand: (_name, data) => { + const mb = parseMacBinary(data); + return mb ? [macFileNode(mb)] : null; + }, + }); + registerArchiveCodec({ + id: ARCHIVE_CODEC_BINHEX, + sniff: ({ name, finderInfo, data }) => { + if (filenameExtension(name) === 'hqx') return true; + if (finderType(finderInfo) === 'TEXT' && finderCreator(finderInfo) === 'SITx') { + return !data?.length || parseBinHex(data) != null; + } + return !!data?.length && parseBinHex(data) != null; + }, + expand: (_name, data) => { + const hqx = parseBinHex(data); + return hqx ? [macFileNode(hqx)] : null; + }, + }); +} + +registerBuiltinArchiveCodecs(); diff --git a/src/fs/expand-inplace.ts b/src/fs/expand-inplace.ts index 30c3618..8a3368c 100644 --- a/src/fs/expand-inplace.ts +++ b/src/fs/expand-inplace.ts @@ -1,238 +1,240 @@ -/** Expand an existing StuffIt archive via Catalog ranged reads (headers, then each member). */ - -import { hfsTimeToAfp } from '../protocol/afp/constants'; -import { makeFinderInfo } from './mac-file'; -import { expandedFromSitEntries, type ExpandedDir } from './expand-incoming'; -import { importExpandedTree, type ImportItemTrack, type ExpandTrackFile } from './import-transfer'; -import { - extractSitMember, - parseStuffItFromReader, - stuffItExpandError, - type SitPackedMember, -} from './stuffit'; -import { SitError } from './stuffit-codec'; -import { - planItemPlacement, - TransferCancelled, - type NameConflictChoice, -} from './name-conflict'; -import { throwIfAborted } from '../util/abort'; -import { log } from '../util/logger'; -import type { Catalog, VNode } from './virtual-fs'; -import type { ByteRangeReader } from './byte-range'; - -export type ExpandSitInPlaceOpts = { - fileSize?: number; - track?: ImportItemTrack; - resolveConflict: (info: { - name: string; - isDir: boolean; - suggestedName: string; - }) => Promise; - /** Wrap catalog writes (Finder own-mutation + beginBatch/endBatch). */ - wrapWrite?: (fn: () => Promise) => Promise; -}; - -type MetaFile = { kind: 'file'; name: string; member: SitPackedMember }; -type MetaDir = { kind: 'dir'; name: string; children: MetaNode[]; member?: SitPackedMember }; -type MetaNode = MetaFile | MetaDir; - -type ExpandFs = Pick< - Catalog, - 'withRangeReader' | 'lookup' | 'remove' | 'ensureDir' | 'createFile' | 'put' ->; - -/** - * Expand a StuffIt archive through VFS range reads. Returns false when the - * payload is not StuffIt (caller should load the whole file and use expandArchiveFile). - */ -export async function expandSitInPlace( - fs: ExpandFs, - node: VNode, - opts: ExpandSitInPlaceOpts, -): Promise { - log.trace(`expand in-place “${node.name}” ${opts.fileSize ?? node.dataBytes ?? node.data.length}b`, 'expand'); - return fs.withRangeReader( - node, - (read) => expandSitFromReader(fs, node.parentId, read, opts), - { signal: opts.track?.signal }, - ); -} - -async function expandSitFromReader( - fs: ExpandFs, - parentId: number, - read: ByteRangeReader, - opts: ExpandSitInPlaceOpts, -): Promise { - let creditCatalog = true; - const catalogRead: ByteRangeReader = async (offset, count) => { - throwIfAborted(opts.track?.signal); - const buf = await read(offset, count); - if (creditCatalog) opts.track?.onBytes?.(buf.length); - return buf; - }; - opts.track?.onStatus?.('Reading archive'); - let members: SitPackedMember[] | null; - try { - members = await parseStuffItFromReader(catalogRead, opts.fileSize); - } catch (err) { - if (err instanceof SitError) throw err; - throw stuffItExpandError(new Uint8Array(), err); - } - creditCatalog = false; - if (!members) return false; - if (!members.length) throw stuffItExpandError(new Uint8Array()); - log.trace(`expand catalog ${members.length} member(s)`, 'expand'); - - const tree = sitPackedToTree(members); - const reserved = new Set(); - const planned: { item: MetaNode; replaceId: number | null }[] = []; - for (const item of tree) { - const plan = await planItemPlacement(fs, parentId, item.name, item.kind === 'dir', { - reserved, - resolveConflict: opts.resolveConflict, - }); - if (!plan) throw new TransferCancelled(); - reserved.add(plan.destName.toLowerCase()); - planned.push({ item: renameMeta(item, plan.destName), replaceId: plan.replaceId }); - } - - const files = metaFiles(planned.map((row) => row.item)); - opts.track?.onExpandBegin?.( - files.reduce((n, f) => n + f.bytesTotal, 0), - files, - ); - - const write = async (): Promise => { - for (const row of planned) { - if (row.replaceId != null) await fs.remove(row.replaceId); - } - for (const row of planned) { - await writeMetaNode(fs, parentId, row.item, catalogRead, opts.track); - } - }; - if (opts.wrapWrite) await opts.wrapWrite(write); - else await write(); - return true; -} - -function renameMeta(node: MetaNode, name: string): MetaNode { - return { ...node, name }; -} - -function sitPackedToTree(members: SitPackedMember[]): MetaNode[] { - const root: MetaNode[] = []; - const dirs = new Map(); - const ensureDir = (path: string, member?: SitPackedMember): MetaDir => { - const hit = dirs.get(path); - if (hit) { - if (member) hit.member = member; - return hit; - } - const parts = path.split('/'); - const name = parts.pop()!; - const node: MetaDir = { kind: 'dir', name, children: [], member }; - dirs.set(path, node); - if (parts.length === 0) root.push(node); - else ensureDir(parts.join('/')).children.push(node); - return node; - }; - for (const m of members) { - const parts = m.name.split('/').filter(Boolean); - if (!parts.length) continue; - const parentPath = parts.slice(0, -1).join('/'); - const parent = parentPath ? ensureDir(parentPath).children : root; - if (m.isFolder) { - ensureDir(m.name, m); - continue; - } - parent.push({ kind: 'file', name: parts[parts.length - 1]!, member: m }); - } - return root; -} - -function metaFiles(nodes: MetaNode[], prefix = ''): ExpandTrackFile[] { - const out: ExpandTrackFile[] = []; - for (const node of nodes) { - const path = prefix ? `${prefix}/${node.name}` : node.name; - if (node.kind === 'dir') { - out.push(...metaFiles(node.children, path)); - continue; - } - out.push({ - name: node.name, - path, - bytesTotal: node.member.dataUlen + node.member.rsrcUlen, - finderInfo: makeFinderInfo(node.member.fileType, node.member.creator, node.member.finderFlags), - }); - } - return out; -} - -async function writeMetaNode( - fs: ExpandFs, - parentId: number, - node: MetaNode, - read: ByteRangeReader, - track?: ImportItemTrack, - prefix = '', -): Promise { - const path = prefix ? `${prefix}/${node.name}` : node.name; - if (node.kind === 'dir') { - log.trace(`expand dir “${path}”`, 'expand'); - const dir = await fs.ensureDir(parentId, node.name); - const folderMeta = folderExpanded(node); - if (folderMeta) await stampDir(fs, dir, folderMeta); - track?.onDir?.(parentId, node.name, dir.id, path); - for (const child of node.children) { - await writeMetaNode(fs, dir.id, child, read, track, path); - } - return; - } - const file: ExpandTrackFile = { - name: node.name, - path, - bytesTotal: node.member.dataUlen + node.member.rsrcUlen, - finderInfo: makeFinderInfo(node.member.fileType, node.member.creator, node.member.finderFlags), - }; - track?.onExpand?.(file); - log.trace(`expand extract “${path}” packed=${node.member.rsrcClen + node.member.dataClen}b`, 'expand'); - const entry = await extractSitMember(read, { ...node.member, name: node.name }); - log.trace( - `expand write “${path}” data=${entry.data.length}b rsrc=${entry.resource.length}b`, - 'expand', - ); - const expanded = expandedFromSitEntries([{ ...entry, name: node.name }]); - await importExpandedTree(fs, parentId, expanded, track, { announce: false, prefix }); -} - -function folderExpanded(node: MetaDir): ExpandedDir | null { - const m = node.member; - if (!m) return null; - return { - kind: 'dir', - name: node.name, - children: [], - finderInfo: makeFinderInfo(m.fileType, m.creator, m.finderFlags), - createDate: m.createDate || undefined, - modDate: m.modDate || undefined, - }; -} - -async function stampDir(fs: Pick, dir: VNode, meta: ExpandedDir): Promise { - let dirty = false; - if (meta.finderInfo && meta.finderInfo.some((b) => b !== 0)) { - dir.finderInfo = meta.finderInfo; - dirty = true; - } - if (meta.createDate) { - dir.createDate = hfsTimeToAfp(meta.createDate); - dirty = true; - } - if (meta.modDate) { - dir.modDate = hfsTimeToAfp(meta.modDate); - dirty = true; - } - if (dirty) await fs.put(dir); -} +/** Expand an existing StuffIt archive via Catalog ranged reads (headers, then each member). */ + +import { hfsTimeToAfp } from '../protocol/afp/constants'; +import { makeFinderInfo } from './mac-file'; +import { expandedFromSitEntries, type ExpandedDir } from './expand-incoming'; +import { importExpandedTree, type ImportItemTrack, type ExpandTrackFile } from './import-transfer'; +import { + extractSitMember, + parseStuffItFromReader, + stuffItExpandError, + type SitPackedMember, +} from './stuffit'; +import { SitError } from './stuffit-codec'; +import { + planItemPlacement, + TransferCancelled, + type NameConflictChoice, +} from './name-conflict'; +import { throwIfAborted } from '../util/abort'; +import { log } from '../util/logger'; +import type { Catalog, VNode } from './virtual-fs'; +import { nodeRef, parentRef } from './virtual-fs'; +import type { NodeRef } from './catalog-caps'; +import type { ByteRangeReader } from './byte-range'; + +export type ExpandSitInPlaceOpts = { + fileSize?: number; + track?: ImportItemTrack; + resolveConflict: (info: { + name: string; + isDir: boolean; + suggestedName: string; + }) => Promise; + /** Wrap catalog writes (Finder own-mutation + beginBatch/endBatch). */ + wrapWrite?: (fn: () => Promise) => Promise; +}; + +type MetaFile = { kind: 'file'; name: string; member: SitPackedMember }; +type MetaDir = { kind: 'dir'; name: string; children: MetaNode[]; member?: SitPackedMember }; +type MetaNode = MetaFile | MetaDir; + +type ExpandFs = Pick< + Catalog, + 'withRangeReader' | 'lookup' | 'remove' | 'ensureDir' | 'createFile' | 'put' +>; + +/** + * Expand a StuffIt archive through VFS range reads. Returns false when the + * payload is not StuffIt (caller should load the whole file and use expandArchiveFile). + */ +export async function expandSitInPlace( + fs: ExpandFs, + node: VNode, + opts: ExpandSitInPlaceOpts, +): Promise { + log.trace(`expand in-place “${node.name}” ${opts.fileSize ?? node.dataBytes ?? node.data.length}b`, 'expand'); + return fs.withRangeReader( + node, + (read) => expandSitFromReader(fs, parentRef(node), read, opts), + { signal: opts.track?.signal }, + ); +} + +async function expandSitFromReader( + fs: ExpandFs, + parentId: NodeRef, + read: ByteRangeReader, + opts: ExpandSitInPlaceOpts, +): Promise { + let creditCatalog = true; + const catalogRead: ByteRangeReader = async (offset, count) => { + throwIfAborted(opts.track?.signal); + const buf = await read(offset, count); + if (creditCatalog) opts.track?.onBytes?.(buf.length); + return buf; + }; + opts.track?.onStatus?.('Reading archive'); + let members: SitPackedMember[] | null; + try { + members = await parseStuffItFromReader(catalogRead, opts.fileSize); + } catch (err) { + if (err instanceof SitError) throw err; + throw stuffItExpandError(new Uint8Array(), err); + } + creditCatalog = false; + if (!members) return false; + if (!members.length) throw stuffItExpandError(new Uint8Array()); + log.trace(`expand catalog ${members.length} member(s)`, 'expand'); + + const tree = sitPackedToTree(members); + const reserved = new Set(); + const planned: { item: MetaNode; replaceId: NodeRef | null }[] = []; + for (const item of tree) { + const plan = await planItemPlacement(fs, parentId, item.name, item.kind === 'dir', { + reserved, + resolveConflict: opts.resolveConflict, + }); + if (!plan) throw new TransferCancelled(); + reserved.add(plan.destName.toLowerCase()); + planned.push({ item: renameMeta(item, plan.destName), replaceId: plan.replaceId }); + } + + const files = metaFiles(planned.map((row) => row.item)); + opts.track?.onExpandBegin?.( + files.reduce((n, f) => n + f.bytesTotal, 0), + files, + ); + + const write = async (): Promise => { + for (const row of planned) { + if (row.replaceId != null) await fs.remove(row.replaceId); + } + for (const row of planned) { + await writeMetaNode(fs, parentId, row.item, catalogRead, opts.track); + } + }; + if (opts.wrapWrite) await opts.wrapWrite(write); + else await write(); + return true; +} + +function renameMeta(node: MetaNode, name: string): MetaNode { + return { ...node, name }; +} + +function sitPackedToTree(members: SitPackedMember[]): MetaNode[] { + const root: MetaNode[] = []; + const dirs = new Map(); + const ensureDir = (path: string, member?: SitPackedMember): MetaDir => { + const hit = dirs.get(path); + if (hit) { + if (member) hit.member = member; + return hit; + } + const parts = path.split('/'); + const name = parts.pop()!; + const node: MetaDir = { kind: 'dir', name, children: [], member }; + dirs.set(path, node); + if (parts.length === 0) root.push(node); + else ensureDir(parts.join('/')).children.push(node); + return node; + }; + for (const m of members) { + const parts = m.name.split('/').filter(Boolean); + if (!parts.length) continue; + const parentPath = parts.slice(0, -1).join('/'); + const parent = parentPath ? ensureDir(parentPath).children : root; + if (m.isFolder) { + ensureDir(m.name, m); + continue; + } + parent.push({ kind: 'file', name: parts[parts.length - 1]!, member: m }); + } + return root; +} + +function metaFiles(nodes: MetaNode[], prefix = ''): ExpandTrackFile[] { + const out: ExpandTrackFile[] = []; + for (const node of nodes) { + const path = prefix ? `${prefix}/${node.name}` : node.name; + if (node.kind === 'dir') { + out.push(...metaFiles(node.children, path)); + continue; + } + out.push({ + name: node.name, + path, + bytesTotal: node.member.dataUlen + node.member.rsrcUlen, + finderInfo: makeFinderInfo(node.member.fileType, node.member.creator, node.member.finderFlags), + }); + } + return out; +} + +async function writeMetaNode( + fs: ExpandFs, + parentId: NodeRef, + node: MetaNode, + read: ByteRangeReader, + track?: ImportItemTrack, + prefix = '', +): Promise { + const path = prefix ? `${prefix}/${node.name}` : node.name; + if (node.kind === 'dir') { + log.trace(`expand dir “${path}”`, 'expand'); + const dir = await fs.ensureDir(parentId, node.name); + const folderMeta = folderExpanded(node); + if (folderMeta) await stampDir(fs, dir, folderMeta); + track?.onDir?.(parentId, node.name, nodeRef(dir), path); + for (const child of node.children) { + await writeMetaNode(fs, nodeRef(dir), child, read, track, path); + } + return; + } + const file: ExpandTrackFile = { + name: node.name, + path, + bytesTotal: node.member.dataUlen + node.member.rsrcUlen, + finderInfo: makeFinderInfo(node.member.fileType, node.member.creator, node.member.finderFlags), + }; + track?.onExpand?.(file); + log.trace(`expand extract “${path}” packed=${node.member.rsrcClen + node.member.dataClen}b`, 'expand'); + const entry = await extractSitMember(read, { ...node.member, name: node.name }); + log.trace( + `expand write “${path}” data=${entry.data.length}b rsrc=${entry.resource.length}b`, + 'expand', + ); + const expanded = expandedFromSitEntries([{ ...entry, name: node.name }]); + await importExpandedTree(fs, parentId, expanded, track, { announce: false, prefix }); +} + +function folderExpanded(node: MetaDir): ExpandedDir | null { + const m = node.member; + if (!m) return null; + return { + kind: 'dir', + name: node.name, + children: [], + finderInfo: makeFinderInfo(m.fileType, m.creator, m.finderFlags), + createDate: m.createDate || undefined, + modDate: m.modDate || undefined, + }; +} + +async function stampDir(fs: Pick, dir: VNode, meta: ExpandedDir): Promise { + let dirty = false; + if (meta.finderInfo && meta.finderInfo.some((b) => b !== 0)) { + dir.finderInfo = meta.finderInfo; + dirty = true; + } + if (meta.createDate) { + dir.createDate = hfsTimeToAfp(meta.createDate); + dirty = true; + } + if (meta.modDate) { + dir.modDate = hfsTimeToAfp(meta.modDate); + dirty = true; + } + if (dirty) await fs.put(dir); +} diff --git a/src/fs/extension-map-netatalk.test.ts b/src/fs/extension-map-netatalk.test.ts new file mode 100644 index 0000000..f965e85 --- /dev/null +++ b/src/fs/extension-map-netatalk.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { parseNetatalkExtensionMap, serializeNetatalkExtensionMap } from './extension-map-netatalk'; + +const sample = `# header +. "????" "????" Unix Binary +.bin "SIT!" "SITx" MacBinary StuffIt Expander +#.txt "TEXT" "ttxt" ASCII Text +`; + +describe('Netatalk extension map', () => { + it('parses enabled lines and keeps trailing comment text', () => { + const rows = parseNetatalkExtensionMap(sample); + expect(rows).toEqual([ + { extension: '.', type: '????', creator: '????', comment: 'Unix Binary' }, + { extension: 'bin', type: 'SIT!', creator: 'SITx', comment: 'MacBinary StuffIt Expander' }, + ]); + }); + + it('replaces enabled lines in place and keeps comments', () => { + const next = serializeNetatalkExtensionMap( + [ + { extension: 'bin', type: 'SIT!', creator: 'SITx', comment: 'MacBinary' }, + { extension: 'png', type: 'PNG ', creator: 'ogle', comment: 'PNG' }, + ], + sample, + ); + expect(next).toContain('# header'); + expect(next).toContain('#.txt "TEXT" "ttxt" ASCII Text'); + expect(next).not.toContain('Unix Binary'); + expect(next).toContain('.bin "SIT!" "SITx" MacBinary'); + expect(next).toContain('.png "PNG " "ogle" PNG'); + }); +}); diff --git a/src/fs/extension-map-netatalk.ts b/src/fs/extension-map-netatalk.ts new file mode 100644 index 0000000..7d3f7e3 --- /dev/null +++ b/src/fs/extension-map-netatalk.ts @@ -0,0 +1,81 @@ +/** Netatalk `.ext "TYPE" "CRTR"` codec for the shared extension-map editor. */ + +import { + normalizeExtension, + normalizeMappings, + padOsType, + type ExtensionMapping, +} from './extension-map'; + +const LINE = /^(\S+)\s+"([^"]*)"\s+"([^"]*)"(.*)$/; + +function parseNetatalkLine(line: string): ExtensionMapping | null { + const m = LINE.exec(line.trim()); + if (!m) return null; + const token = m[1]!; + const extension = token === '.' ? '.' : normalizeExtension(token); + if (!extension) return null; + return { + extension, + type: padOsType(m[2]!), + creator: padOsType(m[3]!), + comment: (m[4] ?? '').trim(), + }; +} + +/** Enabled (uncommented) Netatalk mappings. `#` lines and blanks are ignored. */ +export function parseNetatalkExtensionMap(text: string): ExtensionMapping[] { + const rows: ExtensionMapping[] = []; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const row = parseNetatalkLine(line); + if (row) rows.push(row); + } + return normalizeMappings(rows); +} + +export function formatNetatalkLine(row: ExtensionMapping): string { + const ext = row.extension === '.' ? '.' : `.${row.extension}`; + const comment = row.comment ? ` ${row.comment}` : ''; + return `${ext} "${padOsType(row.type)}" "${padOsType(row.creator)}"${comment}`; +} + +/** + * Write enabled rows in Netatalk form. When `original` is the previous file, + * `#` comments and commented-out mappings are kept; enabled lines are replaced + * in place and new extensions are appended. + */ +export function serializeNetatalkExtensionMap( + rows: readonly ExtensionMapping[], + original = '', +): string { + const enabled = normalizeMappings(rows); + const byExt = new Map(enabled.map((r) => [r.extension, r])); + const written = new Set(); + const out: string[] = []; + if (original) { + for (const raw of original.split(/\r?\n/)) { + const t = raw.trim(); + if (!t) { + out.push(''); + continue; + } + if (t.startsWith('#')) { + out.push(raw); + continue; + } + const parsed = parseNetatalkLine(t); + if (!parsed) continue; + const next = byExt.get(parsed.extension); + if (!next) continue; + out.push(formatNetatalkLine(next)); + written.add(parsed.extension); + } + } + for (const row of enabled) { + if (!written.has(row.extension)) out.push(formatNetatalkLine(row)); + } + while (out.length && out[out.length - 1] === '') out.pop(); + return out.length ? `${out.join('\n')}\n` : ''; +} diff --git a/src/fs/extension-map.test.ts b/src/fs/extension-map.test.ts index 1f5c213..7071e78 100644 --- a/src/fs/extension-map.test.ts +++ b/src/fs/extension-map.test.ts @@ -2,16 +2,21 @@ import { afterEach, describe, expect, it } from 'vitest'; import { DEFAULT_EXTENSION_MAP, EXTENSION_MAP_STORAGE_KEY, + browserExtensionMapStore, cloneDefaultExtensionMap, filenameExtension, finderInfoFromName, + hydrateExtensionMap, loadExtensionMap, lookupExtension, normalizeExtension, normalizeMappings, padOsType, parseExtensionMap, + persistExtensionMap, saveExtensionMap, + setExtensionMapStore, + type ExtensionMapping, } from './extension-map'; const memory = new Map(); @@ -34,6 +39,7 @@ function installStorage(): void { afterEach(() => { memory.clear(); + setExtensionMapStore(browserExtensionMapStore()); }); describe('extension-map', () => { @@ -41,6 +47,7 @@ describe('extension-map', () => { expect(padOsType('PDF')).toBe('PDF '); expect(padOsType('')).toBe('????'); expect(normalizeExtension('.TXT')).toBe('txt'); + expect(normalizeExtension('.')).toBe('.'); expect(filenameExtension('Read Me.txt')).toBe('txt'); expect(filenameExtension('archive.tar.gz')).toBe('gz'); expect(filenameExtension('noext')).toBe(''); @@ -54,6 +61,10 @@ describe('extension-map', () => { expect(lookupExtension('clip.pict', rows)).toEqual({ type: 'PICT', creator: 'TVOD' }); expect(lookupExtension('System.image', rows)).toEqual({ type: 'dImg', creator: 'ddsk' }); expect(lookupExtension('unknown.xyz', rows)).toEqual({ type: '????', creator: '????' }); + expect(lookupExtension('README', [{ extension: '.', type: '????', creator: 'UNIX', comment: '' }])).toEqual({ + type: '????', + creator: 'UNIX', + }); const fi = finderInfoFromName('photo.png', rows); expect(String.fromCharCode(...fi.subarray(0, 4))).toBe('PNG '); expect(String.fromCharCode(...fi.subarray(4, 8))).toBe('ogle'); @@ -91,4 +102,20 @@ describe('extension-map', () => { expect(JSON.parse(memory.get(EXTENSION_MAP_STORAGE_KEY)!)).toEqual(saved); expect(loadExtensionMap()).toEqual(saved); }); + + it('lets a caller replace the backing store', async () => { + const mem: ExtensionMapping[] = []; + setExtensionMapStore({ + async load() { + return mem.map((r) => ({ ...r })); + }, + async save(rows) { + mem.splice(0, mem.length, ...rows); + return mem.map((r) => ({ ...r })); + }, + }); + await persistExtensionMap([{ extension: 'go', creator: 'CWIE', type: 'TEXT', comment: 'Go' }]); + expect(mem).toEqual([{ extension: 'go', creator: 'CWIE', type: 'TEXT', comment: 'Go' }]); + expect(await hydrateExtensionMap()).toEqual(mem); + }); }); diff --git a/src/fs/extension-map.ts b/src/fs/extension-map.ts index 4fb0a58..f46d0a7 100644 --- a/src/fs/extension-map.ts +++ b/src/fs/extension-map.ts @@ -1,9 +1,9 @@ -/** Filename extension → Macintosh type/creator codes (persisted in localStorage). */ +/** Filename extension → Macintosh type/creator codes. Persistence is a pluggable store. */ export const EXTENSION_MAP_STORAGE_KEY = 'classicstack.extension-map'; export interface ExtensionMapping { - /** Filename suffix without a leading dot (lowercase). */ + /** Filename suffix without a leading dot (lowercase). `.` is the Netatalk catch-all. */ extension: string; /** Four-character Macintosh creator OSType. */ creator: string; @@ -13,6 +13,23 @@ export interface ExtensionMapping { comment: string; } +/** Where the editor (and Finder import) reads and writes mappings. */ +export interface ExtensionMapStore { + load(): Promise; + save(rows: readonly ExtensionMapping[]): Promise; +} + +/** Optional sync surface used by the browser localStorage store and prefs export. */ +export interface SyncExtensionMapStore extends ExtensionMapStore { + loadSync(): ExtensionMapping[]; + saveSync(rows: readonly ExtensionMapping[]): ExtensionMapping[]; + resetSync(): void; +} + +function isSyncStore(s: ExtensionMapStore): s is SyncExtensionMapStore { + return typeof (s as SyncExtensionMapStore).loadSync === 'function'; +} + /** * Built-in mappings used until the user saves an edited list. * Type/creator/comment follow classic Internet Config defaults for common files. @@ -48,6 +65,7 @@ export const DEFAULT_EXTENSION_MAP: readonly ExtensionMapping[] = [ { extension: 'jpeg', creator: 'ogle', type: 'JPEG', comment: 'JPEG Picture' }, { extension: 'gif', creator: 'ogle', type: 'GIFf', comment: 'GIF Picture' }, { extension: 'png', creator: 'ogle', type: 'PNG ', comment: 'Portable Network Graphic' }, + { extension: 'bmp', creator: 'ogle', type: 'BMPp', comment: 'Windows Bitmap' }, { extension: 'psd', creator: '8BIM', type: '8BPS', comment: 'PhotoShop Document' }, { extension: 'qxd', creator: 'XPR3', type: 'XDOC', comment: 'QuarkXpress Document' }, { extension: 'ai', creator: 'ART5', type: 'EPSF', comment: 'Adobe Illustrator' }, @@ -87,7 +105,9 @@ export function padOsType(s: string): string { } export function normalizeExtension(ext: string): string { - return ext.trim().replace(/^\.+/, '').toLowerCase(); + const t = ext.trim().toLowerCase(); + if (t === '.') return '.'; + return t.replace(/^\.+/, ''); } /** Last path segment after the final dot, matching historical VirtualFS behavior. */ @@ -131,41 +151,94 @@ export function parseExtensionMap(raw: unknown): ExtensionMapping[] | null { ); } +let cache: ExtensionMapping[] | null = null; +let store: ExtensionMapStore = browserExtensionMapStore(); + +export function browserExtensionMapStore(): SyncExtensionMapStore { + return { + async load() { + return this.loadSync(); + }, + async save(rows) { + return this.saveSync(rows); + }, + loadSync() { + try { + const raw = localStorage.getItem(EXTENSION_MAP_STORAGE_KEY); + if (!raw) return cloneDefaultExtensionMap(); + const parsed = parseExtensionMap(JSON.parse(raw) as unknown); + return parsed ?? cloneDefaultExtensionMap(); + } catch { + return cloneDefaultExtensionMap(); + } + }, + saveSync(rows) { + const next = normalizeMappings(rows); + try { + localStorage.setItem(EXTENSION_MAP_STORAGE_KEY, JSON.stringify(next)); + } catch { + /* quota / private mode */ + } + return next; + }, + resetSync() { + try { + localStorage.removeItem(EXTENSION_MAP_STORAGE_KEY); + } catch { + /* private mode */ + } + }, + }; +} + +/** Install the persistence backend (browser localStorage by default; ClassicStack uses the Go API). */ +export function setExtensionMapStore(next: ExtensionMapStore): void { + store = next; + cache = null; +} + +export function extensionMapStore(): ExtensionMapStore { + return store; +} + export function loadExtensionMap(): ExtensionMapping[] { - try { - const raw = localStorage.getItem(EXTENSION_MAP_STORAGE_KEY); - if (!raw) return cloneDefaultExtensionMap(); - const parsed = parseExtensionMap(JSON.parse(raw) as unknown); - return parsed ?? cloneDefaultExtensionMap(); - } catch { - return cloneDefaultExtensionMap(); + if (cache) return cache; + if (isSyncStore(store)) { + cache = store.loadSync(); + return cache; } + return cloneDefaultExtensionMap(); +} + +export async function hydrateExtensionMap(): Promise { + cache = await store.load(); + return cache; } export function resetExtensionMap(): void { - try { - localStorage.removeItem(EXTENSION_MAP_STORAGE_KEY); - } catch { - /* private mode */ - } + cache = null; + if (isSyncStore(store)) store.resetSync(); } export function saveExtensionMap(rows: readonly ExtensionMapping[]): ExtensionMapping[] { const next = normalizeMappings(rows); - try { - localStorage.setItem(EXTENSION_MAP_STORAGE_KEY, JSON.stringify(next)); - } catch { - /* quota / private mode */ - } + cache = next; + if (isSyncStore(store)) store.saveSync(next); return next; } +export async function persistExtensionMap(rows: readonly ExtensionMapping[]): Promise { + cache = await store.save(normalizeMappings(rows)); + return cache; +} + export function lookupExtension( name: string, rows: readonly ExtensionMapping[] = loadExtensionMap(), ): { type: string; creator: string } { const ext = filenameExtension(name); - const hit = ext ? rows.find((r) => r.extension === ext) : undefined; + const hit = (ext ? rows.find((r) => r.extension === ext) : undefined) ?? + (!ext ? rows.find((r) => r.extension === '.') : undefined); return hit ? { type: padOsType(hit.type), creator: padOsType(hit.creator) } : { type: '????', creator: '????' }; diff --git a/src/fs/icon-cache.test.ts b/src/fs/icon-cache.test.ts index 5778196..f66d124 100644 --- a/src/fs/icon-cache.test.ts +++ b/src/fs/icon-cache.test.ts @@ -13,6 +13,8 @@ import { HAS_BUNDLE, HAS_CUSTOM_ICON, IconCache, + defaultIconsForExtension, + fileExtension, iconForkLoadOptions, iconSetForFile, isCdevStyleType, @@ -21,6 +23,9 @@ import { } from './icon-cache'; import { CDEV_ICON_ID, CUSTOM_ICON_ID, IconSize } from './resource-types/icon-set'; import type { VNode } from './virtual-fs'; +import { encodeIco } from './winicon'; +import { buildPeWithIco } from './winicon/exe-fixtures'; +import type { DecodedIcon } from './resource-types/icon-decoder'; function entry(type: string, id: number, payload: Uint8Array): ResourceEntry { return { @@ -228,7 +233,7 @@ describe('iconForkLoadOptions', () => { }); describe('IconCache.getForNode', () => { - function folderNode(): VNode { + function folderNode(flags = 0): VNode { return { id: 4, parentId: 2, @@ -236,7 +241,7 @@ describe('IconCache.getForNode', () => { isDir: true, data: new Uint8Array(), resource: new Uint8Array(), - finderInfo: new Uint8Array(32), + finderInfo: flags ? finder('fold', 'MACS', flags) : new Uint8Array(32), createDate: 0, modDate: 0, }; @@ -245,17 +250,27 @@ describe('IconCache.getForNode', () => { it('does not look up Icon\\r without a named findChild', async () => { const cache = new IconCache(); let probes = 0; - await cache.getForNode(folderNode(), undefined, async () => { + await cache.getForNode(folderNode(HAS_CUSTOM_ICON), undefined, async () => { probes += 1; return null; }); expect(probes).toBe(0); }); + it('does not look up Icon\\r without the custom-icon flag', async () => { + const cache = new IconCache(); + const names: string[] = []; + await cache.getForNode(folderNode(), async (_id, name) => { + names.push(name); + return undefined; + }); + expect(names).toEqual([]); + }); + it('probes Icon\\r by name and does not list the directory', async () => { const cache = new IconCache(); const names: string[] = []; - const urls = await cache.getForNode(folderNode(), async (_id, name) => { + const urls = await cache.getForNode(folderNode(HAS_CUSTOM_ICON), async (_id, name) => { names.push(name); return undefined; }); @@ -592,4 +607,89 @@ describe('IconCache.getForNode', () => { expect(forks).toBe(0); expect(urls.large.startsWith('data:')).toBe(true); }); + + it('extracts a glyph from a PE .exe data fork', async () => { + const cache = new IconCache(); + const pixels = new Uint8ClampedArray(16 * 16 * 4); + pixels.fill(200); + for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255; + const frame: DecodedIcon = { typeCode: 'ICO', isColor: true, width: 16, height: 16, pixels }; + const pe = buildPeWithIco(encodeIco([frame])); + const urls = await cache.getForNode({ + addr: 'path', + path: 'NOTEPAD.EXE', + parentPath: '', + name: 'NOTEPAD.EXE', + isDir: false, + data: pe, + resource: new Uint8Array(), + finderInfo: new Uint8Array(32), + createDate: 0, + modDate: 0, + dataBytes: pe.length, + }); + expect(urls.small.startsWith('data:')).toBe(true); + expect(urls.small).toContain('ICO'); + }); + + it('uses an Icons8 default for .exe when no PE icon is present', async () => { + const cache = new IconCache(); + const urls = await cache.getForNode(fileNode(3, 'SETUP.EXE', '????', '????')); + expect(urls.large).toMatch(/icons8-application-window-96\.png/); + }); + + it('uses an Icons8 default for archive suffixes (case insensitive)', async () => { + const cache = new IconCache(); + const urls = await cache.getForNode(fileNode(3, 'Pack.ZIP', 'ZIP ', 'SITx')); + expect(urls.large).toMatch(/icons8-archive-folder-96\.png/); + const rar = await cache.getForTypeCreator('????', '????', 'game.RAR'); + expect(rar.large).toMatch(/icons8-archive-folder-96\.png/); + }); + + it('uses an Icons8 default for image suffixes instead of FILE32', async () => { + const cache = new IconCache(); + const urls = await cache.getForNode(fileNode(3, 'Photo.JPEG', 'JPEG', 'ogle')); + expect(urls.large).toMatch(/icons8-image-file-96\.png/); + expect(urls.large).not.toMatch(/FILE32\.png/); + }); + + it('keeps the TEXT32 system glyph for text files', async () => { + const cache = new IconCache(); + const urls = await cache.getForNode(fileNode(3, 'ReadMe.txt', 'TEXT', 'ttxt')); + expect(urls.large).toMatch(/TEXT32\.png/); + expect(urls.large).not.toMatch(/icons8-/); + }); + + it('does not override a known Mac type glyph with an extension default', async () => { + const cache = new IconCache(); + const urls = await cache.getForNode(fileNode(3, 'App.zip', 'APPL', 'ttxt')); + expect(urls.large).toMatch(/APPL32\.png/); + expect(urls.large).not.toMatch(/icons8-archive-folder/); + }); + + it('picks distinct defaults for dll / bat / sys / pdf', async () => { + const cache = new IconCache(); + expect((await cache.getForNode(fileNode(3, 'kernel32.dll', '????', '????'))).large).toMatch( + /icons8-dll-96\.png/, + ); + expect((await cache.getForNode(fileNode(4, 'autoexec.bat', '????', '????'))).large).toMatch( + /icons8-command-line-96\.png/, + ); + expect((await cache.getForNode(fileNode(5, 'himem.sys', '????', '????'))).large).toMatch( + /icons8-binary-file-96\.png/, + ); + expect((await cache.getForNode(fileNode(6, 'Manual.pdf', '????', '????'))).large).toMatch( + /icons8-pdf-1-96\.png/, + ); + }); +}); + +describe('defaultIconsForExtension', () => { + it('matches suffixes case-insensitively and ignores a leading path', () => { + expect(fileExtension('C:\\DOS\\EDIT.COM')).toBe('com'); + expect(defaultIconsForExtension('C:\\DOS\\EDIT.COM')?.large).toMatch(/icons8-command-line-96\.png/); + expect(defaultIconsForExtension('archive.7z')?.large).toMatch(/icons8-archive-folder-96\.png/); + expect(defaultIconsForExtension('readme.txt')).toBeNull(); + expect(defaultIconsForExtension('noext')).toBeNull(); + }); }); diff --git a/src/fs/icon-cache.ts b/src/fs/icon-cache.ts index 8a9f9ef..dd49c92 100644 --- a/src/fs/icon-cache.ts +++ b/src/fs/icon-cache.ts @@ -17,6 +17,9 @@ import { IconSize, } from './resource-types/icon-set'; import type { VNode } from './virtual-fs'; +import { nodeRef } from './virtual-fs'; +import type { NodeRef } from './catalog-caps'; +import { refKey } from './catalog-caps'; import { escapeHostFilename } from '../protocol/host-filename'; import { isAbortError, throwIfAborted } from '../util/abort'; import { @@ -27,8 +30,12 @@ import { finderIconId, isFinderInvisible, } from './finder-info'; +import type { ByteRangeReader } from './byte-range'; +import { bufferRangeReader } from './byte-range'; +import { extractWinIcons, isWinIconName, pickIconNear } from './winicon'; export { HAS_BUNDLE, HAS_CUSTOM_ICON, FINDER_IS_INVISIBLE, finderFlags, isFinderInvisible, finderIconId }; +export { isWinIconName } from './winicon'; export const CUSTOM_FOLDER_ICON_NAME = 'Icon\r'; /** Host-escaped form of Icon\\r (ClassicStack reserved-char token). */ @@ -55,6 +62,8 @@ export interface IconLookupExtras { creator: string, signal?: AbortSignal, ) => Promise; + /** Ranged data-fork reader (PE/NE/.ico). */ + loadDataRange?: (node: VNode, fn: (read: ByteRangeReader) => Promise) => Promise; signal?: AbortSignal; } @@ -81,6 +90,10 @@ function cacheKey(creator: string, type: string): string { return `${padOsType(creator)}|${padOsType(type)}`; } +function winCacheKey(node: VNode): string { + return `win:${refKey(nodeRef(node))}:${node.modDate}:${node.dataBytes ?? node.data.length}`; +} + /** Classic Finder volume Desktop file (FNDR/ERIK); later a volume icon cache, not a glyph source. */ export function isVolumeDesktopFile(name: string, type: string, creator: string): boolean { return name === 'Desktop' && padOsType(type) === 'FNDR' && padOsType(creator) === 'ERIK'; @@ -218,6 +231,63 @@ function pickSystemIcon(candidates: string[]): string { return systemIconUrl(candidates[candidates.length - 1] ?? 'FILE32.png'); } +/** Filename suffix without a leading dot, lowercased. Empty when there is none. */ +export function fileExtension(name: string): string { + const base = name.split(/[/\\]/).pop() ?? name; + const dot = base.lastIndexOf('.'); + if (dot <= 0 || dot === base.length - 1) return ''; + return base.slice(dot + 1).toLowerCase(); +} + +/** Icons8 glyphs used when a file has no Mac type icon (generic FILE16/FILE32). */ +const DEFAULT_EXTENSION_ICONS: Record = { + exe: 'icons8-application-window-96.png', + zip: 'icons8-archive-folder-96.png', + cab: 'icons8-archive-folder-96.png', + rar: 'icons8-archive-folder-96.png', + '7z': 'icons8-archive-folder-96.png', + arj: 'icons8-archive-folder-96.png', + dll: 'icons8-dll-96.png', + bmp: 'icons8-image-file-96.png', + jpg: 'icons8-image-file-96.png', + jpeg: 'icons8-image-file-96.png', + gif: 'icons8-image-file-96.png', + png: 'icons8-image-file-96.png', + com: 'icons8-command-line-96.png', + bat: 'icons8-command-line-96.png', + cmd: 'icons8-command-line-96.png', + sys: 'icons8-binary-file-96.png', + vxd: 'icons8-binary-file-96.png', + ocx: 'icons8-binary-file-96.png', + pdf: 'icons8-pdf-1-96.png', +}; + +function extensionIconUrl(file: string): string { + return `/icons/ui/${file}`; +} + +/** Default Icons8 URLs for a filename suffix, or null when the suffix is unmapped. */ +export function defaultIconsForExtension(name: string): IconUrls | null { + const file = DEFAULT_EXTENSION_ICONS[fileExtension(name)]; + if (!file) return null; + const url = extensionIconUrl(file); + return { small: url, large: url }; +} + +function isGenericFileIconUrl(src: string): boolean { + return /(?:^|\/)FILE(?:16|32)\.png(?:\?|$)/i.test(src); +} + +/** True when the cache fell back to the classic FILE16/FILE32 PNGs. */ +export function isGenericFileIcon(urls: IconUrls): boolean { + return isGenericFileIconUrl(urls.small) || isGenericFileIconUrl(urls.large); +} + +function withExtensionDefault(name: string | undefined, urls: IconUrls): IconUrls { + if (!name || !isGenericFileIcon(urls)) return urls; + return defaultIconsForExtension(name) ?? urls; +} + async function resolveSystemIcon(type: string, size: 16 | 32): Promise { const t = padOsType(type).replace(/\s+$/g, '') || 'FILE'; if (size === 16) { @@ -265,6 +335,8 @@ export class IconCache { private typeInflight = new Map>(); private dirInflight = new Map>(); private desktopInflight = new Map>(); + private winMemory = new Map(); + private winInflight = new Map>(); /** Type/creator keys whose cached glyph came from an 8-bit/cicn family (not ICN# / AFP B&W). */ private colorKeys = new Set(); /** Type/creator keys whose resource fork was already opened for icons. */ @@ -311,6 +383,7 @@ export class IconCache { this.bundleCache.clear(); this.dirMemory.clear(); this.desktopMemory.clear(); + this.winMemory.clear(); this.colorKeys.clear(); this.iconForkTried.clear(); this.defaultKeys.clear(); @@ -318,6 +391,7 @@ export class IconCache { this.typeInflight.clear(); this.dirInflight.clear(); this.desktopInflight.clear(); + this.winInflight.clear(); await this.init(); if (!this.db) return; await this.clearStore('typeIcons'); @@ -372,25 +446,30 @@ export class IconCache { /** Resolve icons for a VirtualFS node (local share or remote AFP). */ async getForNode( node: VNode, - findChild?: (parentId: number, name: string) => Promise, + findChild?: (parent: NodeRef, name: string) => Promise, loadIconFork?: (node: VNode) => Promise, extras?: IconLookupExtras, ): Promise { await this.ensureDefaults(); if (node.isDir) { - return this.getForDirectory(String(node.id), node, findChild, loadIconFork); + return this.getForDirectory(String(nodeRef(node)), node, findChild, loadIconFork); + } + const extDefault = (urls: IconUrls) => withExtensionDefault(node.name, urls); + if (isWinIconName(node.name)) { + const win = await this.getForWinFile(node, extras); + if (win) return extDefault(win); } const { type, creator } = readTypeCreator(node.finderInfo); if (isVolumeDesktopFile(node.name, type, creator)) { - return this.getForTypeCreator(type, creator); + return this.getForTypeCreator(type, creator, node.name); } const key = cacheKey(creator, type); const cached = this.memory.get(key) ?? (await this.loadPersisted(key)); if (cached) this.memory.set(key, cached); const custom = (finderFlags(node.finderInfo) & HAS_CUSTOM_ICON) !== 0; - if (cached && !custom && this.colorKeys.has(key)) return cached; - if (cached && !custom && this.defaultKeys.has(key)) return cached; - if (cached && !custom && this.iconForkTried.has(key)) return cached; + if (cached && !custom && this.colorKeys.has(key)) return extDefault(cached); + if (cached && !custom && this.defaultKeys.has(key)) return extDefault(cached); + if (cached && !custom && this.iconForkTried.has(key)) return extDefault(cached); throwIfAborted(extras?.signal); @@ -404,7 +483,7 @@ export class IconCache { try { const urls = await pending; throwIfAborted(extras?.signal); - return urls; + return extDefault(urls); } catch (err) { if (!(isAbortError(err) && extras?.signal && !extras.signal.aborted)) { throw err; @@ -420,7 +499,7 @@ export class IconCache { if (this.typeInflight.get(key) === work) this.typeInflight.delete(key); }); } - return work; + return extDefault(await work); } private async resolveFileNode( @@ -578,38 +657,81 @@ export class IconCache { }); } + private async getForWinFile(node: VNode, extras?: IconLookupExtras): Promise { + const key = winCacheKey(node); + const hit = this.winMemory.get(key); + if (hit) return hit; + const pending = this.winInflight.get(key); + if (pending) return pending; + + const work = (async (): Promise => { + throwIfAborted(extras?.signal); + try { + const icons = extras?.loadDataRange + ? await extras.loadDataRange(node, (read) => extractWinIcons(read)) + : node.data.length >= 6 + ? await extractWinIcons(bufferRangeReader(node.data)) + : []; + throwIfAborted(extras?.signal); + if (!icons.length) return null; + const smallIcon = pickIconNear(icons, 16); + const largeIcon = pickIconNear(icons, 32); + if (!smallIcon && !largeIcon) return null; + const small = smallIcon ? await decodedIconToDataUrl(smallIcon) : null; + const large = largeIcon ? await decodedIconToDataUrl(largeIcon) : null; + if (!small && !large) return null; + const urls: IconUrls = { small: small ?? large!, large: large ?? small! }; + this.winMemory.set(key, urls); + return urls; + } catch (err) { + if (isAbortError(err)) throw err; + return null; + } + })(); + this.winInflight.set(key, work); + void work.finally(() => { + if (this.winInflight.get(key) === work) this.winInflight.delete(key); + }); + return work; + } + /** Resolve icons when only Finder info is known (remote listings). */ - async getForTypeCreator(type: string, creator: string): Promise { + async getForTypeCreator(type: string, creator: string, name?: string): Promise { await this.ensureDefaults(); const key = cacheKey(creator, type); const pending = this.typeInflight.get(key); - if (pending) return pending; + if (pending) return withExtensionDefault(name, await pending); const cached = this.memory.get(key) ?? (await this.loadPersisted(key)); if (cached) { this.memory.set(key, cached); - return cached; + return withExtensionDefault(name, cached); } const urls: IconUrls = { small: await resolveSystemIcon(type, 16), large: await resolveSystemIcon(type, 32), }; this.memory.set(key, urls); - return urls; + return withExtensionDefault(name, urls); } private async getForDirectory( pathKey: string, node: VNode, - findChild?: (parentId: number, name: string) => Promise, + findChild?: (parent: NodeRef, name: string) => Promise, loadIconFork?: (node: VNode) => Promise, ): Promise { const hit = this.dirMemory.get(pathKey); if (hit) return hit; - // Named Icon\\r lookup only when the caller supplies findChild (Finder does - // this for folders in the current view). Do not enumerate the directory. - if (!findChild) { - return this.defaultFolder ?? DEFAULT_FOLDER_ICONS; + // Named Icon\\r lookup only when the folder has the custom-icon flag and + // the caller supplies findChild (Finder does this for on-screen folders). + // Do not enumerate the directory, and do not probe folders that are only + // using the default glyph. + const custom = (finderFlags(node.finderInfo) & HAS_CUSTOM_ICON) !== 0; + if (!custom || !findChild) { + const urls = this.defaultFolder ?? DEFAULT_FOLDER_ICONS; + this.dirMemory.set(pathKey, urls); + return urls; } const pending = this.dirInflight.get(pathKey); @@ -625,7 +747,7 @@ export class IconCache { private async probeDirectory( pathKey: string, node: VNode, - findChild: (parentId: number, name: string) => Promise, + findChild: (parent: NodeRef, name: string) => Promise, loadIconFork?: (node: VNode) => Promise, ): Promise { const hit = this.dirMemory.get(pathKey); @@ -671,14 +793,14 @@ export class IconCache { private async tryCustomFolderIconFile( dir: VNode, - findChild?: (parentId: number, name: string) => Promise, + findChild?: (parent: NodeRef, name: string) => Promise, loadIconFork?: (node: VNode) => Promise, ): Promise { if (!findChild) return null; try { const iconFile = - (await findChild(dir.id, CUSTOM_FOLDER_ICON_NAME)) ?? - (await findChild(dir.id, CUSTOM_FOLDER_ICON_HOST_NAME)); + (await findChild(nodeRef(dir), CUSTOM_FOLDER_ICON_NAME)) ?? + (await findChild(nodeRef(dir), CUSTOM_FOLDER_ICON_HOST_NAME)); if (!iconFile || iconFile.isDir) return null; const rsrcLen = iconFile.resourceBytes ?? iconFile.resource.length; if (iconFile.resource.length < 16 && rsrcLen < 16) return null; diff --git a/src/fs/import-transfer.ts b/src/fs/import-transfer.ts index 5b5144d..88f9dc2 100644 --- a/src/fs/import-transfer.ts +++ b/src/fs/import-transfer.ts @@ -1,776 +1,778 @@ -/** Shared DataTransfer import for local IndexedDB and remote AFP catalogs. */ - -import { hfsTimeToAfp } from '../protocol/afp/constants'; -import { unescapeHostFilename } from '../protocol/host-filename'; -import { loadPrefs } from '../util/prefs'; -import { log } from '../util/logger'; -import { expandIncoming, isExpandableArchive, type ExpandedDir, type ExpandedFile, type ExpandedNode } from './expand-incoming'; -import type { Catalog, VNode } from './virtual-fs'; -import { throwIfAborted, isAbortError, abortError } from '../util/abort'; -import { iconCache } from './icon-cache'; -import { - planItemPlacement, - TransferCancelled, - type NameConflictChoice, - type PlacementPlan, -} from './name-conflict'; - -export type ExpandTrackFile = { - name: string; - /** Archive-relative path, unique among siblings of the same parent job. */ - path: string; - bytesTotal: number; - finderInfo?: Uint8Array; -}; - -export type ImportItemTrack = { - onBytes?: (n: number) => void; - onDone?: (err?: Error) => void; - /** Parent job caption while cataloging an archive (before nested files exist). */ - onStatus?: (detail: string) => void; - /** Reset the parent job and announce every extracted file (queued) before writes start. */ - onExpandBegin?: (bytesTotal: number, files: ExpandTrackFile[]) => void; - /** Nested job while a dropped wrapper is decoded (BinHex / MacBinary / later StuffIt). */ - onExpand?: (item: ExpandTrackFile) => ImportItemTrack | undefined; - signal?: AbortSignal; - /** Called just before a dest file is created (Finder overlay + cancel partial). */ - onWrite?: (parentId: number, name: string) => void; - /** Called when an extracted folder is created (Finder overlay in that parent). */ - onDir?: (parentId: number, name: string, dirId: number, path: string) => void; - /** Delete a dest file left behind if this write is cancelled mid-flight. */ - removePartial?: (parentId: number, name: string) => Promise; - /** - * Serialize this item behind other Finder copies (AFP Writes on LocalTalk). - * Re-enters for the same job when an archive expands inside the import. - */ - runInCopySlot?: (fn: () => Promise) => Promise; -}; - -async function runInCopySlot(track: ImportItemTrack | undefined, fn: () => Promise): Promise { - return track?.runInCopySlot ? track.runInCopySlot(fn) : fn(); -} - -export type ImportProgress = { - onScan?: (total: number) => void; - onProgress?: (done: number, total: number) => void; - /** One callback per top-level drop item (a folder is a single item). */ - onItem?: (item: { name: string; isDir: boolean; bytesTotal: number }) => ImportItemTrack | undefined; - resolveConflict?: (info: { - name: string; - isDir: boolean; - suggestedName: string; - }) => Promise; -}; - -type ImportBlob = ( - parentId: number, - file: File, - onBytes?: (n: number) => void, - /** Host resource fork from `..namedfork/rsrc` (Chrome on macOS). */ - resource?: Uint8Array, - signal?: AbortSignal, -) => Promise; -type ImportFs = Pick< - Catalog, - 'beginBatch' | 'endBatch' | 'ensureDir' | 'lookup' | 'remove' | 'createFile' | 'put' ->; - -/** - * Import files and folders from a drag-and-drop DataTransfer. - * Uses FileSystemEntry (directory trees) when available; falls back to FileList - * + webkitRelativePath. Returns the number of top-level items imported. - */ -export async function importDataTransferInto( - fs: ImportFs, - parentId: number, - dt: DataTransfer, - importBlob: ImportBlob, - opts?: ImportProgress, -): Promise { - const entries = collectDataTransferEntries(dt); - const total = entries.length > 0 ? await countFsEntries(entries) : dt.files.length; - opts?.onScan?.(total); - - let done = 0; - const tick = (): void => { - done++; - opts?.onProgress?.(done, total); - }; - - if (entries.length > 0) { - const groups = groupTopLevelEntries(entries.sort(compareImportEntries)); - const plans = await planIncoming(fs, parentId, groups, opts?.resolveConflict); - fs.beginBatch(); - try { - let imported = 0; - let cancelled = false; - for (let i = 0; i < groups.length; i++) { - const group = groups[i]!; - const plan = plans[i]!; - await applyReplace(fs, plan); - const bytesTotal = await measureGroupBytes(group.entries); - const track = opts?.onItem?.({ - name: plan.destName, - isDir: group.isDir, - bytesTotal, - }); - try { - await runInCopySlot(track, async () => { - throwIfAborted(track?.signal); - for (const entry of group.entries) { - const asName = mappedIncomingName(unescapeHostFilename(entry.name), group.name, plan.destName); - await importFsEntry(fs, parentId, entry, importBlob, tick, track, asName); - } - }); - track?.onDone?.(); - imported++; - } catch (err) { - track?.onDone?.(err instanceof Error ? err : new Error(String(err))); - if (isAbortError(err)) { - cancelled = true; - continue; - } - throw err; - } - } - if (imported === 0 && cancelled) throw new TransferCancelled(); - return imported; - } finally { - fs.endBatch(); - } - } - - const groups = groupFilesByTopLevel([...dt.files].sort(compareImportFiles)); - const plans = await planIncoming(fs, parentId, groups, opts?.resolveConflict); - fs.beginBatch(); - try { - let imported = 0; - let cancelled = false; - for (let i = 0; i < groups.length; i++) { - const group = groups[i]!; - const plan = plans[i]!; - await applyReplace(fs, plan); - const bytesTotal = group.files.reduce((n, f) => n + f.size, 0); - const track = opts?.onItem?.({ - name: plan.destName, - isDir: group.isDir, - bytesTotal, - }); - try { - await runInCopySlot(track, async () => { - throwIfAborted(track?.signal); - for (const file of group.files) { - const destTop = mappedIncomingName( - topFileName(file), - group.name, - plan.destName, - ); - await importFileWithRelativePath(fs, parentId, file, importBlob, track, destTop); - tick(); - } - }); - track?.onDone?.(); - imported++; - } catch (err) { - track?.onDone?.(err instanceof Error ? err : new Error(String(err))); - if (isAbortError(err)) { - cancelled = true; - continue; - } - throw err; - } - } - if (imported === 0 && cancelled) throw new TransferCancelled(); - return imported; - } finally { - fs.endBatch(); - } -} - -async function planIncoming( - fs: ImportFs, - parentId: number, - groups: { name: string; isDir: boolean }[], - resolveConflict?: ImportProgress['resolveConflict'], -): Promise { - if (!resolveConflict) { - return groups.map((g) => ({ destName: g.name, replaceId: null })); - } - const reserved = new Set(); - const plans: PlacementPlan[] = []; - for (const g of groups) { - const plan = await planItemPlacement(fs, parentId, g.name, g.isDir, { reserved, resolveConflict }); - if (!plan) throw new TransferCancelled(); - reserved.add(plan.destName.toLowerCase()); - plans.push(plan); - } - return plans; -} - -async function applyReplace(fs: ImportFs, plan: PlacementPlan): Promise { - if (plan.replaceId != null) await fs.remove(plan.replaceId); -} - -function mappedIncomingName(rawName: string, original: string, destName: string): string { - if (destName === original) return rawName; - if (rawName === original) return destName; - if (isAppleDoubleSidecarName(rawName) && rawName.slice(2) === original) return `._${destName}`; - return rawName; -} - -function topFileName(file: File): string { - const rel = file.webkitRelativePath; - if (rel && rel.includes('/')) return unescapeHostFilename(rel.split('/')[0]!); - return unescapeHostFilename(file.name); -} - -async function importFsEntry( - fs: ImportFs, - parentId: number, - entry: FileSystemEntry, - importBlob: ImportBlob, - onItem?: () => void, - track?: ImportItemTrack, - destName?: string, - parentDir?: FileSystemDirectoryEntry, -): Promise { - if (isNamedForkDirName(entry.name)) return; - throwIfAborted(track?.signal); - const name = destName ?? unescapeHostFilename(entry.name); - if (entry.isDirectory) { - const dir = await fs.ensureDir(parentId, name); - onItem?.(); - const folder = entry as FileSystemDirectoryEntry; - const kids = (await readDirectoryEntries(folder)).sort(compareImportEntries); - for (const kid of kids) { - await importFsEntry(fs, dir.id, kid, importBlob, onItem, track, undefined, folder); - } - return; - } - if (entry.isFile) { - const fileEntry = entry as FileSystemFileEntry; - const file = await readFileEntry(fileEntry); - const resource = await readNamedResourceForkForEntry(fileEntry, parentDir); - await importOneFile(fs, parentId, fileWithName(file, name), importBlob, track, resource); - onItem?.(); - } -} - -async function importFileWithRelativePath( - fs: ImportFs, - parentId: number, - file: File, - importBlob: ImportBlob, - track?: ImportItemTrack, - destTop?: string, -): Promise { - throwIfAborted(track?.signal); - const rel = file.webkitRelativePath; - if (!rel || !rel.includes('/')) { - const name = destTop ?? unescapeHostFilename(file.name); - await importOneFile(fs, parentId, fileWithName(file, name), importBlob, track); - return; - } - const parts = rel.split('/'); - if (destTop) parts[0] = destTop; - let dirId = parentId; - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]!; - if (!part || part === '.' || part === '..') continue; - dirId = (await fs.ensureDir(dirId, unescapeHostFilename(part))).id; - } - await importOneFile(fs, dirId, file, importBlob, track); -} - -async function importOneFile( - fs: ImportFs, - parentId: number, - file: File, - importBlob: ImportBlob, - track?: ImportItemTrack, - resource?: Uint8Array | null, -): Promise { - const name = unescapeHostFilename(file.name); - const onBytes = track?.onBytes; - const hostResource = resource?.length ? resource : undefined; - throwIfAborted(track?.signal); - if (hostResource) { - log.info(`Imported resource fork for “${name}” (${hostResource.length} bytes)`, 'import'); - } - if (!loadPrefs().autoExpandFiles || !shouldTryExpand(name)) { - await writeTrackedBlob(parentId, name, () => importBlob(parentId, file, onBytes, hostResource, track?.signal), track); - return; - } - const buf = await readBlobProgress(file, onBytes, track?.signal); - throwIfAborted(track?.signal); - let expanded: ExpandedNode[] | null = null; - try { - expanded = expandIncoming(name, buf); - } catch (err) { - log.warn(`Couldn’t auto-expand “${name}”: ${err instanceof Error ? err.message : err}`, 'expand'); - } - if (!expanded) { - await writeTrackedBlob( - parentId, - name, - () => - importBlob( - parentId, - new File([buf], name, { type: file.type, lastModified: file.lastModified }), - undefined, - hostResource, - track?.signal, - ), - track, - ); - return; - } - const first = expanded[0]; - log.info( - expanded.length === 1 && first?.kind === 'file' - ? `Expanded “${name}” → “${first.name}”` - : `Expanded “${name}” into ${expanded.length} item(s)`, - 'expand', - ); - await importExpandedTree(fs, parentId, expanded, track); -} - -async function writeTrackedBlob( - parentId: number, - name: string, - write: () => Promise, - track?: ImportItemTrack, -): Promise { - throwIfAborted(track?.signal); - try { - track?.onWrite?.(parentId, name); - await write(); - throwIfAborted(track?.signal); - } catch (err) { - if (isAbortError(err)) await track?.removePartial?.(parentId, name); - throw err; - } -} - -function shouldTryExpand(name: string): boolean { - return isExpandableArchive(name); -} - -function expandedFiles(nodes: ExpandedNode[], prefix = ''): ExpandTrackFile[] { - const out: ExpandTrackFile[] = []; - for (const node of nodes) { - const path = prefix ? `${prefix}/${node.name}` : node.name; - if (node.kind === 'dir') out.push(...expandedFiles(node.children, path)); - else { - out.push({ - name: node.name, - path, - bytesTotal: node.data.length + node.resource.length, - finderInfo: node.finderInfo, - }); - } - } - return out; -} - -function expandedByteTotal(files: ExpandTrackFile[]): number { - return files.reduce((n, f) => n + f.bytesTotal, 0); -} - -/** Write expanded Mac files/folders through Catalog.createFile / put (forks, Finder info, dates). */ -export async function importExpandedTree( - fs: Pick, - parentId: number, - nodes: ExpandedNode[], - track?: ImportItemTrack, - opts?: { announce?: boolean; prefix?: string }, -): Promise { - const prefix = opts?.prefix ?? ''; - if (opts?.announce !== false) { - const files = expandedFiles(nodes, prefix); - track?.onExpandBegin?.(expandedByteTotal(files), files); - } - await runInCopySlot(track, () => writeExpandedNodes(fs, parentId, nodes, track, prefix)); -} - -async function writeExpandedNodes( - fs: Pick, - parentId: number, - nodes: ExpandedNode[], - track?: ImportItemTrack, - prefix = '', -): Promise { - for (const node of nodes) { - throwIfAborted(track?.signal); - const path = prefix ? `${prefix}/${node.name}` : node.name; - if (node.kind === 'dir') { - const dir = await fs.ensureDir(parentId, node.name); - track?.onDir?.(parentId, node.name, dir.id, path); - await stampExpandedMeta(fs, dir, node, true); - await writeExpandedNodes(fs, dir.id, node.children, track, path); - continue; - } - await importExpandedFile(fs, parentId, node, track, path); - } -} - -async function importExpandedFile( - fs: Pick, - parentId: number, - node: ExpandedFile, - track?: ImportItemTrack, - path = node.name, -): Promise { - const bytesTotal = node.data.length + node.resource.length; - const child = track?.onExpand?.({ - name: node.name, - path, - bytesTotal, - finderInfo: node.finderInfo, - }); - if (child?.signal?.aborted) { - child.onDone?.(abortError(child.signal)); - return; - } - const credit = (n: number): void => { - child?.onBytes?.(n); - track?.onBytes?.(n); - }; - let wrote = 0; - const signal = child?.signal ?? track?.signal; - const removePartial = child?.removePartial ?? track?.removePartial; - try { - throwIfAborted(signal); - child?.onWrite?.(parentId, node.name) ?? track?.onWrite?.(parentId, node.name); - const vnode = await fs.createFile(parentId, node.name, node.data, node.resource, node.finderInfo, (n) => { - throwIfAborted(signal); - wrote += n; - credit(n); - }, signal); - throwIfAborted(signal); - if (wrote === 0 && bytesTotal > 0) credit(bytesTotal); - await stampExpandedMeta(fs, vnode, node, false); - try { - await iconCache.ingestExtracted({ - name: node.name, - finderInfo: node.finderInfo, - resource: node.resource, - data: node.data, - }); - } catch { - /* listing can still fall back to AFP / system glyphs */ - } - child?.onDone?.(); - } catch (err) { - if (isAbortError(err)) await removePartial?.(parentId, node.name); - child?.onDone?.(err instanceof Error ? err : new Error(String(err))); - if (isAbortError(err) && track?.signal && !track.signal.aborted) return; - throw err; - } -} - -async function stampExpandedMeta( - fs: Pick, - vnode: VNode, - meta: ExpandedFile | ExpandedDir, - applyFinderInfo: boolean, -): Promise { - let dirty = false; - if (applyFinderInfo && meta.finderInfo && meta.finderInfo.some((b) => b !== 0)) { - vnode.finderInfo = meta.finderInfo; - dirty = true; - } - if (meta.createDate) { - vnode.createDate = hfsTimeToAfp(meta.createDate); - dirty = true; - } - if (meta.modDate) { - vnode.modDate = hfsTimeToAfp(meta.modDate); - dirty = true; - } - if (dirty) await fs.put(vnode); -} - -function fileWithName(file: File, name: string): File { - if (file.name === name) return file; - return new File([file], name, { type: file.type, lastModified: file.lastModified }); -} - -function collectDataTransferEntries(dt: DataTransfer): FileSystemEntry[] { - const entries: FileSystemEntry[] = []; - for (let i = 0; i < dt.items.length; i++) { - const item = dt.items[i]!; - if (item.kind !== 'file') continue; - const entry = item.webkitGetAsEntry?.() ?? null; - if (entry) entries.push(entry); - } - return entries; -} - -async function countFsEntries(entries: FileSystemEntry[]): Promise { - let total = 0; - const walk = async (entry: FileSystemEntry): Promise => { - if (isNamedForkDirName(entry.name)) return; - total++; - if (!entry.isDirectory) return; - const kids = await readDirectoryEntries(entry as FileSystemDirectoryEntry); - for (const kid of kids) await walk(kid); - }; - for (const entry of entries) await walk(entry); - return total; -} - -async function measureGroupBytes(entries: FileSystemEntry[]): Promise { - let n = 0; - for (const entry of entries) n += await measureEntryBytes(entry); - return n; -} - -async function measureEntryBytes(entry: FileSystemEntry, parentDir?: FileSystemDirectoryEntry): Promise { - if (isNamedForkDirName(entry.name)) return 0; - if (entry.isFile) { - const fileEntry = entry as FileSystemFileEntry; - const file = await readFileEntry(fileEntry); - const resource = await readNamedResourceForkForEntry(fileEntry, parentDir); - return file.size + (resource?.length ?? 0); - } - if (!entry.isDirectory) return 0; - let n = 0; - const folder = entry as FileSystemDirectoryEntry; - const kids = await readDirectoryEntries(folder); - for (const kid of kids) n += await measureEntryBytes(kid, folder); - return n; -} - -function groupTopLevelEntries( - entries: FileSystemEntry[], -): { name: string; isDir: boolean; entries: FileSystemEntry[] }[] { - const map = new Map(); - const order: string[] = []; - for (const entry of entries) { - const raw = unescapeHostFilename(entry.name); - const logical = isAppleDoubleSidecarName(raw) ? raw.slice(2) : raw; - const key = logical.toLowerCase(); - let g = map.get(key); - if (!g) { - g = { name: logical, isDir: false, entries: [] }; - map.set(key, g); - order.push(key); - } - g.entries.push(entry); - if (entry.isDirectory && !isAppleDoubleSidecarName(raw)) g.isDir = true; - } - return order.map((k) => { - const g = map.get(k)!; - g.entries.sort(compareImportEntries); - return g; - }); -} - -function groupFilesByTopLevel(files: File[]): { name: string; isDir: boolean; files: File[] }[] { - const map = new Map(); - const order: string[] = []; - const bump = (logical: string, isDir: boolean, file: File) => { - const key = logical.toLowerCase(); - let g = map.get(key); - if (!g) { - g = { name: logical, isDir, files: [] }; - map.set(key, g); - order.push(key); - } - g.files.push(file); - if (isDir) g.isDir = true; - }; - for (const file of files) { - const rel = file.webkitRelativePath; - if (rel && rel.includes('/')) { - bump(unescapeHostFilename(rel.split('/')[0]!), true, file); - } else { - const raw = unescapeHostFilename(file.name); - bump(isAppleDoubleSidecarName(raw) ? raw.slice(2) : raw, false, file); - } - } - return order.map((k) => { - const g = map.get(k)!; - g.files.sort(compareImportFiles); - return g; - }); -} - -function isAppleDoubleSidecarName(name: string): boolean { - return name.startsWith('._') && name.length > 2; -} - -/** Chrome on macOS can expose a file's resource fork as this parallel path. */ -export const NAMED_RESOURCE_FORK_SUFFIX = '..namedfork/rsrc'; - -export function namedResourceForkPath(fileName: string): string { - return `${fileName}/${NAMED_RESOURCE_FORK_SUFFIX}`; -} - -function isNamedForkDirName(name: string): boolean { - return name === '..namedfork'; -} - -/** AppleDouble sidecars already carry the resource fork; don't probe them. */ -export function shouldProbeNamedResourceFork(name: string): boolean { - if (!name || name === '.' || name === '..' || isNamedForkDirName(name)) return false; - return !isAppleDoubleSidecarName(name); -} - -/** - * Read `file/..namedfork/rsrc` via the drag-and-drop File System API. - * Works in Chrome on macOS; Safari often returns a File that fails to read, - * Firefox typically errors. Empty forks and failures are ignored. - */ -export async function readNamedResourceFork( - dir: FileSystemDirectoryEntry, - fileName: string, -): Promise { - if (!shouldProbeNamedResourceFork(fileName)) return null; - const file = await getFileFromDirectory(dir, namedResourceForkPath(fileName)); - return readResourceForkFile(file); -} - -async function readNamedResourceForkForEntry( - fileEntry: FileSystemFileEntry, - parentDir?: FileSystemDirectoryEntry | null, -): Promise { - if (!shouldProbeNamedResourceFork(fileEntry.name)) return null; - const dir = parentDir ?? (await getParentDirectory(fileEntry)); - if (dir) { - const fromParent = await readNamedResourceFork(dir, fileEntry.name); - if (fromParent) return fromParent; - } - const root = fileEntry.filesystem?.root; - const fullPath = fileEntry.fullPath?.replace(/^\//, ''); - if (!root || !fullPath) return null; - const file = await getFileFromDirectory(root, namedResourceForkPath(fullPath)); - return readResourceForkFile(file); -} - -async function readResourceForkFile(file: File | null): Promise { - if (!file?.size) return null; - try { - const buf = new Uint8Array(await file.arrayBuffer()); - return buf.length ? buf : null; - } catch { - // Safari: getFile succeeds but reading the named fork throws. - return null; - } -} - -function getFileFromDirectory(dir: FileSystemDirectoryEntry, relativePath: string): Promise { - return new Promise((resolve) => { - if (typeof dir.getFile !== 'function') { - resolve(null); - return; - } - try { - dir.getFile( - relativePath, - { create: false }, - (entry) => { - (entry as FileSystemFileEntry).file( - (file) => resolve(file), - () => resolve(null), - ); - }, - () => resolve(null), - ); - } catch { - resolve(null); - } - }); -} - -function getParentDirectory(entry: FileSystemEntry): Promise { - return new Promise((resolve) => { - if (typeof entry.getParent !== 'function') { - resolve(null); - return; - } - try { - entry.getParent( - (parent) => resolve(parent as FileSystemDirectoryEntry), - () => resolve(null), - ); - } catch { - resolve(null); - } - }); -} - -function compareImportEntries(a: FileSystemEntry, b: FileSystemEntry): number { - const as = isAppleDoubleSidecarName(a.name) ? 1 : 0; - const bs = isAppleDoubleSidecarName(b.name) ? 1 : 0; - return as - bs || a.name.localeCompare(b.name); -} - -function compareImportFiles(a: File, b: File): number { - const an = a.webkitRelativePath || a.name; - const bn = b.webkitRelativePath || b.name; - const as = isAppleDoubleSidecarName(a.name) ? 1 : 0; - const bs = isAppleDoubleSidecarName(b.name) ? 1 : 0; - return as - bs || an.localeCompare(bn); -} - -function readDirectoryEntries(dir: FileSystemDirectoryEntry): Promise { - const reader = dir.createReader(); - const all: FileSystemEntry[] = []; - return new Promise((resolve, reject) => { - const readBatch = (): void => { - reader.readEntries( - (batch) => { - if (batch.length === 0) { - resolve(all); - return; - } - all.push(...batch); - readBatch(); - }, - (err) => reject(err), - ); - }; - readBatch(); - }); -} - -function readFileEntry(entry: FileSystemFileEntry): Promise { - return new Promise((resolve, reject) => { - entry.file(resolve, reject); - }); -} - -/** Read a File, optionally reporting each stream chunk (not the full size up front). */ -export async function readBlobProgress(file: File, onBytes?: (n: number) => void, signal?: AbortSignal): Promise { - throwIfAborted(signal); - if (!onBytes || typeof file.stream !== 'function') { - const buf = new Uint8Array(await file.arrayBuffer()); - throwIfAborted(signal); - return buf; - } - const reader = file.stream().getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - for (;;) { - throwIfAborted(signal); - const { done, value } = await reader.read(); - if (done) break; - if (!value?.byteLength) continue; - chunks.push(value); - total += value.byteLength; - onBytes(value.byteLength); - } - } finally { - reader.releaseLock(); - } - const out = new Uint8Array(total); - let o = 0; - for (const c of chunks) { - out.set(c, o); - o += c.length; - } - return out; -} +/** Shared DataTransfer import for local IndexedDB and remote AFP catalogs. */ + +import { hfsTimeToAfp } from '../protocol/afp/constants'; +import { unescapeHostFilename } from '../protocol/host-filename'; +import { loadPrefs } from '../util/prefs'; +import { log } from '../util/logger'; +import { expandIncoming, isExpandableArchive, type ExpandedDir, type ExpandedFile, type ExpandedNode } from './expand-incoming'; +import type { Catalog, VNode } from './virtual-fs'; +import { nodeRef } from './virtual-fs'; +import type { NodeRef } from './catalog-caps'; +import { throwIfAborted, isAbortError, abortError } from '../util/abort'; +import { iconCache } from './icon-cache'; +import { + planItemPlacement, + TransferCancelled, + type NameConflictChoice, + type PlacementPlan, +} from './name-conflict'; + +export type ExpandTrackFile = { + name: string; + /** Archive-relative path, unique among siblings of the same parent job. */ + path: string; + bytesTotal: number; + finderInfo?: Uint8Array; +}; + +export type ImportItemTrack = { + onBytes?: (n: number) => void; + onDone?: (err?: Error) => void; + /** Parent job caption while cataloging an archive (before nested files exist). */ + onStatus?: (detail: string) => void; + /** Reset the parent job and announce every extracted file (queued) before writes start. */ + onExpandBegin?: (bytesTotal: number, files: ExpandTrackFile[]) => void; + /** Nested job while a dropped wrapper is decoded (BinHex / MacBinary / later StuffIt). */ + onExpand?: (item: ExpandTrackFile) => ImportItemTrack | undefined; + signal?: AbortSignal; + /** Called just before a dest file is created (Finder overlay + cancel partial). */ + onWrite?: (parentId: NodeRef, name: string) => void; + /** Called when an extracted folder is created (Finder overlay in that parent). */ + onDir?: (parentId: NodeRef, name: string, dirId: NodeRef, path: string) => void; + /** Delete a dest file left behind if this write is cancelled mid-flight. */ + removePartial?: (parentId: NodeRef, name: string) => Promise; + /** + * Serialize this item behind other Finder copies (AFP Writes on LocalTalk). + * Re-enters for the same job when an archive expands inside the import. + */ + runInCopySlot?: (fn: () => Promise) => Promise; +}; + +async function runInCopySlot(track: ImportItemTrack | undefined, fn: () => Promise): Promise { + return track?.runInCopySlot ? track.runInCopySlot(fn) : fn(); +} + +export type ImportProgress = { + onScan?: (total: number) => void; + onProgress?: (done: number, total: number) => void; + /** One callback per top-level drop item (a folder is a single item). */ + onItem?: (item: { name: string; isDir: boolean; bytesTotal: number }) => ImportItemTrack | undefined; + resolveConflict?: (info: { + name: string; + isDir: boolean; + suggestedName: string; + }) => Promise; +}; + +type ImportBlob = ( + parentId: NodeRef, + file: File, + onBytes?: (n: number) => void, + /** Host resource fork from `..namedfork/rsrc` (Chrome on macOS). */ + resource?: Uint8Array, + signal?: AbortSignal, +) => Promise; +type ImportFs = Pick< + Catalog, + 'beginBatch' | 'endBatch' | 'ensureDir' | 'lookup' | 'remove' | 'createFile' | 'put' +>; + +/** + * Import files and folders from a drag-and-drop DataTransfer. + * Uses FileSystemEntry (directory trees) when available; falls back to FileList + * + webkitRelativePath. Returns the number of top-level items imported. + */ +export async function importDataTransferInto( + fs: ImportFs, + parentId: NodeRef, + dt: DataTransfer, + importBlob: ImportBlob, + opts?: ImportProgress, +): Promise { + const entries = collectDataTransferEntries(dt); + const total = entries.length > 0 ? await countFsEntries(entries) : dt.files.length; + opts?.onScan?.(total); + + let done = 0; + const tick = (): void => { + done++; + opts?.onProgress?.(done, total); + }; + + if (entries.length > 0) { + const groups = groupTopLevelEntries(entries.sort(compareImportEntries)); + const plans = await planIncoming(fs, parentId, groups, opts?.resolveConflict); + fs.beginBatch(); + try { + let imported = 0; + let cancelled = false; + for (let i = 0; i < groups.length; i++) { + const group = groups[i]!; + const plan = plans[i]!; + await applyReplace(fs, plan); + const bytesTotal = await measureGroupBytes(group.entries); + const track = opts?.onItem?.({ + name: plan.destName, + isDir: group.isDir, + bytesTotal, + }); + try { + await runInCopySlot(track, async () => { + throwIfAborted(track?.signal); + for (const entry of group.entries) { + const asName = mappedIncomingName(unescapeHostFilename(entry.name), group.name, plan.destName); + await importFsEntry(fs, parentId, entry, importBlob, tick, track, asName); + } + }); + track?.onDone?.(); + imported++; + } catch (err) { + track?.onDone?.(err instanceof Error ? err : new Error(String(err))); + if (isAbortError(err)) { + cancelled = true; + continue; + } + throw err; + } + } + if (imported === 0 && cancelled) throw new TransferCancelled(); + return imported; + } finally { + fs.endBatch(); + } + } + + const groups = groupFilesByTopLevel([...dt.files].sort(compareImportFiles)); + const plans = await planIncoming(fs, parentId, groups, opts?.resolveConflict); + fs.beginBatch(); + try { + let imported = 0; + let cancelled = false; + for (let i = 0; i < groups.length; i++) { + const group = groups[i]!; + const plan = plans[i]!; + await applyReplace(fs, plan); + const bytesTotal = group.files.reduce((n, f) => n + f.size, 0); + const track = opts?.onItem?.({ + name: plan.destName, + isDir: group.isDir, + bytesTotal, + }); + try { + await runInCopySlot(track, async () => { + throwIfAborted(track?.signal); + for (const file of group.files) { + const destTop = mappedIncomingName( + topFileName(file), + group.name, + plan.destName, + ); + await importFileWithRelativePath(fs, parentId, file, importBlob, track, destTop); + tick(); + } + }); + track?.onDone?.(); + imported++; + } catch (err) { + track?.onDone?.(err instanceof Error ? err : new Error(String(err))); + if (isAbortError(err)) { + cancelled = true; + continue; + } + throw err; + } + } + if (imported === 0 && cancelled) throw new TransferCancelled(); + return imported; + } finally { + fs.endBatch(); + } +} + +async function planIncoming( + fs: ImportFs, + parentId: NodeRef, + groups: { name: string; isDir: boolean }[], + resolveConflict?: ImportProgress['resolveConflict'], +): Promise { + if (!resolveConflict) { + return groups.map((g) => ({ destName: g.name, replaceId: null })); + } + const reserved = new Set(); + const plans: PlacementPlan[] = []; + for (const g of groups) { + const plan = await planItemPlacement(fs, parentId, g.name, g.isDir, { reserved, resolveConflict }); + if (!plan) throw new TransferCancelled(); + reserved.add(plan.destName.toLowerCase()); + plans.push(plan); + } + return plans; +} + +async function applyReplace(fs: ImportFs, plan: PlacementPlan): Promise { + if (plan.replaceId != null) await fs.remove(plan.replaceId); +} + +function mappedIncomingName(rawName: string, original: string, destName: string): string { + if (destName === original) return rawName; + if (rawName === original) return destName; + if (isAppleDoubleSidecarName(rawName) && rawName.slice(2) === original) return `._${destName}`; + return rawName; +} + +function topFileName(file: File): string { + const rel = file.webkitRelativePath; + if (rel && rel.includes('/')) return unescapeHostFilename(rel.split('/')[0]!); + return unescapeHostFilename(file.name); +} + +async function importFsEntry( + fs: ImportFs, + parentId: NodeRef, + entry: FileSystemEntry, + importBlob: ImportBlob, + onItem?: () => void, + track?: ImportItemTrack, + destName?: string, + parentDir?: FileSystemDirectoryEntry, +): Promise { + if (isNamedForkDirName(entry.name)) return; + throwIfAborted(track?.signal); + const name = destName ?? unescapeHostFilename(entry.name); + if (entry.isDirectory) { + const dir = await fs.ensureDir(parentId, name); + onItem?.(); + const folder = entry as FileSystemDirectoryEntry; + const kids = (await readDirectoryEntries(folder)).sort(compareImportEntries); + for (const kid of kids) { + await importFsEntry(fs, nodeRef(dir), kid, importBlob, onItem, track, undefined, folder); + } + return; + } + if (entry.isFile) { + const fileEntry = entry as FileSystemFileEntry; + const file = await readFileEntry(fileEntry); + const resource = await readNamedResourceForkForEntry(fileEntry, parentDir); + await importOneFile(fs, parentId, fileWithName(file, name), importBlob, track, resource); + onItem?.(); + } +} + +async function importFileWithRelativePath( + fs: ImportFs, + parentId: NodeRef, + file: File, + importBlob: ImportBlob, + track?: ImportItemTrack, + destTop?: string, +): Promise { + throwIfAborted(track?.signal); + const rel = file.webkitRelativePath; + if (!rel || !rel.includes('/')) { + const name = destTop ?? unescapeHostFilename(file.name); + await importOneFile(fs, parentId, fileWithName(file, name), importBlob, track); + return; + } + const parts = rel.split('/'); + if (destTop) parts[0] = destTop; + let dirId = parentId; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]!; + if (!part || part === '.' || part === '..') continue; + dirId = nodeRef(await fs.ensureDir(dirId, unescapeHostFilename(part))); + } + await importOneFile(fs, dirId, file, importBlob, track); +} + +async function importOneFile( + fs: ImportFs, + parentId: NodeRef, + file: File, + importBlob: ImportBlob, + track?: ImportItemTrack, + resource?: Uint8Array | null, +): Promise { + const name = unescapeHostFilename(file.name); + const onBytes = track?.onBytes; + const hostResource = resource?.length ? resource : undefined; + throwIfAborted(track?.signal); + if (hostResource) { + log.info(`Imported resource fork for “${name}” (${hostResource.length} bytes)`, 'import'); + } + if (!loadPrefs().autoExpandFiles || !shouldTryExpand(name)) { + await writeTrackedBlob(parentId, name, () => importBlob(parentId, file, onBytes, hostResource, track?.signal), track); + return; + } + const buf = await readBlobProgress(file, onBytes, track?.signal); + throwIfAborted(track?.signal); + let expanded: ExpandedNode[] | null = null; + try { + expanded = expandIncoming(name, buf); + } catch (err) { + log.warn(`Couldn’t auto-expand “${name}”: ${err instanceof Error ? err.message : err}`, 'expand'); + } + if (!expanded) { + await writeTrackedBlob( + parentId, + name, + () => + importBlob( + parentId, + new File([buf], name, { type: file.type, lastModified: file.lastModified }), + undefined, + hostResource, + track?.signal, + ), + track, + ); + return; + } + const first = expanded[0]; + log.info( + expanded.length === 1 && first?.kind === 'file' + ? `Expanded “${name}” → “${first.name}”` + : `Expanded “${name}” into ${expanded.length} item(s)`, + 'expand', + ); + await importExpandedTree(fs, parentId, expanded, track); +} + +async function writeTrackedBlob( + parentId: NodeRef, + name: string, + write: () => Promise, + track?: ImportItemTrack, +): Promise { + throwIfAborted(track?.signal); + try { + track?.onWrite?.(parentId, name); + await write(); + throwIfAborted(track?.signal); + } catch (err) { + if (isAbortError(err)) await track?.removePartial?.(parentId, name); + throw err; + } +} + +function shouldTryExpand(name: string): boolean { + return isExpandableArchive(name); +} + +function expandedFiles(nodes: ExpandedNode[], prefix = ''): ExpandTrackFile[] { + const out: ExpandTrackFile[] = []; + for (const node of nodes) { + const path = prefix ? `${prefix}/${node.name}` : node.name; + if (node.kind === 'dir') out.push(...expandedFiles(node.children, path)); + else { + out.push({ + name: node.name, + path, + bytesTotal: node.data.length + node.resource.length, + finderInfo: node.finderInfo, + }); + } + } + return out; +} + +function expandedByteTotal(files: ExpandTrackFile[]): number { + return files.reduce((n, f) => n + f.bytesTotal, 0); +} + +/** Write expanded Mac files/folders through Catalog.createFile / put (forks, Finder info, dates). */ +export async function importExpandedTree( + fs: Pick, + parentId: NodeRef, + nodes: ExpandedNode[], + track?: ImportItemTrack, + opts?: { announce?: boolean; prefix?: string }, +): Promise { + const prefix = opts?.prefix ?? ''; + if (opts?.announce !== false) { + const files = expandedFiles(nodes, prefix); + track?.onExpandBegin?.(expandedByteTotal(files), files); + } + await runInCopySlot(track, () => writeExpandedNodes(fs, parentId, nodes, track, prefix)); +} + +async function writeExpandedNodes( + fs: Pick, + parentId: NodeRef, + nodes: ExpandedNode[], + track?: ImportItemTrack, + prefix = '', +): Promise { + for (const node of nodes) { + throwIfAborted(track?.signal); + const path = prefix ? `${prefix}/${node.name}` : node.name; + if (node.kind === 'dir') { + const dir = await fs.ensureDir(parentId, node.name); + track?.onDir?.(parentId, node.name, nodeRef(dir), path); + await stampExpandedMeta(fs, dir, node, true); + await writeExpandedNodes(fs, nodeRef(dir), node.children, track, path); + continue; + } + await importExpandedFile(fs, parentId, node, track, path); + } +} + +async function importExpandedFile( + fs: Pick, + parentId: NodeRef, + node: ExpandedFile, + track?: ImportItemTrack, + path = node.name, +): Promise { + const bytesTotal = node.data.length + node.resource.length; + const child = track?.onExpand?.({ + name: node.name, + path, + bytesTotal, + finderInfo: node.finderInfo, + }); + if (child?.signal?.aborted) { + child.onDone?.(abortError(child.signal)); + return; + } + const credit = (n: number): void => { + child?.onBytes?.(n); + track?.onBytes?.(n); + }; + let wrote = 0; + const signal = child?.signal ?? track?.signal; + const removePartial = child?.removePartial ?? track?.removePartial; + try { + throwIfAborted(signal); + child?.onWrite?.(parentId, node.name) ?? track?.onWrite?.(parentId, node.name); + const vnode = await fs.createFile(parentId, node.name, node.data, node.resource, node.finderInfo, (n) => { + throwIfAborted(signal); + wrote += n; + credit(n); + }, signal); + throwIfAborted(signal); + if (wrote === 0 && bytesTotal > 0) credit(bytesTotal); + await stampExpandedMeta(fs, vnode, node, false); + try { + await iconCache.ingestExtracted({ + name: node.name, + finderInfo: node.finderInfo, + resource: node.resource, + data: node.data, + }); + } catch { + /* listing can still fall back to AFP / system glyphs */ + } + child?.onDone?.(); + } catch (err) { + if (isAbortError(err)) await removePartial?.(parentId, node.name); + child?.onDone?.(err instanceof Error ? err : new Error(String(err))); + if (isAbortError(err) && track?.signal && !track.signal.aborted) return; + throw err; + } +} + +async function stampExpandedMeta( + fs: Pick, + vnode: VNode, + meta: ExpandedFile | ExpandedDir, + applyFinderInfo: boolean, +): Promise { + let dirty = false; + if (applyFinderInfo && meta.finderInfo && meta.finderInfo.some((b) => b !== 0)) { + vnode.finderInfo = meta.finderInfo; + dirty = true; + } + if (meta.createDate) { + vnode.createDate = hfsTimeToAfp(meta.createDate); + dirty = true; + } + if (meta.modDate) { + vnode.modDate = hfsTimeToAfp(meta.modDate); + dirty = true; + } + if (dirty) await fs.put(vnode); +} + +function fileWithName(file: File, name: string): File { + if (file.name === name) return file; + return new File([file], name, { type: file.type, lastModified: file.lastModified }); +} + +function collectDataTransferEntries(dt: DataTransfer): FileSystemEntry[] { + const entries: FileSystemEntry[] = []; + for (let i = 0; i < dt.items.length; i++) { + const item = dt.items[i]!; + if (item.kind !== 'file') continue; + const entry = item.webkitGetAsEntry?.() ?? null; + if (entry) entries.push(entry); + } + return entries; +} + +async function countFsEntries(entries: FileSystemEntry[]): Promise { + let total = 0; + const walk = async (entry: FileSystemEntry): Promise => { + if (isNamedForkDirName(entry.name)) return; + total++; + if (!entry.isDirectory) return; + const kids = await readDirectoryEntries(entry as FileSystemDirectoryEntry); + for (const kid of kids) await walk(kid); + }; + for (const entry of entries) await walk(entry); + return total; +} + +async function measureGroupBytes(entries: FileSystemEntry[]): Promise { + let n = 0; + for (const entry of entries) n += await measureEntryBytes(entry); + return n; +} + +async function measureEntryBytes(entry: FileSystemEntry, parentDir?: FileSystemDirectoryEntry): Promise { + if (isNamedForkDirName(entry.name)) return 0; + if (entry.isFile) { + const fileEntry = entry as FileSystemFileEntry; + const file = await readFileEntry(fileEntry); + const resource = await readNamedResourceForkForEntry(fileEntry, parentDir); + return file.size + (resource?.length ?? 0); + } + if (!entry.isDirectory) return 0; + let n = 0; + const folder = entry as FileSystemDirectoryEntry; + const kids = await readDirectoryEntries(folder); + for (const kid of kids) n += await measureEntryBytes(kid, folder); + return n; +} + +function groupTopLevelEntries( + entries: FileSystemEntry[], +): { name: string; isDir: boolean; entries: FileSystemEntry[] }[] { + const map = new Map(); + const order: string[] = []; + for (const entry of entries) { + const raw = unescapeHostFilename(entry.name); + const logical = isAppleDoubleSidecarName(raw) ? raw.slice(2) : raw; + const key = logical.toLowerCase(); + let g = map.get(key); + if (!g) { + g = { name: logical, isDir: false, entries: [] }; + map.set(key, g); + order.push(key); + } + g.entries.push(entry); + if (entry.isDirectory && !isAppleDoubleSidecarName(raw)) g.isDir = true; + } + return order.map((k) => { + const g = map.get(k)!; + g.entries.sort(compareImportEntries); + return g; + }); +} + +function groupFilesByTopLevel(files: File[]): { name: string; isDir: boolean; files: File[] }[] { + const map = new Map(); + const order: string[] = []; + const bump = (logical: string, isDir: boolean, file: File) => { + const key = logical.toLowerCase(); + let g = map.get(key); + if (!g) { + g = { name: logical, isDir, files: [] }; + map.set(key, g); + order.push(key); + } + g.files.push(file); + if (isDir) g.isDir = true; + }; + for (const file of files) { + const rel = file.webkitRelativePath; + if (rel && rel.includes('/')) { + bump(unescapeHostFilename(rel.split('/')[0]!), true, file); + } else { + const raw = unescapeHostFilename(file.name); + bump(isAppleDoubleSidecarName(raw) ? raw.slice(2) : raw, false, file); + } + } + return order.map((k) => { + const g = map.get(k)!; + g.files.sort(compareImportFiles); + return g; + }); +} + +function isAppleDoubleSidecarName(name: string): boolean { + return name.startsWith('._') && name.length > 2; +} + +/** Chrome on macOS can expose a file's resource fork as this parallel path. */ +export const NAMED_RESOURCE_FORK_SUFFIX = '..namedfork/rsrc'; + +export function namedResourceForkPath(fileName: string): string { + return `${fileName}/${NAMED_RESOURCE_FORK_SUFFIX}`; +} + +function isNamedForkDirName(name: string): boolean { + return name === '..namedfork'; +} + +/** AppleDouble sidecars already carry the resource fork; don't probe them. */ +export function shouldProbeNamedResourceFork(name: string): boolean { + if (!name || name === '.' || name === '..' || isNamedForkDirName(name)) return false; + return !isAppleDoubleSidecarName(name); +} + +/** + * Read `file/..namedfork/rsrc` via the drag-and-drop File System API. + * Works in Chrome on macOS; Safari often returns a File that fails to read, + * Firefox typically errors. Empty forks and failures are ignored. + */ +export async function readNamedResourceFork( + dir: FileSystemDirectoryEntry, + fileName: string, +): Promise { + if (!shouldProbeNamedResourceFork(fileName)) return null; + const file = await getFileFromDirectory(dir, namedResourceForkPath(fileName)); + return readResourceForkFile(file); +} + +async function readNamedResourceForkForEntry( + fileEntry: FileSystemFileEntry, + parentDir?: FileSystemDirectoryEntry | null, +): Promise { + if (!shouldProbeNamedResourceFork(fileEntry.name)) return null; + const dir = parentDir ?? (await getParentDirectory(fileEntry)); + if (dir) { + const fromParent = await readNamedResourceFork(dir, fileEntry.name); + if (fromParent) return fromParent; + } + const root = fileEntry.filesystem?.root; + const fullPath = fileEntry.fullPath?.replace(/^\//, ''); + if (!root || !fullPath) return null; + const file = await getFileFromDirectory(root, namedResourceForkPath(fullPath)); + return readResourceForkFile(file); +} + +async function readResourceForkFile(file: File | null): Promise { + if (!file?.size) return null; + try { + const buf = new Uint8Array(await file.arrayBuffer()); + return buf.length ? buf : null; + } catch { + // Safari: getFile succeeds but reading the named fork throws. + return null; + } +} + +function getFileFromDirectory(dir: FileSystemDirectoryEntry, relativePath: string): Promise { + return new Promise((resolve) => { + if (typeof dir.getFile !== 'function') { + resolve(null); + return; + } + try { + dir.getFile( + relativePath, + { create: false }, + (entry) => { + (entry as FileSystemFileEntry).file( + (file) => resolve(file), + () => resolve(null), + ); + }, + () => resolve(null), + ); + } catch { + resolve(null); + } + }); +} + +function getParentDirectory(entry: FileSystemEntry): Promise { + return new Promise((resolve) => { + if (typeof entry.getParent !== 'function') { + resolve(null); + return; + } + try { + entry.getParent( + (parent) => resolve(parent as FileSystemDirectoryEntry), + () => resolve(null), + ); + } catch { + resolve(null); + } + }); +} + +function compareImportEntries(a: FileSystemEntry, b: FileSystemEntry): number { + const as = isAppleDoubleSidecarName(a.name) ? 1 : 0; + const bs = isAppleDoubleSidecarName(b.name) ? 1 : 0; + return as - bs || a.name.localeCompare(b.name); +} + +function compareImportFiles(a: File, b: File): number { + const an = a.webkitRelativePath || a.name; + const bn = b.webkitRelativePath || b.name; + const as = isAppleDoubleSidecarName(a.name) ? 1 : 0; + const bs = isAppleDoubleSidecarName(b.name) ? 1 : 0; + return as - bs || an.localeCompare(bn); +} + +function readDirectoryEntries(dir: FileSystemDirectoryEntry): Promise { + const reader = dir.createReader(); + const all: FileSystemEntry[] = []; + return new Promise((resolve, reject) => { + const readBatch = (): void => { + reader.readEntries( + (batch) => { + if (batch.length === 0) { + resolve(all); + return; + } + all.push(...batch); + readBatch(); + }, + (err) => reject(err), + ); + }; + readBatch(); + }); +} + +function readFileEntry(entry: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); +} + +/** Read a File, optionally reporting each stream chunk (not the full size up front). */ +export async function readBlobProgress(file: File, onBytes?: (n: number) => void, signal?: AbortSignal): Promise { + throwIfAborted(signal); + if (!onBytes || typeof file.stream !== 'function') { + const buf = new Uint8Array(await file.arrayBuffer()); + throwIfAborted(signal); + return buf; + } + const reader = file.stream().getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + throwIfAborted(signal); + const { done, value } = await reader.read(); + if (done) break; + if (!value?.byteLength) continue; + chunks.push(value); + total += value.byteLength; + onBytes(value.byteLength); + } + } finally { + reader.releaseLock(); + } + const out = new Uint8Array(total); + let o = 0; + for (const c of chunks) { + out.set(c, o); + o += c.length; + } + return out; +} diff --git a/src/fs/name-conflict.ts b/src/fs/name-conflict.ts index 6dee4cd..36c43b5 100644 --- a/src/fs/name-conflict.ts +++ b/src/fs/name-conflict.ts @@ -1,6 +1,8 @@ /** Name clashes when copying, moving, or importing into a folder. */ import { isAbortError } from '../util/abort'; +import type { NodeRef } from './catalog-caps'; +import { nodeRef, type VNode } from './virtual-fs'; export type NameConflictChoice = 'replace' | 'rename' | 'cancel'; @@ -33,12 +35,12 @@ export function copyItemName(base: string): string { } type LookupFs = { - lookup(parentId: number, name: string): Promise<{ id: number } | undefined>; + lookup(parent: NodeRef, name: string): Promise; }; export async function uniqueCopyName( fs: LookupFs, - parentId: number, + parentId: NodeRef, base: string, reserved: Set = new Set(), ): Promise { @@ -56,15 +58,15 @@ export async function uniqueCopyName( } } -export type PlacementPlan = { destName: string; replaceId: number | null }; +export type PlacementPlan = { destName: string; replaceId: NodeRef | null }; export async function planItemPlacement( fs: LookupFs, - parentId: number, + parentId: NodeRef, name: string, isDir: boolean, opts: { - ignoreId?: number; + ignoreId?: NodeRef; reserved?: Set; resolveConflict: (info: { name: string; @@ -75,12 +77,12 @@ export async function planItemPlacement( ): Promise { const reserved = opts.reserved ?? new Set(); const existing = await fs.lookup(parentId, name); - if (!existing || existing.id === opts.ignoreId) { + if (!existing || nodeRef(existing) === opts.ignoreId) { return { destName: name, replaceId: null }; } const suggestedName = await uniqueCopyName(fs, parentId, name, reserved); const choice = await opts.resolveConflict({ name, isDir, suggestedName }); if (choice === 'cancel') return null; - if (choice === 'replace') return { destName: name, replaceId: existing.id }; + if (choice === 'replace') return { destName: name, replaceId: nodeRef(existing) }; return { destName: suggestedName, replaceId: null }; } diff --git a/src/fs/remote-vfs.ts b/src/fs/remote-vfs.ts index 2da4001..6200be7 100644 --- a/src/fs/remote-vfs.ts +++ b/src/fs/remote-vfs.ts @@ -1,12 +1,15 @@ /** AFP volume as a Finder Catalog (same API as IndexedDB VirtualFS). */ -import { CNIDRoot, macTime } from '../protocol/afp/constants'; +import { CNIDRoot } from '../protocol/afp/constants'; import type { AfpClient } from '../services/afp-client/client'; import type { DirEntry } from '../services/afp-client/commands'; import { unescapeHostFilename } from '../protocol/host-filename'; import { parseAppleDouble, parseAppleSingle, AS_MAGIC, AD_MAGIC } from './appledouble'; import { be32 } from '../protocol/binary'; -import type { Catalog, VNode, VfsChangeListener, ChildrenBatchListener } from './virtual-fs'; +import type { Catalog, CnidVNode, VNode, VfsChangeListener, ChildrenBatchListener } from './virtual-fs'; +import { requireCnid, nodeRef } from './virtual-fs'; +import { afpVolumeCaps, asCnid, fromUnixMs, toUnixMs, type CatalogCapabilities, type NodeRef } from './catalog-caps'; +import { finderFlags, kIsInvisible, kNameLocked } from './finder-info'; import { importDataTransferInto, type ImportProgress } from './import-transfer'; import { finderInfoFromName } from './extension-map'; import { bufferRangeReader, type ByteRangeReader } from './byte-range'; @@ -18,10 +21,10 @@ const EMPTY = new Uint8Array(); export class RemoteVfs implements Catalog { readonly reportsChunkedBytes = true; - private client: AfpClient; - private volumeName: string; - private volId: number; - private nodes = new Map(); + readonly client: AfpClient; + readonly volumeName: string; + readonly volId: number; + private nodes = new Map(); private forksLoaded = new Set(); private listeners = new Set(); private batchDepth = 0; @@ -31,10 +34,14 @@ export class RemoteVfs implements Catalog { this.client = client; this.volumeName = volumeName; this.volId = volId; - this.nodes.set(this.rootId(), this.makeRoot()); + this.nodes.set(CNIDRoot, this.makeRoot()); } - rootId(): number { + capabilities(): CatalogCapabilities { + return afpVolumeCaps; + } + + rootId(): NodeRef { return CNIDRoot; } @@ -57,11 +64,51 @@ export class RemoteVfs implements Catalog { } } - async get(id: number): Promise { - return this.nodes.get(id) ?? (id === this.rootId() ? this.ensureRoot() : undefined); + async get(ref: NodeRef): Promise { + if (typeof ref === 'string') return this.resolvePath(ref); + return this.nodes.get(ref) ?? (ref === CNIDRoot ? this.ensureRoot() : undefined); + } + + async resolvePath(path: string): Promise { + const parts = path.split('/').filter(Boolean); + let cur: VNode | undefined = await this.get(CNIDRoot); + for (const part of parts) { + if (!cur) return undefined; + cur = await this.lookup(nodeRef(cur), part); + } + return cur; } - async ensureContent(id: number, onBytes?: (n: number) => void, signal?: AbortSignal): Promise { + async pathOf(ref: NodeRef): Promise { + if (typeof ref === 'string') return ref; + const names: string[] = []; + let cur = await this.get(ref); + while (cur && cur.addr === 'cnid' && cur.id !== CNIDRoot) { + if (cur.name) names.unshift(cur.name); + if (!cur.parentId || cur.parentId === cur.id) break; + cur = await this.get(cur.parentId); + } + return names.join('/'); + } + + async setAttrs(ref: NodeRef, patch: Record): Promise { + const node = await this.get(ref); + if (!node) throw new Error('not found'); + node.attrs = { ...(node.attrs ?? {}), ...patch }; + if (node.finderInfo.length >= 10) { + const fi = new Uint8Array(node.finderInfo); + let flags = finderFlags(fi); + if (patch.invisible != null) flags = patch.invisible ? flags | kIsInvisible : flags & ~kIsInvisible; + if (patch.locked != null) flags = patch.locked ? flags | kNameLocked : flags & ~kNameLocked; + fi[8] = (flags >> 8) & 0xff; + fi[9] = flags & 0xff; + node.finderInfo = fi; + } + await this.put(node); + } + + async ensureContent(ref: NodeRef, onBytes?: (n: number) => void, signal?: AbortSignal): Promise { + const id = asCnid(ref); const node = await this.get(id); if (!node || node.isDir) return node; if (!this.forksLoaded.has(id)) await this.hydrateForks(node, onBytes, signal); @@ -69,10 +116,11 @@ export class RemoteVfs implements Catalog { } async children( - parentId: number, + parent: NodeRef, onBatch?: ChildrenBatchListener, signal?: AbortSignal, ): Promise { + const parentId = asCnid(parent); const kids: VNode[] = []; const seen = new Set(); const take = async (batch: DirEntry[]) => { @@ -90,8 +138,9 @@ export class RemoteVfs implements Catalog { return kids; } - async lookup(parentId: number, name: string, signal?: AbortSignal): Promise { + async lookup(parent: NodeRef, name: string, signal?: AbortSignal): Promise { throwIfAborted(signal); + const parentId = asCnid(parent); const lower = name.toLowerCase(); for (const n of this.nodes.values()) { if (n.parentId === parentId && n.name.toLowerCase() === lower) return n; @@ -154,13 +203,13 @@ export class RemoteVfs implements Catalog { opts?: { resource?: boolean; signal?: AbortSignal; priority?: number }, ): Promise { throwIfAborted(opts?.signal); - if (this.forksLoaded.has(node.id)) { + if (this.forksLoaded.has(requireCnid(node).id)) { const bytes = opts?.resource ? node.resource : node.data; return fn(bufferRangeReader(bytes)); } return this.client.withForkReader( node.name, - node.parentId, + requireCnid(node).parentId, opts?.resource === true, fn, this.volId, @@ -168,7 +217,8 @@ export class RemoteVfs implements Catalog { ); } - async mkdir(parentId: number, name: string): Promise { + async mkdir(parent: NodeRef, name: string): Promise { + const parentId = asCnid(parent); const existing = await this.lookup(parentId, name); if (existing) throw new Error('exists'); await this.client.mkdir(name, parentId, this.volId); @@ -177,7 +227,8 @@ export class RemoteVfs implements Catalog { return node; } - async ensureDir(parentId: number, name: string): Promise { + async ensureDir(parent: NodeRef, name: string): Promise { + const parentId = asCnid(parent); const existing = await this.lookup(parentId, name); if (existing?.isDir) return existing; if (existing) throw new Error('exists'); @@ -185,7 +236,7 @@ export class RemoteVfs implements Catalog { } async createFile( - parentId: number, + parent: NodeRef, name: string, data: Uint8Array, resource = new Uint8Array(), @@ -194,6 +245,7 @@ export class RemoteVfs implements Catalog { signal?: AbortSignal, ): Promise { throwIfAborted(signal); + const parentId = asCnid(parent); await this.client.writeFile(name, data, parentId, false, this.volId, onBytes, signal); throwIfAborted(signal); if (resource.length) await this.client.writeFile(name, resource, parentId, true, this.volId, onBytes, signal); @@ -205,58 +257,64 @@ export class RemoteVfs implements Catalog { (await this.lookup(parentId, name)) ?? this.placeholderFile(parentId, name, data, resource, finderInfo); if (node) { + const cnid = requireCnid(node); node.data = data; node.resource = resource; node.finderInfo = finderInfo; node.dataBytes = data.length; node.resourceBytes = resource.length; - this.forksLoaded.add(node.id); - this.nodes.set(node.id, node); + this.forksLoaded.add(cnid.id); + this.nodes.set(cnid.id, cnid); } this.notify(parentId); return node; } async put(node: VNode): Promise { - this.nodes.set(node.id, node); - await this.client.setFinderInfo(node.name, node.finderInfo, node.parentId, this.volId, { - createDate: node.createDate || undefined, - modDate: node.modDate || undefined, + const n = requireCnid(node); + this.nodes.set(n.id, n); + await this.client.setFinderInfo(n.name, n.finderInfo, n.parentId, this.volId, { + createDate: n.createDate ? fromUnixMs(n.createDate) : undefined, + modDate: n.modDate ? fromUnixMs(n.modDate) : undefined, }); - this.notify(node.parentId); + this.notify(n.parentId); } - async rename(id: number, newName: string): Promise { - const n = this.nodes.get(id) ?? (await this.get(id)); + async rename(ref: NodeRef, newName: string): Promise { + const id = asCnid(ref); + const n = this.nodes.get(id) ?? requireCnid((await this.get(id))!); if (!n) throw new Error('not found'); if (n.id === this.rootId()) throw new Error('cannot rename volume root'); await this.client.rename(n.name, newName, n.parentId, this.volId); n.name = newName; - n.modDate = macTime(); + n.modDate = Date.now(); this.nodes.set(id, n); this.notify(n.parentId); } - async move(id: number, newParent: number): Promise { - const n = this.nodes.get(id) ?? (await this.get(id)); + async move(ref: NodeRef, newParent: NodeRef): Promise { + const id = asCnid(ref); + const parentId = asCnid(newParent); + const n = this.nodes.get(id) ?? requireCnid((await this.get(id))!); if (!n) throw new Error('not found'); if (n.id === this.rootId()) throw new Error('cannot move volume root'); - if (n.parentId === newParent) return; - await this.client.moveAndRename(n.parentId, n.name, newParent, '', this.volId); + if (n.parentId === parentId) return; + await this.client.moveAndRename(n.parentId, n.name, parentId, '', this.volId); const oldParent = n.parentId; - n.parentId = newParent; - n.modDate = macTime(); + n.parentId = parentId; + n.modDate = Date.now(); this.nodes.set(id, n); - this.notify(oldParent, newParent); + this.notify(oldParent, parentId); } - async remove(id: number): Promise { + async remove(ref: NodeRef): Promise { + const id = asCnid(ref); const n = this.nodes.get(id); if (!n) return; if (n.id === this.rootId()) throw new Error('cannot delete volume root'); if (n.isDir) { const kids = await this.children(id); - for (const k of kids) await this.remove(k.id); + for (const k of kids) await this.remove(nodeRef(k)); } await this.client.remove(n.name, n.parentId, this.volId); this.nodes.delete(id); @@ -264,17 +322,19 @@ export class RemoteVfs implements Catalog { this.notify(n.parentId); } - async importDataTransfer(parentId: number, dt: DataTransfer, opts?: ImportProgress): Promise { + async importDataTransfer(parent: NodeRef, dt: DataTransfer, opts?: ImportProgress): Promise { + const parentId = asCnid(parent); return importDataTransferInto(this, parentId, dt, (p, file, onBytes, resource, signal) => this.importBlob(p, file, onBytes, resource, signal), opts); } private async importBlob( - parentId: number, + parent: NodeRef, file: File, onBytes?: (n: number) => void, resource?: Uint8Array, signal?: AbortSignal, ): Promise { + const parentId = asCnid(parent); throwIfAborted(signal); const buf = new Uint8Array(await file.arrayBuffer()); throwIfAborted(signal); @@ -285,7 +345,8 @@ export class RemoteVfs implements Catalog { const target = name.slice(2); const existing = await this.lookup(parentId, target); if (existing && !existing.isDir) { - const data = this.forksLoaded.has(existing.id) + const cnid = requireCnid(existing); + const data = this.forksLoaded.has(cnid.id) ? existing.data : await this.client.readFile(existing.name, parentId, false, this.volId); return this.createFile(parentId, target, data, ad.resource, ad.finderInfo, onBytes, signal); @@ -306,22 +367,23 @@ export class RemoteVfs implements Catalog { private async hydrateForks(node: VNode, onBytes?: (n: number) => void, signal?: AbortSignal): Promise { throwIfAborted(signal); + const n = requireCnid(node); try { - node.data = await this.client.readFile(node.name, node.parentId, false, this.volId, onBytes, signal); + node.data = await this.client.readFile(n.name, n.parentId, false, this.volId, onBytes, signal); } catch (err) { if (isAbortError(err)) throw err; node.data = EMPTY; } try { - node.resource = await this.client.readFile(node.name, node.parentId, true, this.volId, onBytes, signal); + node.resource = await this.client.readFile(n.name, n.parentId, true, this.volId, onBytes, signal); } catch (err) { if (isAbortError(err)) throw err; node.resource = EMPTY; } node.dataBytes = node.data.length; node.resourceBytes = node.resource.length; - this.forksLoaded.add(node.id); - this.nodes.set(node.id, node); + this.forksLoaded.add(n.id); + this.nodes.set(n.id, n); } private adopt( @@ -340,7 +402,8 @@ export class RemoteVfs implements Catalog { parentId: number, ): VNode { const prev = this.nodes.get(e.cnid); - const node: VNode = { + const node: CnidVNode = { + addr: 'cnid', id: e.cnid, parentId: e.parentId || parentId, name: e.name, @@ -348,8 +411,8 @@ export class RemoteVfs implements Catalog { data: prev && this.forksLoaded.has(e.cnid) ? prev.data : EMPTY, resource: prev && this.forksLoaded.has(e.cnid) ? prev.resource : EMPTY, finderInfo: e.finderInfo?.length ? e.finderInfo.slice() : new Uint8Array(32), - createDate: e.createDate, - modDate: e.modDate, + createDate: toUnixMs(e.createDate), + modDate: toUnixMs(e.modDate), dataBytes: e.isDir ? 0 : e.dataLen, resourceBytes: e.isDir ? 0 : e.rsrcLen, ...(e.attributes ? { attributes: e.attributes } : {}), @@ -364,9 +427,10 @@ export class RemoteVfs implements Catalog { return node; } - private makeRoot(): VNode { + private makeRoot(): CnidVNode { return { - id: this.rootId(), + addr: 'cnid', + id: CNIDRoot, parentId: 1, name: this.volumeName, isDir: true, @@ -379,7 +443,7 @@ export class RemoteVfs implements Catalog { } private ensureRoot(): VNode { - let n = this.nodes.get(this.rootId()); + let n = this.nodes.get(CNIDRoot); if (!n) { n = this.makeRoot(); this.nodes.set(n.id, n); @@ -387,8 +451,9 @@ export class RemoteVfs implements Catalog { return n; } - private placeholderDir(parentId: number, name: string): VNode { - const node: VNode = { + private placeholderDir(parentId: number, name: string): CnidVNode { + const node: CnidVNode = { + addr: 'cnid', id: Date.now() & 0x7fffffff, parentId, name, @@ -396,8 +461,8 @@ export class RemoteVfs implements Catalog { data: EMPTY, resource: EMPTY, finderInfo: new Uint8Array(32), - createDate: macTime(), - modDate: macTime(), + createDate: Date.now(), + modDate: Date.now(), }; this.nodes.set(node.id, node); return node; @@ -410,7 +475,8 @@ export class RemoteVfs implements Catalog { resource: Uint8Array, finderInfo: Uint8Array, ): VNode { - const node: VNode = { + const node: CnidVNode = { + addr: 'cnid', id: Date.now() & 0x7fffffff, parentId, name, @@ -418,8 +484,8 @@ export class RemoteVfs implements Catalog { data, resource, finderInfo, - createDate: macTime(), - modDate: macTime(), + createDate: Date.now(), + modDate: Date.now(), dataBytes: data.length, resourceBytes: resource.length, }; diff --git a/src/fs/resource-compress.ts b/src/fs/resource-compress.ts index eaae3a1..1bed48c 100644 --- a/src/fs/resource-compress.ts +++ b/src/fs/resource-compress.ts @@ -4,6 +4,11 @@ */ import { be16, be32s, concat } from '../protocol/binary'; +import { + RESOURCE_DECOMPRESSOR_DCMP, + registerResourceDecompressor, + registeredResourceDecompressors, +} from './codecs'; /** Resource attribute bit: data is compressed (KSFL / ResEdit extended header). */ export const RES_COMPRESSED = 1 << 0; @@ -52,12 +57,15 @@ function signaturePrefix(data: Uint8Array): boolean { /** Decompress if this looks like a compressed resource; otherwise return `data`. */ export function maybeDecompressResource(data: Uint8Array, attributes = 0): Uint8Array { - if (!isCompressedResource(data, attributes)) return data; - try { - return decompressResource(data); - } catch { - return data; + for (const d of registeredResourceDecompressors()) { + if (!d.sniff(data, attributes)) continue; + try { + return d.decompress(data); + } catch { + return data; + } } + return data; } export function decompressResource(data: Uint8Array): Uint8Array { @@ -410,3 +418,9 @@ function tablePairs(hex: string): Uint8Array[] { for (let i = 0; i < bytes.length; i += 2) out.push(bytes.subarray(i, i + 2)); return out; } + +registerResourceDecompressor({ + id: RESOURCE_DECOMPRESSOR_DCMP, + sniff: (data, attributes = 0) => isCompressedResource(data, attributes), + decompress: decompressResource, +}); diff --git a/src/fs/resource-types/vers.ts b/src/fs/resource-types/vers.ts index 9c4d930..c4e8278 100644 --- a/src/fs/resource-types/vers.ts +++ b/src/fs/resource-types/vers.ts @@ -29,6 +29,10 @@ export interface VersRec { export interface VersGetInfo { version: string; copyright: string; + product?: string; + productVersion?: string; + description?: string; + company?: string; } const COPYRIGHT_RE = /(?:©|\(c\)|copyright)/i; diff --git a/src/fs/virtual-fs.ts b/src/fs/virtual-fs.ts index d905322..06b9683 100644 --- a/src/fs/virtual-fs.ts +++ b/src/fs/virtual-fs.ts @@ -1,75 +1,105 @@ /** IndexedDB-backed virtual AFP volume. */ import { openDB, type IDBPDatabase } from './idb-shim'; -import { macTime } from '../protocol/afp/constants'; import { escapeHostFilename, unescapeHostFilename } from '../protocol/host-filename'; +import { + afpVolumeCaps, + toUnixMs, + type CatalogCapabilities, + type NodeRef, +} from './catalog-caps'; import { parseAppleDouble, parseAppleSingle, AS_MAGIC, AD_MAGIC } from './appledouble'; import { be32 } from '../protocol/binary'; import { importDataTransferInto, readBlobProgress, type ImportProgress } from './import-transfer'; +import { finderFlags, kIsInvisible, kNameLocked } from './finder-info'; import { finderInfoFromName } from './extension-map'; import { throwIfAborted } from '../util/abort'; import { bufferRangeReader, type ByteRangeReader } from './byte-range'; import { loadFinderIconFork, ResourceFork, type ResourceForkLoadOpts } from './resource-fork'; import { iconForkLoadOptions } from './icon-cache'; -export type VfsChange = { parentIds: number[] }; +export type VfsChange = { parentIds: NodeRef[] }; export type VfsChangeListener = (change: VfsChange) => void; /** Invoked with children gathered so far after each remote enumerate page. */ export type ChildrenBatchListener = (nodes: VNode[]) => void | Promise; -export interface VNode { - id: number; - parentId: number; +export type VNodeBase = { name: string; isDir: boolean; data: Uint8Array; resource: Uint8Array; finderInfo: Uint8Array; + /** Unix milliseconds. */ createDate: number; + /** Unix milliseconds. */ modDate: number; - /** Enumerated fork sizes when `data`/`resource` are not loaded (remote AFP). */ dataBytes?: number; resourceBytes?: number; - /** AFP file/dir attribute bits (locked / inhibit flags), when known. */ attributes?: number; + shortName?: string; + mediumName?: string; + accessDate?: number; + backupDate?: number; + attrs?: Record; +}; + +export type CnidVNode = VNodeBase & { addr: 'cnid'; id: number; parentId: number }; +export type PathVNode = VNodeBase & { addr: 'path'; path: string; parentPath: string }; +export type VNode = CnidVNode | PathVNode; + +export function nodeRef(n: VNode): NodeRef { + return n.addr === 'path' ? n.path : n.id; } -/** Finder catalog: local IndexedDB share and remote AFP volumes both implement this. */ +export function parentRef(n: VNode): NodeRef { + return n.addr === 'path' ? n.parentPath : n.parentId; +} + +export function cnidOf(n: VNode): number { + if (n.addr !== 'cnid') throw new Error('cnid node required'); + return n.id; +} + +export function requireCnid(n: VNode): CnidVNode { + if (n.addr !== 'cnid') throw new Error('cnid node required'); + return n; +} + +export function pathOfNode(n: VNode): string { + if (n.addr === 'path') return n.path; + throw new Error('path node required'); +} + +/** Finder catalog: CNID volumes (AFP) and path volumes (SMB/NCP/EtherDFS). */ export interface Catalog { - rootId(): number; + capabilities(): CatalogCapabilities; + rootId(): NodeRef; subscribe(fn: VfsChangeListener): () => void; beginBatch(): void; endBatch(): void; - get(id: number): Promise; - /** Load data/resource forks if the catalog stores them separately (remote AFP). */ - ensureContent(id: number, onBytes?: (n: number) => void, signal?: AbortSignal): Promise; - children(parentId: number, onBatch?: ChildrenBatchListener, signal?: AbortSignal): Promise; - lookup(parentId: number, name: string, signal?: AbortSignal): Promise; - /** Parse a resource fork via ranged reads (header and map; payloads on demand). */ + get(ref: NodeRef): Promise; + ensureContent(ref: NodeRef, onBytes?: (n: number) => void, signal?: AbortSignal): Promise; + children(parent: NodeRef, onBatch?: ChildrenBatchListener, signal?: AbortSignal): Promise; + lookup(parent: NodeRef, name: string, signal?: AbortSignal): Promise; + resolvePath(path: string): Promise; + pathOf(ref: NodeRef): Promise; + setAttrs?(ref: NodeRef, patch: Record): Promise; loadResourceFork(node: VNode, opts?: ResourceForkLoadOpts): Promise; - /** Enough of the resource fork to decode Finder icons. */ loadIconResources(node: VNode, signal?: AbortSignal): Promise; - /** - * AFP Desktop DB bitmaps for a type/creator (often B&W ICN#). Local catalogs omit this. - */ loadDesktopIcons?( type: string, creator: string, signal?: AbortSignal, ): Promise<{ iconType: number; data: Uint8Array }[] | null>; - /** - * Ranged reads of a file’s data or resource bytes. The catalog may keep a - * backend handle open until `fn` returns. - */ withRangeReader( node: VNode, fn: (read: ByteRangeReader) => Promise, opts?: { resource?: boolean; signal?: AbortSignal; priority?: number }, ): Promise; - mkdir(parentId: number, name: string): Promise; - ensureDir(parentId: number, name: string): Promise; + mkdir(parent: NodeRef, name: string): Promise; + ensureDir(parent: NodeRef, name: string): Promise; createFile( - parentId: number, + parent: NodeRef, name: string, data: Uint8Array, resource?: Uint8Array, @@ -78,14 +108,10 @@ export interface Catalog { signal?: AbortSignal, ): Promise; put(node: VNode): Promise; - rename(id: number, newName: string): Promise; - move(id: number, newParent: number): Promise; - remove(id: number): Promise; - importDataTransfer(parentId: number, dt: DataTransfer, opts?: ImportProgress): Promise; - /** - * True when createFile/ensureContent invoke onBytes per transferred chunk - * (AFP). Local IndexedDB catalogs write the whole buffer at once. - */ + rename(ref: NodeRef, newName: string): Promise; + move(ref: NodeRef, newParent: NodeRef): Promise; + remove(ref: NodeRef): Promise; + importDataTransfer(parent: NodeRef, dt: DataTransfer, opts?: ImportProgress): Promise; readonly reportsChunkedBytes?: boolean; } @@ -169,6 +195,7 @@ export class VirtualFS implements Catalog { const root = await this.get(ROOT_ID); if (!root) { await this.put({ + addr: 'cnid', id: ROOT_ID, parentId: 1, name: '', @@ -176,8 +203,8 @@ export class VirtualFS implements Catalog { data: new Uint8Array(), resource: new Uint8Array(), finderInfo: new Uint8Array(32), - createDate: macTime(), - modDate: macTime(), + createDate: Date.now(), + modDate: Date.now(), }); } const meta = await this.db.get('meta', 'nextId'); @@ -193,7 +220,7 @@ export class VirtualFS implements Catalog { const kids = await this.children(ROOT_ID); this.beginBatch(); try { - for (const k of kids) await this.remove(k.id); + for (const k of kids) await this.remove(nodeRef(k)); } finally { this.endBatch(); } @@ -208,26 +235,77 @@ export class VirtualFS implements Catalog { await this.db.put('meta', { key, value }); } - rootId(): number { + capabilities(): CatalogCapabilities { + return afpVolumeCaps; + } + + rootId(): NodeRef { return ROOT_ID; } - async get(id: number): Promise { - const n = await this.db.get('nodes', id); + async get(ref: NodeRef): Promise { + if (typeof ref === 'string') return this.resolvePath(ref); + const n = await this.db.get('nodes', ref); return n ? revive(n) : undefined; } async put(node: VNode): Promise { - await this.db.put('nodes', serialize(node)); - this.rememberName(node.parentId, node.name, node.id); - this.notifyChange(node.parentId); + const n = requireCnid(node); + await this.db.put('nodes', serialize(n)); + this.rememberName(n.parentId, n.name, n.id); + this.notifyChange(n.parentId); } - async remove(id: number): Promise { + private async requireNode(ref: NodeRef): Promise { + const n = await this.get(ref); + if (!n) throw new Error('not found'); + return n; + } + + async resolvePath(path: string): Promise { + const parts = path.split('/').filter(Boolean); + let cur: VNode | undefined = await this.get(ROOT_ID); + for (const part of parts) { + if (!cur) return undefined; + cur = await this.lookup(nodeRef(cur), part); + } + return cur; + } + + async pathOf(ref: NodeRef): Promise { + const id = requireCnidRef(ref); + const names: string[] = []; + let cur = await this.get(id); + while (cur && cur.addr === 'cnid' && cur.id !== ROOT_ID) { + if (cur.name) names.unshift(cur.name); + if (!cur.parentId || cur.parentId === cur.id) break; + cur = await this.get(cur.parentId); + } + return names.join('/'); + } + + async setAttrs(ref: NodeRef, patch: Record): Promise { + const node = await this.get(ref); + if (!node) throw new Error('not found'); + node.attrs = { ...(node.attrs ?? {}), ...patch }; + if (node.finderInfo.length >= 10) { + const fi = new Uint8Array(node.finderInfo); + let flags = finderFlags(fi); + if (patch.invisible != null) flags = patch.invisible ? flags | kIsInvisible : flags & ~kIsInvisible; + if (patch.locked != null) flags = patch.locked ? flags | kNameLocked : flags & ~kNameLocked; + fi[8] = (flags >> 8) & 0xff; + fi[9] = flags & 0xff; + node.finderInfo = fi; + } + await this.put(node); + } + + async remove(ref: NodeRef): Promise { + const id = requireCnidRef(ref); const node = await this.get(id); - const parentId = node?.parentId; + const parentId = node && node.addr === 'cnid' ? node.parentId : undefined; const kids = await this.children(id); - for (const k of kids) await this.removeQuiet(k.id); + for (const k of kids) await this.removeQuiet(requireCnid(k).id); await this.db.delete('nodes', id); if (node && parentId != null) this.forgetName(parentId, node.name); if (parentId != null) this.notifyChange(parentId); @@ -236,20 +314,22 @@ export class VirtualFS implements Catalog { /** Recursive delete without per-node notifications (parent remove notifies once). */ private async removeQuiet(id: number): Promise { const kids = await this.children(id); - for (const k of kids) await this.removeQuiet(k.id); + for (const k of kids) await this.removeQuiet(requireCnid(k).id); await this.db.delete('nodes', id); } - async children(parentId: number, onBatch?: ChildrenBatchListener, signal?: AbortSignal): Promise { + async children(parent: NodeRef, onBatch?: ChildrenBatchListener, signal?: AbortSignal): Promise { throwIfAborted(signal); + const parentId = requireCnidRef(parent); const all = await this.db.getAllFromIndex('nodes', 'parentId', parentId); const kids = all.map(revive); await onBatch?.(kids); return kids; } - async lookup(parentId: number, name: string, signal?: AbortSignal): Promise { + async lookup(parent: NodeRef, name: string, signal?: AbortSignal): Promise { throwIfAborted(signal); + const parentId = requireCnidRef(parent); const lower = name.toLowerCase(); if (this.nameCache) { await this.ensureParentNameCache(parentId); @@ -280,10 +360,12 @@ export class VirtualFS implements Catalog { this.nameCacheLoadedParents.add(parentId); } - async mkdir(parentId: number, name: string): Promise { + async mkdir(parent: NodeRef, name: string): Promise { + const parentId = requireCnidRef(parent); const existing = await this.lookup(parentId, name); if (existing) throw new Error('exists'); const node: VNode = { + addr: 'cnid', id: await this.allocId(), parentId, name, @@ -291,15 +373,16 @@ export class VirtualFS implements Catalog { data: new Uint8Array(), resource: new Uint8Array(), finderInfo: new Uint8Array(32), - createDate: macTime(), - modDate: macTime(), + createDate: Date.now(), + modDate: Date.now(), }; await this.put(node); return node; } /** Get or create a directory under parent (merge into existing dirs). */ - async ensureDir(parentId: number, name: string): Promise { + async ensureDir(parent: NodeRef, name: string): Promise { + const parentId = requireCnidRef(parent); const existing = await this.lookup(parentId, name); if (existing?.isDir) return existing; if (existing) throw new Error('exists'); @@ -307,7 +390,7 @@ export class VirtualFS implements Catalog { } async createFile( - parentId: number, + parent: NodeRef, name: string, data: Uint8Array, resource = new Uint8Array(), @@ -316,16 +399,18 @@ export class VirtualFS implements Catalog { signal?: AbortSignal, ): Promise { throwIfAborted(signal); + const parentId = requireCnidRef(parent); const existing = await this.lookup(parentId, name); if (existing) { existing.data = data; existing.resource = resource; existing.finderInfo = finderInfo; - existing.modDate = macTime(); + existing.modDate = Date.now(); await this.put(existing); return existing; } const node: VNode = { + addr: 'cnid', id: await this.allocId(), parentId, name, @@ -333,20 +418,21 @@ export class VirtualFS implements Catalog { data, resource, finderInfo, - createDate: macTime(), - modDate: macTime(), + createDate: Date.now(), + modDate: Date.now(), }; await this.put(node); return node; } async importBlob( - parentId: number, + parent: NodeRef, file: File, onBytes?: (n: number) => void, resource?: Uint8Array, signal?: AbortSignal, ): Promise { + const parentId = requireCnidRef(parent); const buf = await readBlobProgress(file, onBytes, signal); throwIfAborted(signal); // Host FS may store reserved Mac chars as ClassicStack "0xNN" tokens (Icon\r → Icon0x0D). @@ -360,7 +446,7 @@ export class VirtualFS implements Catalog { const leftover = (await this.lookup(parentId, name)) ?? (await this.lookup(parentId, file.name)); const node = await this.applyAppleDoubleSidecar(parentId, target, ad); - if (leftover && leftover.id !== node.id) await this.remove(leftover.id); + if (leftover && nodeRef(leftover) !== nodeRef(node)) await this.remove(nodeRef(leftover)); return node; } } @@ -385,7 +471,7 @@ export class VirtualFS implements Catalog { if (existing && !existing.isDir) { existing.data = buf; if (hostResource && existing.resource.length === 0) existing.resource = hostResource; - existing.modDate = macTime(); + existing.modDate = Date.now(); await this.put(existing); await this.consumeNamedSidecar(parentId, name, existing); return existing; @@ -407,7 +493,7 @@ export class VirtualFS implements Catalog { if (existing) { existing.resource = ad.resource; existing.finderInfo = ad.finderInfo; - existing.modDate = macTime(); + existing.modDate = Date.now(); await this.put(existing); return existing; } @@ -425,15 +511,15 @@ export class VirtualFS implements Catalog { if (ad) { into.resource = ad.resource; into.finderInfo = ad.finderInfo; - into.modDate = macTime(); + into.modDate = Date.now(); await this.put(into); } - await this.remove(sidecar.id); + await this.remove(nodeRef(sidecar)); } - async ensureContent(id: number, _onBytes?: (n: number) => void, signal?: AbortSignal): Promise { + async ensureContent(ref: NodeRef, _onBytes?: (n: number) => void, signal?: AbortSignal): Promise { throwIfAborted(signal); - return this.get(id); + return this.get(requireCnidRef(ref)); } async withRangeReader( @@ -442,7 +528,7 @@ export class VirtualFS implements Catalog { opts?: { resource?: boolean; signal?: AbortSignal }, ): Promise { throwIfAborted(opts?.signal); - const full = (await this.ensureContent(node.id, undefined, opts?.signal)) ?? node; + const full = (await this.ensureContent(nodeRef(node), undefined, opts?.signal)) ?? node; const bytes = opts?.resource ? full.resource : full.data; return fn(bufferRangeReader(bytes)); } @@ -476,32 +562,32 @@ export class VirtualFS implements Catalog { * + webkitRelativePath. Returns the number of top-level items imported. * Notifications are batched for the whole import. */ - async importDataTransfer(parentId: number, dt: DataTransfer, opts?: ImportProgress): Promise { + async importDataTransfer(parent: NodeRef, dt: DataTransfer, opts?: ImportProgress): Promise { + const parentId = requireCnidRef(parent); return importDataTransferInto(this, parentId, dt, (p, file, onBytes, resource, signal) => this.importBlob(p, file, onBytes, resource, signal), opts); } - async rename(id: number, newName: string): Promise { - const n = await this.get(id); - if (!n) throw new Error('not found'); + async rename(ref: NodeRef, newName: string): Promise { + const n = requireCnid(await this.requireNode(ref)); const oldName = n.name; n.name = newName; - n.modDate = macTime(); + n.modDate = Date.now(); if (oldName.toLowerCase() !== newName.toLowerCase()) { this.forgetName(n.parentId, oldName); } await this.put(n); } - async move(id: number, newParent: number): Promise { - const n = await this.get(id); - if (!n) throw new Error('not found'); + async move(ref: NodeRef, newParent: NodeRef): Promise { + const n = requireCnid(await this.requireNode(ref)); + const dest = requireCnidRef(newParent); const oldParent = n.parentId; - n.parentId = newParent; - n.modDate = macTime(); + n.parentId = dest; + n.modDate = Date.now(); await this.db.put('nodes', serialize(n)); this.forgetName(oldParent, n.name); - this.rememberName(newParent, n.name, n.id); - this.notifyChange(oldParent, newParent); + this.rememberName(dest, n.name, n.id); + this.notifyChange(oldParent, dest); } private async allocId(): Promise { @@ -525,10 +611,16 @@ function nameCacheKey(parentId: number, lowerName: string): string { return `${parentId}\0${lowerName}`; } -function serialize(n: VNode): Record { +function requireCnidRef(ref: NodeRef): number { + if (typeof ref === 'string') throw new Error('virtual-fs: cnid catalog requires numeric id'); + return ref; +} + +function serialize(n: CnidVNode): Record { // Store forks as binary (Uint8Array). IndexedDB structured-clone handles this // without the huge cost of Array.from → number[] → revive. return { + addr: 'cnid', id: n.id, parentId: n.parentId, name: n.name, @@ -539,6 +631,7 @@ function serialize(n: VNode): Record { createDate: n.createDate, modDate: n.modDate, ...(n.attributes ? { attributes: n.attributes } : {}), + ...(n.attrs ? { attrs: n.attrs } : {}), }; } @@ -569,8 +662,10 @@ function padUint8(src: Uint8Array, len: number): Uint8Array { return out; } -function revive(raw: Record): VNode { +function revive(raw: Record): CnidVNode { + const attrs = raw.attrs && typeof raw.attrs === 'object' ? (raw.attrs as Record) : undefined; return { + addr: 'cnid', id: raw.id as number, parentId: raw.parentId as number, name: raw.name as string, @@ -578,8 +673,9 @@ function revive(raw: Record): VNode { data: toUint8(raw.data), resource: toUint8(raw.resource), finderInfo: toUint8(raw.finderInfo, 32), - createDate: raw.createDate as number, - modDate: raw.modDate as number, + createDate: toUnixMs(Number(raw.createDate) || 0), + modDate: toUnixMs(Number(raw.modDate) || 0), ...(typeof raw.attributes === 'number' ? { attributes: raw.attributes } : {}), + ...(attrs ? { attrs } : {}), }; } diff --git a/src/fs/volume-chrome.ts b/src/fs/volume-chrome.ts new file mode 100644 index 0000000..94ceccb --- /dev/null +++ b/src/fs/volume-chrome.ts @@ -0,0 +1,73 @@ +/** Finder chrome from volume identity — decoration only, not catalog I/O. */ + +import type { ShareKind } from '../ui/finder-host'; +import type { CatalogCapabilities, PathFormat } from './catalog-caps'; + +export type VolumeChrome = { + volumeIcon: string; + kindLabel: string; + pathFormat: PathFormat; +}; + +const CHROME: Record = { + afp: { volumeIcon: 'appleshare', kindLabel: 'AppleShare', pathFormat: 'mac' }, + smb: { volumeIcon: 'windows', kindLabel: 'Windows share', pathFormat: 'dos' }, + ncp: { volumeIcon: 'novell', kindLabel: 'NetWare volume', pathFormat: 'ncp' }, + etherdfs: { volumeIcon: 'dos', kindLabel: 'DOS drive', pathFormat: 'dos' }, + local: { volumeIcon: 'disk', kindLabel: 'Local volume', pathFormat: 'posix' }, +}; + +const GENERIC: VolumeChrome = { volumeIcon: 'disk', kindLabel: 'Volume', pathFormat: 'posix' }; + +/** Chrome for a volume: local shares use protocol so local+smb still looks Windows. */ +export function volumeChrome(caps: CatalogCapabilities): VolumeChrome { + const kind: ShareKind | string = caps.identity.shareKind === 'local' + ? (caps.identity.protocol ?? 'local') + : caps.identity.shareKind; + const base = CHROME[kind] ?? CHROME[caps.identity.shareKind] ?? GENERIC; + return { + ...base, + pathFormat: caps.pathFormat || base.pathFormat, + kindLabel: caps.identity.shareKind === 'local' && caps.identity.protocol + ? `Local ${base.kindLabel}` + : base.kindLabel, + }; +} + +/** True when s is a ClassicStack client URI (`scheme://…`), not an NBP object name. */ +export function isClientURI(s: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(s); +} + +/** + * Join a discovered server with a volume per the client URI grammar: + * scheme://[user[:pass]@]server[,transport]/volume + * NBP names (in-browser AFP) stay `server:volume`. + */ +export function clientVolumeURI(server: string, volume = ''): string { + const vol = volume.trim(); + if (isClientURI(server)) { + const base = server.replace(/\/+$/, ''); + if (!vol) return base; + const suffix = '/' + vol; + if (base.toLowerCase().endsWith(suffix.toLowerCase())) return base; + return base + suffix; + } + if (!vol) return server; + return `${server}:${vol}`; +} + +/** Format a store-relative `'/'` path for the status bar / Get Info. */ +export function formatStorePath(path: string, fmt: PathFormat, volume = ''): string { + const parts = path.split('/').filter(Boolean); + switch (fmt) { + case 'mac': + return [volume || 'Volume', ...parts].join(':'); + case 'dos': + return (volume ? `${volume}\\` : '\\') + parts.join('\\'); + case 'ncp': + return `${volume || 'VOL'}:${parts.join('/')}`; + default: + return '/' + parts.join('/'); + } +} diff --git a/src/fs/welcome-pack.ts b/src/fs/welcome-pack.ts index 5d4e871..eb13bb1 100644 --- a/src/fs/welcome-pack.ts +++ b/src/fs/welcome-pack.ts @@ -7,6 +7,8 @@ import { AS_MAGIC, AD_MAGIC, buildAppleSingle } from './appledouble'; import { expandIncoming, isExpandableArchive, type ExpandedNode } from './expand-incoming'; import { importExpandedTree, type ImportItemTrack } from './import-transfer'; import type { Catalog } from './virtual-fs'; +import { nodeRef } from './virtual-fs'; +import type { NodeRef } from './catalog-caps'; import { log } from '../util/logger'; export const WELCOME_PACK_BASE = '/welcome'; @@ -38,10 +40,9 @@ export type WelcomePackProgress = { export type WelcomePackFs = Pick< Catalog, - 'rootId' | 'ensureDir' | 'beginBatch' | 'endBatch' | 'createFile' | 'put' | 'remove' + 'rootId' | 'ensureDir' | 'beginBatch' | 'endBatch' | 'createFile' | 'put' | 'remove' | 'lookup' > & { - lookup(parentId: number, name: string): Promise<{ id: number; isDir: boolean } | undefined>; - importBlob(parentId: number, file: File): Promise; + importBlob(parentId: NodeRef, file: File): Promise; }; export type WelcomePackStore = WelcomePackFs & { @@ -191,13 +192,13 @@ function tryExpandWelcome(name: string, data: Uint8Array): ExpandedNode[] | null /** Drop the wrapper once inner items are in the catalog (unless expand kept that name). */ async function discardWelcomeArchive( fs: WelcomePackFs, - parentId: number, + parentId: NodeRef, archiveName: string, expanded: ExpandedNode[], ): Promise { if (expanded.some((n) => namesMatch(n.name, archiveName))) return; const leftover = await fs.lookup(parentId, archiveName); - if (leftover && !leftover.isDir) await fs.remove(leftover.id); + if (leftover && !leftover.isDir) await fs.remove(nodeRef(leftover)); } function yieldForUi(): Promise { @@ -218,7 +219,7 @@ async function importOneWelcomeFile( let parentId = fs.rootId(); for (const dir of item.dirs) { try { - parentId = (await fs.ensureDir(parentId, dir)).id; + parentId = nodeRef(await fs.ensureDir(parentId, dir)); } catch { result.skipped++; return result; diff --git a/src/fs/winicon/bmp.test.ts b/src/fs/winicon/bmp.test.ts new file mode 100644 index 0000000..aa58777 --- /dev/null +++ b/src/fs/winicon/bmp.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { writeLe16, writeLe32 } from '../../protocol/binary'; +import { decodeBmp, sniffBmp } from './bmp'; + +function rgbBmp(width: number, height: number, r: number, g: number, b: number): Uint8Array { + const stride = (width * 3 + 3) & ~3; + const pixels = stride * height; + const off = 54; + const out = new Uint8Array(off + pixels); + out[0] = 0x42; + out[1] = 0x4d; + writeLe32(out, 2, out.length); + writeLe32(out, 10, off); + writeLe32(out, 14, 40); + writeLe32(out, 18, width); + writeLe32(out, 22, height); + writeLe16(out, 26, 1); + writeLe16(out, 28, 24); + writeLe32(out, 34, pixels); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const p = off + y * stride + x * 3; + out[p] = b; + out[p + 1] = g; + out[p + 2] = r; + } + } + return out; +} + +describe('BMP files', () => { + it('sniffs BITMAPFILEHEADER', () => { + expect(sniffBmp(rgbBmp(2, 2, 1, 2, 3))).toBe(true); + expect(sniffBmp(new Uint8Array([0x4d, 0x5a]))).toBe(false); + }); + + it('decodes a 24-bpp BMP to RGBA', () => { + const icon = decodeBmp(rgbBmp(2, 2, 255, 0, 128)); + expect(icon).not.toBeNull(); + expect(icon!.width).toBe(2); + expect(icon!.height).toBe(2); + expect(icon!.pixels[0]).toBe(255); + expect(icon!.pixels[1]).toBe(0); + expect(icon!.pixels[2]).toBe(128); + expect(icon!.pixels[3]).toBe(255); + }); +}); diff --git a/src/fs/winicon/bmp.ts b/src/fs/winicon/bmp.ts new file mode 100644 index 0000000..f223ebf --- /dev/null +++ b/src/fs/winicon/bmp.ts @@ -0,0 +1,167 @@ +/** + * Windows DIB (BITMAPINFOHEADER) used as the image payload inside ICO/CUR. + * Height in the header is XOR+AND (2× icon height) for icons. + */ + +import { le16, le32 } from '../../protocol/binary'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; + +const BI_RGB = 0; +const BI_BITFIELDS = 3; + +export function dibRowBytes(width: number, bitCount: number): number { + return ((width * bitCount + 31) >> 5) << 2; +} + +function paletteCount(bitCount: number, biClrUsed: number): number { + if (biClrUsed > 0) return biClrUsed; + if (bitCount === 1) return 2; + if (bitCount === 4) return 16; + if (bitCount === 8) return 256; + return 0; +} + +function sampleAnd(mask: Uint8Array, width: number, height: number, x: number, y: number): boolean { + const rowBytes = dibRowBytes(width, 1); + const row = (height - 1 - y) * rowBytes; + const byte = mask[row + (x >> 3)]; + if (byte == null) return false; + return (byte & (0x80 >> (x & 7))) !== 0; +} + +/** + * Decode a Windows DIB to RGBA. Icon DIBs (`icon: true`) store XOR then AND + * so BITMAPINFOHEADER height is 2×. RT_BITMAP uses the raw height. + */ +export function decodeIcoDib( + data: Uint8Array, + typeCode = 'ICO', + opts?: { icon?: boolean; maxDim?: number }, +): DecodedIcon | null { + if (data.length < 40) return null; + const icon = opts?.icon !== false; + const maxDim = opts?.maxDim ?? (icon ? 256 : 2048); + const biSize = le32(data, 0); + if (biSize < 40) return null; + const width = le32(data, 4) | 0; + const rawHeight = le32(data, 8) | 0; + if (width <= 0 || width > maxDim || rawHeight === 0) return null; + const absH = Math.abs(rawHeight); + const height = icon ? (absH === width * 2 || absH > width ? Math.floor(absH / 2) : absH) : absH; + if (height <= 0 || height > maxDim) return null; + const planes = le16(data, 12); + const bitCount = le16(data, 14); + const compression = le32(data, 16); + const clrUsed = le32(data, 32); + if (planes !== 1) return null; + if (compression !== BI_RGB && compression !== BI_BITFIELDS) return null; + if (![1, 4, 8, 24, 32].includes(bitCount)) return null; + + let off = biSize; + if (compression === BI_BITFIELDS) { + off += 12; + } + const nPal = paletteCount(bitCount, clrUsed); + const palette: [number, number, number][] = []; + for (let i = 0; i < nPal; i++) { + if (off + 4 > data.length) return null; + palette.push([data[off]!, data[off + 1]!, data[off + 2]!]); // B,G,R + off += 4; + } + + const xorStride = dibRowBytes(width, bitCount); + const xorLen = xorStride * height; + if (off + xorLen > data.length) return null; + const xorOff = off; + off += xorLen; + const andStride = dibRowBytes(width, 1); + const andLen = andStride * height; + const and = off + andLen <= data.length ? data.subarray(off, off + andLen) : new Uint8Array(andLen); + + const pixels = new Uint8ClampedArray(width * height * 4); + let hasXorAlpha = false; + + for (let y = 0; y < height; y++) { + const srcY = height - 1 - y; + const row = xorOff + srcY * xorStride; + for (let x = 0; x < width; x++) { + const dst = (y * width + x) * 4; + let r = 0; + let g = 0; + let b = 0; + let a = 255; + if (bitCount === 32) { + const p = row + x * 4; + b = data[p]!; + g = data[p + 1]!; + r = data[p + 2]!; + a = data[p + 3]!; + if (a) hasXorAlpha = true; + } else if (bitCount === 24) { + const p = row + x * 3; + b = data[p]!; + g = data[p + 1]!; + r = data[p + 2]!; + } else if (bitCount === 8) { + const idx = data[row + x]!; + const c = palette[idx] ?? [0, 0, 0]; + b = c[0]; + g = c[1]; + r = c[2]; + } else if (bitCount === 4) { + const byte = data[row + (x >> 1)]!; + const idx = x & 1 ? byte & 0xf : (byte >> 4) & 0xf; + const c = palette[idx] ?? [0, 0, 0]; + b = c[0]; + g = c[1]; + r = c[2]; + } else { + const byte = data[row + (x >> 3)]!; + const on = (byte & (0x80 >> (x & 7))) !== 0; + const c = palette[on ? 1 : 0] ?? (on ? [255, 255, 255] : [0, 0, 0]); + b = c[0]; + g = c[1]; + r = c[2]; + } + pixels[dst] = r; + pixels[dst + 1] = g; + pixels[dst + 2] = b; + pixels[dst + 3] = a; + } + } + + if (icon && (bitCount !== 32 || !hasXorAlpha)) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (sampleAnd(and, width, height, x, y)) { + pixels[(y * width + x) * 4 + 3] = 0; + } + } + } + } + + return { + typeCode, + isColor: bitCount > 1, + width, + height, + pixels, + }; +} + +const BMP_MAGIC = 0x4d42; // 'BM' + +export function sniffBmp(data: Uint8Array): boolean { + return data.length >= 14 && le16(data, 0) === BMP_MAGIC; +} + +/** + * Decode a Windows BMP file (BITMAPFILEHEADER + DIB). OS/2 COREHEADER + * and RLE-compressed files are not supported. + */ +export function decodeBmp(data: Uint8Array): DecodedIcon | null { + if (!sniffBmp(data)) return null; + const offBits = le32(data, 10); + if (offBits < 14 || offBits > data.length) return null; + return decodeIcoDib(data.subarray(14), 'BMP', { icon: false }); +} diff --git a/src/fs/winicon/decode-res.test.ts b/src/fs/winicon/decode-res.test.ts new file mode 100644 index 0000000..623da43 --- /dev/null +++ b/src/fs/winicon/decode-res.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { writeLe16, writeLe32 } from '../../protocol/binary'; +import { decodeStringTable, decodeVersionInfo } from './decode-res'; + +function utf16z(s: string): number[] { + const out: number[] = []; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + out.push(c & 0xff, (c >> 8) & 0xff); + } + out.push(0, 0); + return out; +} + +describe('Win resource decoders', () => { + it('reads FileVersion from VS_FIXEDFILEINFO', () => { + const hdr: number[] = [0, 0, 52, 0, 0, 0, ...utf16z('VS_VERSION_INFO')]; + while (hdr.length % 4) hdr.push(0); + const ffi = new Uint8Array(52); + writeLe32(ffi, 0, 0xfeef04bd); + writeLe32(ffi, 8, (1 << 16) | 2); + writeLe32(ffi, 12, (3 << 16) | 4); + writeLe32(ffi, 16, (5 << 16) | 6); + writeLe32(ffi, 20, (7 << 16) | 8); + const buf = new Uint8Array(hdr.length + ffi.length); + buf.set(hdr); + buf.set(ffi, hdr.length); + writeLe16(buf, 0, buf.length); + const fields = decodeVersionInfo(buf); + expect(fields.some((f) => f.key === 'FileVersion' && f.value === '1.2.3.4')).toBe(true); + expect(fields.some((f) => f.key === 'ProductVersion' && f.value === '5.6.7.8')).toBe(true); + }); + + it('lets StringFileInfo values replace the binary FileVersion', () => { + const hdr: number[] = [0, 0, 52, 0, 0, 0, ...utf16z('VS_VERSION_INFO')]; + while (hdr.length % 4) hdr.push(0); + const ffi = new Uint8Array(52); + writeLe32(ffi, 0, 0xfeef04bd); + writeLe32(ffi, 8, (1 << 16) | 0); + writeLe32(ffi, 12, 0); + const strKey = utf16z('FileVersion'); + const strVal = utf16z('1.00'); + const str: number[] = [0, 0, strVal.length / 2, 0, 1, 0, ...strKey]; + while (str.length % 4) str.push(0); + str.push(...strVal); + while (str.length % 4) str.push(0); + str[0] = str.length & 0xff; + str[1] = (str.length >> 8) & 0xff; + const buf = new Uint8Array(hdr.length + ffi.length + str.length); + buf.set(hdr); + buf.set(ffi, hdr.length); + buf.set(str, hdr.length + ffi.length); + writeLe16(buf, 0, buf.length); + const fields = decodeVersionInfo(buf); + expect(fields.find((f) => f.key === 'FileVersion')?.value).toBe('1.00'); + }); + + it('reads a string-table block', () => { + const bytes = new Uint8Array(2 + 5 * 2 + 15 * 2); + writeLe16(bytes, 0, 5); + const hello = 'Hello'; + for (let i = 0; i < hello.length; i++) writeLe16(bytes, 2 + i * 2, hello.charCodeAt(i)); + expect(decodeStringTable(bytes, 1)).toEqual([{ index: 0, text: 'Hello' }]); + }); +}); diff --git a/src/fs/winicon/decode-res.ts b/src/fs/winicon/decode-res.ts new file mode 100644 index 0000000..2428ab1 --- /dev/null +++ b/src/fs/winicon/decode-res.ts @@ -0,0 +1,197 @@ +import { le16, le32 } from '../../protocol/binary'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; +import { decodeIcoDib } from './bmp'; +import { assembleIcoFromGroup, decodeIco, wrapSingleIcoImage } from './ico'; +import { + RT_BITMAP, + RT_CURSOR, + RT_GROUP_CURSOR, + RT_GROUP_ICON, + RT_HTML, + RT_ICON, + RT_MANIFEST, + RT_RCDATA, + RT_STRING, + RT_VERSION, +} from './rt'; + +export type WinResPreview = + | { kind: 'icon'; icons: DecodedIcon[] } + | { kind: 'bitmap'; icon: DecodedIcon } + | { kind: 'version'; fields: { key: string; value: string }[] } + | { kind: 'strings'; lines: { index: number; text: string }[] } + | { kind: 'text'; text: string; encoding: string } + | { kind: 'hex' }; + +function u16le(b: Uint8Array, o: number): number { + return o + 2 <= b.length ? le16(b, o) : 0; +} + +function readUtf16z(b: Uint8Array, o: number): { s: string; next: number } { + let s = ''; + let p = o; + while (p + 1 < b.length) { + const c = le16(b, p); + p += 2; + if (c === 0) break; + s += String.fromCharCode(c); + } + return { s, next: p }; +} + +function align4(n: number): number { + return (n + 3) & ~3; +} + +function formatVersionQuad(ms: number, ls: number): string { + return `${(ms >>> 16) & 0xffff}.${ms & 0xffff}.${(ls >>> 16) & 0xffff}.${ls & 0xffff}`; +} + +/** VS_VERSION_INFO tree (StringFileInfo / VarFileInfo). */ +export function decodeVersionInfo(bytes: Uint8Array): { key: string; value: string }[] { + const map = new Map(); + if (bytes.length < 6) return []; + + const put = (key: string, value: string) => { + const v = value.trim(); + if (key && v) map.set(key, v); + }; + + const walk = (off: number, end: number, depth: number): void => { + if (depth > 12 || off + 6 > end) return; + const wLength = u16le(bytes, off); + const wValueLength = u16le(bytes, off + 2); + const wType = u16le(bytes, off + 4); + if (wLength < 6 || off + wLength > bytes.length) return; + const { s: key, next } = readUtf16z(bytes, off + 6); + let p = align4(next); + const nodeEnd = Math.min(off + wLength, end); + + if (wValueLength > 0 && p < nodeEnd) { + if (wType === 1) { + const { s } = readUtf16z(bytes, p); + put(key, s); + p = align4(p + wValueLength * 2); + } else if (key === 'VS_VERSION_INFO' && wValueLength >= 52 && p + 52 <= bytes.length) { + put('FileVersion', formatVersionQuad(le32(bytes, p + 8), le32(bytes, p + 12))); + put('ProductVersion', formatVersionQuad(le32(bytes, p + 16), le32(bytes, p + 20))); + p = align4(p + wValueLength); + } else { + p = align4(p + wValueLength); + } + } + while (p + 6 <= nodeEnd) { + const childLen = u16le(bytes, p); + if (childLen < 6) break; + walk(p, Math.min(p + childLen, nodeEnd), depth + 1); + p = align4(p + childLen); + } + }; + + walk(0, bytes.length, 0); + return [...map].map(([key, value]) => ({ key, value })); +} + +/** RT_STRING: 16 Pascal UTF-16 strings per block. */ +export function decodeStringTable(bytes: Uint8Array, blockId: number): { index: number; text: string }[] { + const lines: { index: number; text: string }[] = []; + let p = 0; + for (let i = 0; i < 16 && p + 2 <= bytes.length; i++) { + const n = le16(bytes, p); + p += 2; + let text = ''; + for (let j = 0; j < n && p + 1 < bytes.length; j++) { + text += String.fromCharCode(le16(bytes, p)); + p += 2; + } + if (text) lines.push({ index: (blockId - 1) * 16 + i, text }); + } + return lines; +} + +function looksUtf16Le(b: Uint8Array): boolean { + if (b.length >= 2 && b[0] === 0xff && b[1] === 0xfe) return true; + if (b.length < 8) return false; + let zeros = 0; + for (let i = 1; i < Math.min(64, b.length); i += 2) if (b[i] === 0) zeros++; + return zeros >= 8; +} + +function decodeTextish(bytes: Uint8Array): { text: string; encoding: string } | null { + if (!bytes.length) return null; + if (looksUtf16Le(bytes)) { + const start = bytes[0] === 0xff && bytes[1] === 0xfe ? 2 : 0; + const n = Math.min(32_000, Math.floor((bytes.length - start) / 2)); + let text = ''; + for (let i = 0; i < n; i++) text += String.fromCharCode(le16(bytes, start + i * 2)); + return { text, encoding: 'UTF-16LE' }; + } + try { + const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes.subarray(0, 64_000)); + const printable = text.replace(/[\t\n\r]/g, ''); + let bad = 0; + for (let i = 0; i < Math.min(printable.length, 400); i++) { + const c = printable.charCodeAt(i); + if (c < 32 && c !== 9) bad++; + } + if (bad > 8) return null; + return { text, encoding: 'UTF-8' }; + } catch { + return null; + } +} + +export async function previewWinResource(opts: { + typeId: number | null; + id: number | null; + bytes: Uint8Array; + iconBlobs: Map; + cursorBlobs: Map; +}): Promise { + const { typeId, id, bytes, iconBlobs, cursorBlobs } = opts; + if (typeId === RT_GROUP_ICON) { + const ico = assembleIcoFromGroup(bytes, iconBlobs); + if (ico) { + const icons = await decodeIco(ico); + if (icons.length) return { kind: 'icon', icons }; + } + } + if (typeId === RT_GROUP_CURSOR) { + const ico = assembleIcoFromGroup(bytes, cursorBlobs); + if (ico) { + const icons = await decodeIco(ico); + if (icons.length) return { kind: 'icon', icons }; + } + } + if (typeId === RT_ICON) { + if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50) { + const icons = await decodeIco(wrapSingleIcoImage(bytes)); + if (icons.length) return { kind: 'icon', icons }; + } + const dib = decodeIcoDib(bytes, 'ICO'); + if (dib) return { kind: 'icon', icons: [dib] }; + } + if (typeId === RT_CURSOR) { + const dib = decodeIcoDib(bytes.length >= 4 ? bytes.subarray(4) : bytes, 'CUR'); + if (dib) return { kind: 'icon', icons: [dib] }; + } + if (typeId === RT_BITMAP) { + const bmp = decodeIcoDib(bytes, 'BMP', { icon: false }); + if (bmp) return { kind: 'bitmap', icon: bmp }; + } + if (typeId === RT_VERSION) { + const fields = decodeVersionInfo(bytes); + if (fields.length) return { kind: 'version', fields }; + } + if (typeId === RT_STRING && id != null) { + const lines = decodeStringTable(bytes, id); + if (lines.length) return { kind: 'strings', lines }; + } + if (typeId === RT_MANIFEST || typeId === RT_HTML || typeId === RT_RCDATA) { + const t = decodeTextish(bytes); + if (t) return { kind: 'text', ...t }; + } + const t = decodeTextish(bytes); + if (t && t.text.length > 8 && !t.text.includes('\0')) return { kind: 'text', ...t }; + return { kind: 'hex' }; +} diff --git a/src/fs/winicon/exe-fixtures.ts b/src/fs/winicon/exe-fixtures.ts new file mode 100644 index 0000000..abdd7d5 --- /dev/null +++ b/src/fs/winicon/exe-fixtures.ts @@ -0,0 +1,166 @@ +/** Test-only PE/NE builders wrapping an ICO in a resource section. */ + +import { le16, le32, writeLe16, writeLe32 } from '../../protocol/binary'; + +function align(n: number, a: number): number { + return (n + a - 1) & ~(a - 1); +} + +function icoImages(ico: Uint8Array): Uint8Array[] { + const count = le16(ico, 4); + const images: Uint8Array[] = []; + for (let i = 0; i < count; i++) { + const o = 6 + i * 16; + images.push(ico.subarray(le32(ico, o + 12), le32(ico, o + 12) + le32(ico, o + 8))); + } + return images; +} + +function icoGroup(ico: Uint8Array): Uint8Array { + const count = le16(ico, 4); + const group = new Uint8Array(6 + count * 14); + writeLe16(group, 2, 1); + writeLe16(group, 4, count); + for (let i = 0; i < count; i++) { + group.set(ico.subarray(6 + i * 16, 6 + i * 16 + 12), 6 + i * 14); + writeLe16(group, 6 + i * 14 + 12, i + 1); + } + return group; +} + +/** Minimal PE32 with .rsrc holding RT_GROUP_ICON + RT_ICON from an ICO. */ +export function buildPeWithIco(ico: Uint8Array): Uint8Array { + const images = icoImages(ico); + const group = icoGroup(ico); + const n = images.length; + let o = 0; + const root = o; + o += 32; + const typeIcon = o; + o += 16 + 8 * n; + const typeGroup = o; + o += 24; + const nameIcons: number[] = []; + for (let i = 0; i < n; i++) { + nameIcons.push(o); + o += 24; + } + const nameGroup = o; + o += 24; + const dataIcons: number[] = []; + for (let i = 0; i < n; i++) { + dataIcons.push(o); + o += 16; + } + const dataGroup = o; + o += 16; + const payIcons: number[] = []; + for (const im of images) { + payIcons.push(o); + o += im.length; + } + const payGroup = o; + o += group.length; + const rsrc = new Uint8Array(o); + const RES_RVA = 0x1000; + const DIR = 0x80000000; + const writeDir = (off: number, nId: number) => writeLe16(rsrc, off + 14, nId); + const writeId = (off: number, idx: number, id: number, child: number, isDir: boolean) => { + const p = off + 16 + idx * 8; + writeLe32(rsrc, p, id); + writeLe32(rsrc, p + 4, child | (isDir ? DIR : 0)); + }; + + writeDir(root, 2); + writeId(root, 0, 3, typeIcon, true); + writeId(root, 1, 14, typeGroup, true); + writeDir(typeIcon, n); + for (let i = 0; i < n; i++) writeId(typeIcon, i, i + 1, nameIcons[i]!, true); + writeDir(typeGroup, 1); + writeId(typeGroup, 0, 1, nameGroup, true); + for (let i = 0; i < n; i++) { + writeDir(nameIcons[i]!, 1); + writeId(nameIcons[i]!, 0, 0x0409, dataIcons[i]!, false); + writeLe32(rsrc, dataIcons[i]!, RES_RVA + payIcons[i]!); + writeLe32(rsrc, dataIcons[i]! + 4, images[i]!.length); + rsrc.set(images[i]!, payIcons[i]!); + } + writeDir(nameGroup, 1); + writeId(nameGroup, 0, 0x0409, dataGroup, false); + writeLe32(rsrc, dataGroup, RES_RVA + payGroup); + writeLe32(rsrc, dataGroup + 4, group.length); + rsrc.set(group, payGroup); + + const rawOff = 0x200; + const rawSize = align(rsrc.length, 0x200); + const file = new Uint8Array(rawOff + rawSize); + writeLe16(file, 0, 0x5a4d); + writeLe32(file, 0x3c, 0x40); + writeLe32(file, 0x40, 0x4550); + writeLe16(file, 0x44, 0x14c); + writeLe16(file, 0x46, 1); + writeLe16(file, 0x54, 224); + writeLe16(file, 0x56, 0x0102); + writeLe16(file, 0x58, 0x10b); + writeLe32(file, 0x58 + 32, 0x1000); + writeLe32(file, 0x58 + 36, 0x200); + writeLe32(file, 0x58 + 56, 0x2000); + writeLe32(file, 0x58 + 60, 0x200); + writeLe16(file, 0x58 + 68, 2); + writeLe32(file, 0x58 + 92, 16); + writeLe32(file, 0x58 + 96 + 16, RES_RVA); + writeLe32(file, 0x58 + 96 + 20, rsrc.length); + const sec = 0x58 + 224; + file.set([0x2e, 0x72, 0x73, 0x72, 0x63], sec); + writeLe32(file, sec + 8, rsrc.length); + writeLe32(file, sec + 12, RES_RVA); + writeLe32(file, sec + 16, rawSize); + writeLe32(file, sec + 20, rawOff); + file.set(rsrc, rawOff); + return file; +} + +/** Minimal NE with a resource table holding group + icon images from an ICO. */ +export function buildNeWithIco(ico: Uint8Array): Uint8Array { + const images = icoImages(ico); + const group = icoGroup(ico); + const shift = 1; + const unit = 1 << shift; + const tableOff = 0x80; + const tableSize = 2 + 8 + 12 + 8 + 12 * images.length + 2; + let dataOff = Math.ceil((tableOff + tableSize) / unit) * unit; + const payloads: { off: number; bytes: Uint8Array; type: number; id: number }[] = []; + const place = (bytes: Uint8Array, type: number, id: number) => { + const off = dataOff; + payloads.push({ off, bytes, type, id }); + dataOff += Math.ceil(bytes.length / unit) * unit; + }; + place(group, 14, 1); + for (let i = 0; i < images.length; i++) place(images[i]!, 3, i + 1); + + const file = new Uint8Array(dataOff); + writeLe16(file, 0, 0x5a4d); + writeLe32(file, 0x3c, 0x40); + writeLe16(file, 0x40, 0x454e); + writeLe16(file, 0x40 + 0x24, 0x40); + let p = tableOff; + writeLe16(file, p, shift); + p += 2; + const writeType = (type: number, items: { off: number; bytes: Uint8Array; id: number }[]) => { + writeLe16(file, p, type | 0x8000); + writeLe16(file, p + 2, items.length); + p += 8; + for (const it of items) { + writeLe16(file, p, it.off / unit); + writeLe16(file, p + 2, Math.ceil(it.bytes.length / unit)); + writeLe16(file, p + 4, 0x1c30); + writeLe16(file, p + 6, it.id | 0x8000); + p += 12; + } + }; + writeType(14, payloads.filter((x) => x.type === 14)); + writeType(3, payloads.filter((x) => x.type === 3)); + writeLe16(file, p, 0); + for (const it of payloads) file.set(it.bytes, it.off); + return file; +} diff --git a/src/fs/winicon/extract.test.ts b/src/fs/winicon/extract.test.ts new file mode 100644 index 0000000..79ec2e2 --- /dev/null +++ b/src/fs/winicon/extract.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { bufferRangeReader } from '../byte-range'; +import { encodeIco, extractWinIcons, inspectWinResources, isWinExeName, isWinIconName, isWinVersionName, sniffWinIcon } from './index'; +import { buildNeWithIco, buildPeWithIco } from './exe-fixtures'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; + +function solid(width: number, r: number, g: number, b: number): DecodedIcon { + const pixels = new Uint8ClampedArray(width * width * 4); + for (let i = 0; i < width * width; i++) { + pixels[i * 4] = r; + pixels[i * 4 + 1] = g; + pixels[i * 4 + 2] = b; + pixels[i * 4 + 3] = 255; + } + return { typeCode: 'ICO', isColor: true, width, height: width, pixels }; +} + +describe('extractWinIcons', () => { + it('matches Windows icon / executable names', () => { + expect(isWinIconName('NOTEPAD.EXE')).toBe(true); + expect(isWinIconName('ssstars.scr')).toBe(true); + expect(isWinIconName('app.ico')).toBe(true); + expect(isWinIconName('shell32.dll')).toBe(false); + expect(isWinIconName('README.TXT')).toBe(false); + expect(isWinExeName('NOTEPAD.EXE')).toBe(true); + expect(isWinExeName('shell32.dll')).toBe(true); + expect(isWinExeName('app.ico')).toBe(false); + expect(isWinVersionName('NOTEPAD.EXE')).toBe(true); + expect(isWinVersionName('app.ico')).toBe(false); + }); + + it('sniffs ICO vs PE vs NE', () => { + const ico = encodeIco([solid(16, 1, 2, 3)]); + expect(sniffWinIcon(ico)).toBe('ico'); + expect(sniffWinIcon(buildPeWithIco(ico))).toBe('pe'); + expect(sniffWinIcon(buildNeWithIco(ico).subarray(0, 80))).toBe('ne'); + }); + + it('decodes a standalone ICO through the range reader', async () => { + const ico = encodeIco([solid(16, 9, 8, 7)]); + const icons = await extractWinIcons(bufferRangeReader(ico)); + expect(icons[0]!.pixels[0]).toBe(9); + }); + + it('decodes icons from a PE .exe', async () => { + const ico = encodeIco([solid(16, 11, 0, 0)]); + const icons = await extractWinIcons(bufferRangeReader(buildPeWithIco(ico))); + expect(icons.some((i) => i.pixels[0] === 11)).toBe(true); + }); + + it('decodes icons from an NE .exe', async () => { + const ico = encodeIco([solid(16, 0, 12, 0)]); + const icons = await extractWinIcons(bufferRangeReader(buildNeWithIco(ico))); + expect(icons.some((i) => i.pixels[1] === 12)).toBe(true); + }); + + it('enumerates RT_GROUP_ICON and RT_ICON in a PE image', async () => { + const ico = encodeIco([solid(16, 1, 0, 0)]); + const table = await inspectWinResources(bufferRangeReader(buildPeWithIco(ico))); + expect(table.kind).toBe('pe'); + expect(table.types.map((t) => t.code).sort()).toEqual(['RT_GROUP_ICON', 'RT_ICON']); + expect(table.entries).toHaveLength(2); + }); + + it('enumerates RT_GROUP_ICON and RT_ICON in an NE image', async () => { + const ico = encodeIco([solid(16, 0, 1, 0)]); + const table = await inspectWinResources(bufferRangeReader(buildNeWithIco(ico))); + expect(table.kind).toBe('ne'); + expect(table.types.map((t) => t.code).sort()).toEqual(['RT_GROUP_ICON', 'RT_ICON']); + }); + + it('lists frames of a standalone ICO', async () => { + const ico = encodeIco([solid(16, 9, 8, 7), solid(32, 1, 2, 3)]); + const table = await inspectWinResources(bufferRangeReader(ico)); + expect(table.kind).toBe('ico'); + expect(table.entries).toHaveLength(2); + expect(table.entries[0]!.name).toBe('16×16'); + }); +}); diff --git a/src/fs/winicon/extract.ts b/src/fs/winicon/extract.ts new file mode 100644 index 0000000..c002089 --- /dev/null +++ b/src/fs/winicon/extract.ts @@ -0,0 +1,103 @@ +/** + * Windows icon extraction: standalone .ico and icons embedded in PE/NE .exe/.scr. + */ + +import type { ByteRangeReader } from '../byte-range'; +import { bufferRangeReader } from '../byte-range'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; +import { decodeIcoFromReader, sniffIcoHeader } from './ico'; +import { extractNeIcons } from './ne'; +import { extractPeIcons } from './pe'; +import { readExact } from './read'; +import { le16, le32 } from '../../protocol/binary'; + +const WIN_ICON_EXT = /\.(exe|scr|cpl|ocx|ico|icl|cur)$/i; +const WIN_EXE_EXT = /\.(exe|scr|dll|cpl|ocx|icl)$/i; +const WIN_VERSION_EXT = /\.(exe|dll|scr|ocx)$/i; +const MZ = 0x5a4d; +const PE_SIG = 0x4550; +const NE_SIG = 0x454e; + +export type WinIconKind = 'ico' | 'pe' | 'ne'; + +function winBaseName(name: string): string { + return name.split(/[/\\]/).pop() ?? name; +} + +/** True when the filename is a Windows icon or executable that may contain one. */ +export function isWinIconName(name: string): boolean { + return WIN_ICON_EXT.test(winBaseName(name)); +} + +/** True when the filename is a PE/NE module (not a standalone .ico/.cur). */ +export function isWinExeName(name: string): boolean { + return WIN_EXE_EXT.test(winBaseName(name)); +} + +/** True when Get Info should look for RT_VERSION / VERSIONINFO. */ +export function isWinVersionName(name: string): boolean { + return WIN_VERSION_EXT.test(winBaseName(name)); +} + +/** True when the Windows resource explorer applies (PE/NE/ICO, including .dll). */ +export function isWinResourceName(name: string): boolean { + return isWinIconName(name) || isWinExeName(name); +} + +export function sniffWinIcon(header: Uint8Array): WinIconKind | null { + if (sniffIcoHeader(header)) return 'ico'; + if (header.length >= 2 && le16(header, 0) === MZ) { + if (header.length < 64) return 'pe'; + const lfanew = le32(header, 0x3c); + if (lfanew + 4 <= header.length) { + const sig = le16(header, lfanew); + if (sig === NE_SIG) return 'ne'; + if (le32(header, lfanew) === PE_SIG) return 'pe'; + } + return 'pe'; + } + return null; +} + +/** Closest frame to `target` px (prefer exact, then smallest ≥ target, then largest). */ +export function pickIconNear(icons: DecodedIcon[], target: number): DecodedIcon | undefined { + if (!icons.length) return undefined; + const score = (i: DecodedIcon): number => { + const d = Math.max(i.width, i.height); + const depth = i.isColor ? 1 : 0; + if (d === target) return 1000 + depth; + if (d > target) return 500 - (d - target) + depth; + return d + depth; + }; + return [...icons].sort((a, b) => score(b) - score(a))[0]; +} + +export async function extractWinIcons(read: ByteRangeReader): Promise { + const header = await readExact(read, 0, 64); + if (!header) return []; + const kind = sniffWinIcon(header); + if (kind === 'ico') return decodeIcoFromReader(read); + if (kind === 'ne') { + const lfanew = le32(header, 0x3c); + const ne = await readExact(read, lfanew, 2); + if (ne && le16(ne, 0) === NE_SIG) return extractNeIcons(read); + return extractPeIcons(read); + } + if (kind === 'pe') { + const lfanew = header.length >= 64 ? le32(header, 0x3c) : 0; + if (lfanew) { + const sig = await readExact(read, lfanew, 4); + if (sig && le16(sig, 0) === NE_SIG) return extractNeIcons(read); + } + return extractPeIcons(read); + } + return []; +} + +export async function extractWinIconsFromBuffer(data: Uint8Array): Promise { + return extractWinIcons(bufferRangeReader(data)); +} + +export { decodeIco, decodeIcoFromReader, encodeIco } from './ico'; +export { extractPeIcons, extractPeIconsFromBuffer } from './pe'; +export { extractNeIcons, extractNeIconsFromBuffer } from './ne'; diff --git a/src/fs/winicon/ico.test.ts b/src/fs/winicon/ico.test.ts new file mode 100644 index 0000000..23d6ffc --- /dev/null +++ b/src/fs/winicon/ico.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { encodeIco, decodeIco, sniffIcoHeader, pickIconNear } from './index'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; + +function solid(width: number, height: number, r: number, g: number, b: number, a = 255): DecodedIcon { + const pixels = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < width * height; i++) { + pixels[i * 4] = r; + pixels[i * 4 + 1] = g; + pixels[i * 4 + 2] = b; + pixels[i * 4 + 3] = a; + } + return { typeCode: 'ICO', isColor: true, width, height, pixels }; +} + +describe('ICO', () => { + it('sniffs ICONDIR', () => { + const ico = encodeIco([solid(16, 16, 255, 0, 0)]); + expect(sniffIcoHeader(ico)).toBe('ico'); + expect(sniffIcoHeader(new Uint8Array([0x4d, 0x5a]))).toBeNull(); + }); + + it('round-trips 16 and 32 px 32-bpp frames', async () => { + const src = [solid(16, 16, 255, 0, 0), solid(32, 32, 0, 0, 255)]; + const ico = encodeIco(src); + const got = await decodeIco(ico); + expect(got).toHaveLength(2); + const small = got.find((i) => i.width === 16)!; + const large = got.find((i) => i.width === 32)!; + expect(small.pixels[0]).toBe(255); + expect(small.pixels[1]).toBe(0); + expect(small.pixels[2]).toBe(0); + expect(small.pixels[3]).toBe(255); + expect(large.pixels[0]).toBe(0); + expect(large.pixels[2]).toBe(255); + }); + + it('applies the AND mask when 32-bpp alpha is zero', async () => { + const src = solid(16, 16, 0, 255, 0, 0); + const ico = encodeIco([src]); + // AND mask at end of DIB: set pixel 0 transparent already (alpha 0). + const got = await decodeIco(ico); + expect(got[0]!.pixels[3]).toBe(0); + }); + + it('picks the nearest size', () => { + const icons = [solid(16, 16, 1, 0, 0), solid(48, 48, 2, 0, 0), solid(32, 32, 3, 0, 0)]; + expect(pickIconNear(icons, 16)?.width).toBe(16); + expect(pickIconNear(icons, 32)?.width).toBe(32); + expect(pickIconNear(icons, 24)?.width).toBe(32); + }); +}); diff --git a/src/fs/winicon/ico.ts b/src/fs/winicon/ico.ts new file mode 100644 index 0000000..6966be2 --- /dev/null +++ b/src/fs/winicon/ico.ts @@ -0,0 +1,253 @@ +/** + * Windows ICO / CUR decoder (ICONDIR + BMP DIB or PNG images). + * PNG frames need createImageBitmap (skipped in environments without it). + */ + +import { le16, le32, writeLe16, writeLe32 } from '../../protocol/binary'; +import type { ByteRangeReader } from '../byte-range'; +import { bufferRangeReader } from '../byte-range'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; +import { decodeIcoDib, dibRowBytes } from './bmp'; +import { readExact, readSlice } from './read'; + +const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const MAX_IMAGES = 64; +const MAX_IMAGE = 2 * 1024 * 1024; + +export type IcoKind = 'ico' | 'cur'; + +export function sniffIcoHeader(data: Uint8Array): IcoKind | null { + if (data.length < 6) return null; + if (le16(data, 0) !== 0) return null; + const type = le16(data, 2); + const count = le16(data, 4); + if (count === 0 || count > MAX_IMAGES) return null; + if (type === 1) return 'ico'; + if (type === 2) return 'cur'; + return null; +} + +function isPng(data: Uint8Array): boolean { + if (data.length < 8) return false; + for (let i = 0; i < 8; i++) if (data[i] !== PNG_SIG[i]) return false; + return true; +} + +async function decodePngIcon(data: Uint8Array, typeCode: string): Promise { + if (typeof createImageBitmap !== 'function' || typeof OffscreenCanvas === 'undefined') return null; + try { + const blob = new Blob([data], { type: 'image/png' }); + const bmp = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(bmp.width, bmp.height); + const ctx = canvas.getContext('2d'); + if (!ctx) { + bmp.close(); + return null; + } + ctx.drawImage(bmp, 0, 0); + const img = ctx.getImageData(0, 0, bmp.width, bmp.height); + bmp.close(); + return { + typeCode, + isColor: true, + width: img.width, + height: img.height, + pixels: img.data, + }; + } catch { + return null; + } +} + +type IcoEntry = { width: number; height: number; bytesInRes: number; imageOffset: number }; + +function parseEntries(dir: Uint8Array, count: number): IcoEntry[] | null { + const entries: IcoEntry[] = []; + for (let i = 0; i < count; i++) { + const o = 6 + i * 16; + if (o + 16 > dir.length) return null; + const w = dir[o] || 256; + const h = dir[o + 1] || 256; + const bytesInRes = le32(dir, o + 8); + const imageOffset = le32(dir, o + 12); + if (bytesInRes <= 0 || bytesInRes > MAX_IMAGE) return null; + entries.push({ width: w, height: h, bytesInRes, imageOffset }); + } + return entries; +} + +/** Decode a whole ICO/CUR buffer. */ +export async function decodeIco(data: Uint8Array): Promise { + return decodeIcoFromReader(bufferRangeReader(data)); +} + +export type IcoFrame = { + index: number; + width: number; + height: number; + bytes: Uint8Array; +}; + +/** Directory + raw image payloads for each ICO/CUR frame. */ +export async function enumerateIcoFrames( + read: ByteRangeReader, +): Promise<{ kind: IcoKind; frames: IcoFrame[] } | null> { + const header = await readExact(read, 0, 6); + if (!header) return null; + const kind = sniffIcoHeader(header); + if (!kind) return null; + const count = le16(header, 4); + const dir = await readExact(read, 0, 6 + count * 16); + if (!dir) return null; + const entries = parseEntries(dir, count); + if (!entries) return null; + const frames: IcoFrame[] = []; + for (let i = 0; i < entries.length; i++) { + const e = entries[i]!; + const bytes = await readSlice(read, e.imageOffset, e.bytesInRes); + if (!bytes.length) continue; + frames.push({ index: i, width: e.width, height: e.height, bytes }); + } + return { kind, frames }; +} + +/** Decode ICO/CUR via ranged reads (directory then each image). */ +export async function decodeIcoFromReader(read: ByteRangeReader): Promise { + const header = await readExact(read, 0, 6); + if (!header) return []; + const kind = sniffIcoHeader(header); + if (!kind) return []; + const count = le16(header, 4); + const dir = await readExact(read, 0, 6 + count * 16); + if (!dir) return []; + const entries = parseEntries(dir, count); + if (!entries) return []; + const typeCode = kind === 'cur' ? 'CUR' : 'ICO'; + const icons: DecodedIcon[] = []; + for (const e of entries) { + const raw = await readSlice(read, e.imageOffset, e.bytesInRes); + if (raw.length < 16) continue; + if (isPng(raw)) { + const png = await decodePngIcon(raw, typeCode); + if (png) icons.push(png); + continue; + } + const dib = decodeIcoDib(raw, typeCode); + if (dib) icons.push(dib); + } + return icons; +} + +/** Build a 32-bpp ICO from top-down RGBA frames (tests + PE/NE round-trip). */ +export function encodeIco(frames: DecodedIcon[]): Uint8Array { + const n = frames.length; + const dirSize = 6 + n * 16; + const images: Uint8Array[] = frames.map(encodeIcoDib32); + let off = dirSize; + const out = new Uint8Array(dirSize + images.reduce((s, im) => s + im.length, 0)); + writeLe16(out, 0, 0); + writeLe16(out, 2, 1); + writeLe16(out, 4, n); + for (let i = 0; i < n; i++) { + const fr = frames[i]!; + const im = images[i]!; + const e = 6 + i * 16; + out[e] = fr.width >= 256 ? 0 : fr.width; + out[e + 1] = fr.height >= 256 ? 0 : fr.height; + out[e + 2] = 0; + out[e + 3] = 0; + writeLe16(out, e + 4, 1); + writeLe16(out, e + 6, 32); + writeLe32(out, e + 8, im.length); + writeLe32(out, e + 12, off); + out.set(im, off); + off += im.length; + } + return out; +} + +function encodeIcoDib32(icon: DecodedIcon): Uint8Array { + const w = icon.width; + const h = icon.height; + const xorStride = dibRowBytes(w, 32); + const andStride = dibRowBytes(w, 1); + const xorLen = xorStride * h; + const andLen = andStride * h; + const out = new Uint8Array(40 + xorLen + andLen); + writeLe32(out, 0, 40); + writeLe32(out, 4, w); + writeLe32(out, 8, h * 2); + writeLe16(out, 12, 1); + writeLe16(out, 14, 32); + const xorOff = 40; + for (let y = 0; y < h; y++) { + const srcY = h - 1 - y; + const row = xorOff + y * xorStride; + for (let x = 0; x < w; x++) { + const s = (srcY * w + x) * 4; + const d = row + x * 4; + out[d] = icon.pixels[s + 2]!; + out[d + 1] = icon.pixels[s + 1]!; + out[d + 2] = icon.pixels[s]!; + out[d + 3] = icon.pixels[s + 3]!; + } + } + return out; +} + +/** ICONDIR wrapping a single PNG or DIB image (PE RT_ICON PNG frames). */ +export function wrapSingleIcoImage(image: Uint8Array, type = 1): Uint8Array { + const out = new Uint8Array(22 + image.length); + writeLe16(out, 2, type === 2 ? 2 : 1); + writeLe16(out, 4, 1); + out[6] = 32; + out[7] = 32; + writeLe16(out, 10, 1); + writeLe16(out, 12, 32); + writeLe32(out, 14, image.length); + writeLe32(out, 18, 22); + out.set(image, 22); + return out; +} + +/** GRPICONDIR (PE/NE RT_GROUP_ICON) → ICONDIR with image payloads from RT_ICON ids. */ +export function assembleIcoFromGroup( + group: Uint8Array, + iconsById: Map, +): Uint8Array | null { + if (group.length < 6) return null; + if (le16(group, 0) !== 0) return null; + const type = le16(group, 2); + if (type !== 1 && type !== 2) return null; + const count = le16(group, 4); + if (count === 0 || count > MAX_IMAGES) return null; + const entries: { dir: Uint8Array; data: Uint8Array }[] = []; + let payload = 0; + for (let i = 0; i < count; i++) { + const o = 6 + i * 14; + if (o + 14 > group.length) return null; + const id = le16(group, o + 12); + const data = iconsById.get(id); + if (!data?.length) continue; + const ent = new Uint8Array(16); + ent.set(group.subarray(o, o + 12)); + writeLe32(ent, 8, data.length); + entries.push({ dir: ent, data }); + payload += data.length; + } + if (!entries.length) return null; + const dirSize = 6 + entries.length * 16; + const out = new Uint8Array(dirSize + payload); + writeLe16(out, 0, 0); + writeLe16(out, 2, type); + writeLe16(out, 4, entries.length); + let off = 6 + entries.length * 16; + for (let i = 0; i < entries.length; i++) { + const e = entries[i]!; + writeLe32(e.dir, 12, off); + out.set(e.dir, 6 + i * 16); + out.set(e.data, off); + off += e.data.length; + } + return out; +} diff --git a/src/fs/winicon/index.ts b/src/fs/winicon/index.ts new file mode 100644 index 0000000..61d4802 --- /dev/null +++ b/src/fs/winicon/index.ts @@ -0,0 +1,50 @@ +/** Windows ICO decoding and PE/NE executable icon extraction. */ + +export { + decodeIco, + decodeIcoFromReader, + encodeIco, + extractNeIcons, + extractNeIconsFromBuffer, + extractPeIcons, + extractPeIconsFromBuffer, + extractWinIcons, + extractWinIconsFromBuffer, + isWinExeName, + isWinIconName, + isWinResourceName, + isWinVersionName, + pickIconNear, + sniffWinIcon, + type WinIconKind, +} from './extract'; +export { sniffIcoHeader, enumerateIcoFrames } from './ico'; +export { sniffBmp, decodeBmp } from './bmp'; +export { enumeratePeResources, type PeResourceLeaf, type PeResourceTable } from './pe'; +export { enumerateNeResources, type NeResourceLeaf, type NeResourceTable } from './ne'; +export { + inspectWinResources, + preferredWinType, + type WinResEntry, + type WinResInspect, + type WinResKind, + type WinResTypeGroup, +} from './table'; +export { decodeVersionInfo, decodeStringTable, previewWinResource, type WinResPreview } from './decode-res'; +export { + extractWinVersion, + winVersionForGetInfo, + type WinVersionGetInfo, +} from './version-info'; +export { + RT_BITMAP, + RT_CURSOR, + RT_GROUP_ICON, + RT_ICON, + RT_MANIFEST, + RT_STRING, + RT_VERSION, + RT_VERSIONINFO, + rtTypeCode, + rtTypeLabel, +} from './rt'; diff --git a/src/fs/winicon/ne.test.ts b/src/fs/winicon/ne.test.ts new file mode 100644 index 0000000..8ae67cb --- /dev/null +++ b/src/fs/winicon/ne.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { encodeIco, extractNeIconsFromBuffer, sniffWinIcon } from './index'; +import { buildNeWithIco } from './exe-fixtures'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; + +function solid(width: number, height: number, r: number, g: number, b: number): DecodedIcon { + const pixels = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < width * height; i++) { + pixels[i * 4] = r; + pixels[i * 4 + 1] = g; + pixels[i * 4 + 2] = b; + pixels[i * 4 + 3] = 255; + } + return { typeCode: 'ICO', isColor: true, width, height, pixels }; +} + +describe('NE icons', () => { + it('extracts a 32 px icon from the NE resource table', async () => { + const ico = encodeIco([solid(32, 32, 0, 0, 255)]); + const ne = buildNeWithIco(ico); + expect(sniffWinIcon(ne.subarray(0, 80))).toBe('ne'); + const icons = await extractNeIconsFromBuffer(ne); + expect(icons.some((i) => i.width === 32 && i.pixels[2] === 255)).toBe(true); + }); +}); diff --git a/src/fs/winicon/ne.ts b/src/fs/winicon/ne.ts new file mode 100644 index 0000000..3e61409 --- /dev/null +++ b/src/fs/winicon/ne.ts @@ -0,0 +1,136 @@ +/** + * NE (New Executable, 16-bit Windows) resource table. + */ + +import { le16, le32 } from '../../protocol/binary'; +import type { ByteRangeReader } from '../byte-range'; +import { bufferRangeReader } from '../byte-range'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; +import { assembleIcoFromGroup, decodeIco } from './ico'; +import { RT_GROUP_ICON, RT_ICON } from './rt'; +import { readExact, readSlice } from './read'; + +const MZ = 0x5a4d; +const NE_SIG = 0x454e; // 'NE' +const INTEGER_TYPE = 0x8000; +const MAX_ALIGN = 16; +const MAX_RES = 2 * 1024 * 1024; + +export function sniffNeAfterMz(header: Uint8Array, neBytes: Uint8Array): boolean { + if (header.length < 64 || le16(header, 0) !== MZ) return false; + return neBytes.length >= 2 && le16(neBytes, 0) === NE_SIG; +} + +type NeRes = { + typeId: number | null; + typeName: string | null; + id: number | null; + name: string | null; + offset: number; + length: number; +}; + +function nePascal(table: Uint8Array, off: number): string | null { + if (off < 0 || off >= table.length) return null; + const n = table[off]!; + if (off + 1 + n > table.length) return null; + let s = ''; + for (let i = 0; i < n; i++) s += String.fromCharCode(table[off + 1 + i]!); + return s; +} + +function parseResourceTable(table: Uint8Array, fileBase: number, alignShift: number): NeRes[] { + const out: NeRes[] = []; + let p = 2; + const unit = 1 << alignShift; + while (p + 8 <= table.length) { + const typeRaw = le16(table, p); + if (typeRaw === 0) break; + const count = le16(table, p + 2); + p += 8; + const typeId = typeRaw & INTEGER_TYPE ? typeRaw & ~INTEGER_TYPE : null; + const typeName = typeId == null ? nePascal(table, typeRaw) : null; + for (let i = 0; i < count; i++) { + if (p + 12 > table.length) return out; + const offsetUnits = le16(table, p); + const lengthUnits = le16(table, p + 2); + const idRaw = le16(table, p + 6); + p += 12; + const id = idRaw & INTEGER_TYPE ? idRaw & ~INTEGER_TYPE : null; + const name = id == null ? nePascal(table, idRaw) : null; + out.push({ + typeId, + typeName, + id, + name, + offset: fileBase + offsetUnits * unit, + length: Math.min(MAX_RES, lengthUnits * unit), + }); + } + } + return out; +} + +async function loadNeTable(read: ByteRangeReader): Promise<{ table: Uint8Array; fileOff: number; shift: number } | null> { + const dos = await readExact(read, 0, 64); + if (!dos || le16(dos, 0) !== MZ) return null; + const lfanew = le32(dos, 0x3c); + if (lfanew < 64 || lfanew > 0x100000) return null; + const ne = await readExact(read, lfanew, 0x40); + if (!ne || le16(ne, 0) !== NE_SIG) return null; + const resOff = le16(ne, 0x24); + if (resOff < 0x40) return null; + const tableOff = lfanew + resOff; + const head = await readExact(read, tableOff, 2); + if (!head) return null; + const shift = le16(head, 0); + if (shift > MAX_ALIGN) return null; + // Type-info list is small; 64KiB covers any realistic NE resource table. + const table = await readSlice(read, tableOff, 64 * 1024); + if (table.length < 4) return null; + return { table, fileOff: 0, shift }; +} + +export type NeResourceLeaf = NeRes & { bytes: Uint8Array }; + +export type NeResourceTable = { + shift: number; + leaves: NeResourceLeaf[]; +}; + +/** Every NE resource with payload bytes. */ +export async function enumerateNeResources(read: ByteRangeReader): Promise { + const loaded = await loadNeTable(read); + if (!loaded) return null; + const entries = parseResourceTable(loaded.table, loaded.fileOff, loaded.shift); + const leaves: NeResourceLeaf[] = []; + for (const e of entries) { + const bytes = await readSlice(read, e.offset, e.length); + if (!bytes.length) continue; + leaves.push({ ...e, bytes }); + } + return { shift: loaded.shift, leaves }; +} + +/** Extract decoded icons from a 16-bit NE executable. */ +export async function extractNeIcons(read: ByteRangeReader): Promise { + const table = await enumerateNeResources(read); + if (!table) return []; + const iconBlobs = new Map(); + const groups: Uint8Array[] = []; + for (const e of table.leaves) { + if (e.typeId === RT_ICON && e.id != null) iconBlobs.set(e.id, e.bytes); + else if (e.typeId === RT_GROUP_ICON) groups.push(e.bytes); + } + const icons: DecodedIcon[] = []; + for (const g of groups) { + const ico = assembleIcoFromGroup(g, iconBlobs); + if (!ico) continue; + icons.push(...(await decodeIco(ico))); + } + return icons; +} + +export async function extractNeIconsFromBuffer(data: Uint8Array): Promise { + return extractNeIcons(bufferRangeReader(data)); +} diff --git a/src/fs/winicon/pe.test.ts b/src/fs/winicon/pe.test.ts new file mode 100644 index 0000000..832464e --- /dev/null +++ b/src/fs/winicon/pe.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { writeLe16, writeLe32 } from '../../protocol/binary'; +import { encodeIco, extractPeIconsFromBuffer, sniffWinIcon } from './index'; +import { buildPeWithIco } from './exe-fixtures'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; + +function solid(width: number, height: number, r: number, g: number, b: number): DecodedIcon { + const pixels = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < width * height; i++) { + pixels[i * 4] = r; + pixels[i * 4 + 1] = g; + pixels[i * 4 + 2] = b; + pixels[i * 4 + 3] = 255; + } + return { typeCode: 'ICO', isColor: true, width, height, pixels }; +} + +describe('PE icons', () => { + it('extracts 16/32 frames from RT_GROUP_ICON', async () => { + const ico = encodeIco([solid(16, 16, 255, 0, 0), solid(32, 32, 0, 128, 0)]); + const pe = buildPeWithIco(ico); + expect(sniffWinIcon(pe)).toBe('pe'); + const icons = await extractPeIconsFromBuffer(pe); + expect(icons.some((i) => i.width === 16 && i.pixels[0] === 255)).toBe(true); + expect(icons.some((i) => i.width === 32 && i.pixels[1] === 128)).toBe(true); + }); + + it('returns nothing for MZ without a resource directory', async () => { + const stub = new Uint8Array(128); + writeLe16(stub, 0, 0x5a4d); + writeLe32(stub, 0x3c, 0x40); + writeLe32(stub, 0x40, 0x4550); + writeLe16(stub, 0x44, 0x14c); + writeLe16(stub, 0x54, 96); + writeLe16(stub, 0x58, 0x10b); + const icons = await extractPeIconsFromBuffer(stub); + expect(icons).toEqual([]); + }); +}); diff --git a/src/fs/winicon/pe.ts b/src/fs/winicon/pe.ts new file mode 100644 index 0000000..3b139b4 --- /dev/null +++ b/src/fs/winicon/pe.ts @@ -0,0 +1,237 @@ +/** + * PE (Portable Executable) resource directory. + * Reads only the COFF headers and .rsrc section (range-friendly). + */ + +import { le16, le32 } from '../../protocol/binary'; +import type { ByteRangeReader } from '../byte-range'; +import { bufferRangeReader } from '../byte-range'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; +import { assembleIcoFromGroup, decodeIco, wrapSingleIcoImage } from './ico'; +import { decodeIcoDib } from './bmp'; +import { readExact, readSlice } from './read'; +import { RT_GROUP_ICON, RT_ICON } from './rt'; + +export { RT_GROUP_ICON, RT_ICON } from './rt'; + +const MZ = 0x5a4d; +const PE_MAGIC32 = 0x10b; +const PE_MAGIC64 = 0x20b; +const MAX_SECTIONS = 96; +const MAX_RSRC = 8 * 1024 * 1024; +const DIR_HIGH = 0x80000000; + +type Section = { va: number; vs: number; raw: number; rawSize: number }; + +function rvaToOff(sections: Section[], rva: number): number | null { + for (const s of sections) { + const span = Math.max(s.vs, s.rawSize); + if (rva >= s.va && rva < s.va + span) return s.raw + (rva - s.va); + } + return null; +} + +export function sniffPe(header: Uint8Array): boolean { + if (header.length < 64) return false; + if (le16(header, 0) !== MZ) return false; + const lfanew = le32(header, 0x3c); + if (lfanew < 64 || lfanew > 0x100000) return false; + return true; +} + +type ResData = { rva: number; size: number }; + +class ResDir { + constructor( + readonly named: { nameRva: number; child: number; isDir: boolean }[], + readonly ids: { id: number; child: number; isDir: boolean }[], + ) {} +} + +function parseDir(buf: Uint8Array, off: number): ResDir | null { + if (off + 16 > buf.length) return null; + const nNamed = le16(buf, off + 12); + const nId = le16(buf, off + 14); + const total = nNamed + nId; + if (total > 4096 || off + 16 + total * 8 > buf.length) return null; + const named: ResDir['named'] = []; + const ids: ResDir['ids'] = []; + let p = off + 16; + for (let i = 0; i < nNamed; i++, p += 8) { + const name = le32(buf, p); + const child = le32(buf, p + 4); + named.push({ nameRva: name & ~DIR_HIGH, child: child & ~DIR_HIGH, isDir: (child & DIR_HIGH) !== 0 }); + } + for (let i = 0; i < nId; i++, p += 8) { + const id = le32(buf, p); + const child = le32(buf, p + 4); + ids.push({ id, child: child & ~DIR_HIGH, isDir: (child & DIR_HIGH) !== 0 }); + } + return new ResDir(named, ids); +} + +function parseData(buf: Uint8Array, off: number): ResData | null { + if (off + 16 > buf.length) return null; + return { rva: le32(buf, off), size: le32(buf, off + 4) }; +} + +type Seg = { id: number | null; name: string | null }; +type LeafPath = { path: Seg[]; data: ResData }; + +function readUName(buf: Uint8Array, off: number): string | null { + if (off + 2 > buf.length) return null; + const n = le16(buf, off); + if (n > 256 || off + 2 + n * 2 > buf.length) return null; + let s = ''; + for (let i = 0; i < n; i++) s += String.fromCharCode(le16(buf, off + 2 + i * 2)); + return s; +} + +function collectLeaves(buf: Uint8Array, dirOff: number, depth: number, out: LeafPath[], path: Seg[]): void { + if (depth > 4) return; + const dir = parseDir(buf, dirOff); + if (!dir) return; + const walk = (seg: Seg, child: number, isDir: boolean) => { + const next = [...path, seg]; + if (isDir) collectLeaves(buf, child, depth + 1, out, next); + else { + const data = parseData(buf, child); + if (data) out.push({ path: next, data }); + } + }; + for (const e of dir.ids) walk({ id: e.id, name: null }, e.child, e.isDir); + for (const e of dir.named) walk({ id: null, name: readUName(buf, e.nameRva) }, e.child, e.isDir); +} + +async function loadHeaders(read: ByteRangeReader): Promise<{ + sections: Section[]; + resRva: number; + resSize: number; + machine: number; + magic: 'pe32' | 'pe32+'; +} | null> { + const dos = await readExact(read, 0, 64); + if (!dos || le16(dos, 0) !== MZ) return null; + const lfanew = le32(dos, 0x3c); + const sigCoff = await readExact(read, lfanew, 24); + if (!sigCoff) return null; + if (le32(sigCoff, 0) !== 0x4550) return null; + const machine = le16(sigCoff, 4); + const nSections = le16(sigCoff, 6); + const optSize = le16(sigCoff, 20); + if (nSections === 0 || nSections > MAX_SECTIONS || optSize < 96) return null; + const opt = await readExact(read, lfanew + 24, optSize); + if (!opt) return null; + const magicRaw = le16(opt, 0); + if (magicRaw !== PE_MAGIC32 && magicRaw !== PE_MAGIC64) return null; + const magic = magicRaw === PE_MAGIC64 ? 'pe32+' : 'pe32'; + const ddOff = magic === 'pe32+' ? 112 : 96; + if (ddOff + 24 > opt.length) return null; + const nRva = le32(opt, magic === 'pe32+' ? 108 : 92); + if (nRva < 3) return null; + const resRva = le32(opt, ddOff + 16); + const resSize = le32(opt, ddOff + 20); + if (!resRva || !resSize || resSize > MAX_RSRC) return null; + + const secOff = lfanew + 24 + optSize; + const secBuf = await readExact(read, secOff, nSections * 40); + if (!secBuf) return null; + const sections: Section[] = []; + for (let i = 0; i < nSections; i++) { + const o = i * 40; + sections.push({ + vs: le32(secBuf, o + 8), + va: le32(secBuf, o + 12), + rawSize: le32(secBuf, o + 16), + raw: le32(secBuf, o + 20), + }); + } + return { sections, resRva, resSize, machine, magic }; +} + +function sliceRva(rsrc: Uint8Array, resRva: number, rva: number, size: number): Uint8Array | null { + const local = rva - resRva; + if (local < 0 || local + size > rsrc.length) return null; + return rsrc.subarray(local, local + size); +} + +export type PeResourceLeaf = { + typeId: number | null; + typeName: string | null; + id: number | null; + name: string | null; + language: number; + bytes: Uint8Array; +}; + +export type PeResourceTable = { + magic: 'pe32' | 'pe32+'; + machine: number; + resRva: number; + resSize: number; + leaves: PeResourceLeaf[]; +}; + +/** Every PE resource leaf (type / name / language) with payload bytes. */ +export async function enumeratePeResources(read: ByteRangeReader): Promise { + const hdr = await loadHeaders(read); + if (!hdr) return null; + const fileOff = rvaToOff(hdr.sections, hdr.resRva); + if (fileOff == null) return null; + const rsrc = await readSlice(read, fileOff, hdr.resSize); + if (rsrc.length < 16) return null; + const raw: LeafPath[] = []; + collectLeaves(rsrc, 0, 0, raw, []); + const leaves: PeResourceLeaf[] = []; + for (const leaf of raw) { + const type = leaf.path[0]; + const name = leaf.path[1]; + const lang = leaf.path[2]; + const bytes = sliceRva(rsrc, hdr.resRva, leaf.data.rva, leaf.data.size); + if (!bytes) continue; + leaves.push({ + typeId: type?.id ?? null, + typeName: type?.name ?? null, + id: name?.id ?? null, + name: name?.name ?? null, + language: lang?.id ?? 0, + bytes, + }); + } + return { magic: hdr.magic, machine: hdr.machine, resRva: hdr.resRva, resSize: hdr.resSize, leaves }; +} + +/** Extract decoded icons from a PE image. */ +export async function extractPeIcons(read: ByteRangeReader): Promise { + const table = await enumeratePeResources(read); + if (!table) return []; + + const iconBlobs = new Map(); + const groups: Uint8Array[] = []; + for (const leaf of table.leaves) { + if (leaf.typeId === RT_ICON && leaf.id != null) iconBlobs.set(leaf.id, leaf.bytes); + else if (leaf.typeId === RT_GROUP_ICON) groups.push(leaf.bytes); + } + + const icons: DecodedIcon[] = []; + for (const g of groups) { + const ico = assembleIcoFromGroup(g, iconBlobs); + if (!ico) continue; + icons.push(...(await decodeIco(ico))); + } + if (icons.length) return icons; + + for (const e of iconBlobs.values()) { + if (e.length >= 8 && e[0] === 0x89 && e[1] === 0x50) { + icons.push(...(await decodeIco(wrapSingleIcoImage(e)))); + continue; + } + const dib = decodeIcoDib(e); + if (dib) icons.push(dib); + } + return icons; +} + +export async function extractPeIconsFromBuffer(data: Uint8Array): Promise { + return extractPeIcons(bufferRangeReader(data)); +} diff --git a/src/fs/winicon/read.ts b/src/fs/winicon/read.ts new file mode 100644 index 0000000..0bb4524 --- /dev/null +++ b/src/fs/winicon/read.ts @@ -0,0 +1,25 @@ +/** Range-read helpers for PE / NE / ICO parsers. */ + +import type { ByteRangeReader } from '../byte-range'; + +const MAX_SLICE = 8 * 1024 * 1024; + +export async function readSlice( + read: ByteRangeReader, + offset: number, + count: number, +): Promise { + if (count <= 0 || offset < 0) return new Uint8Array(); + const n = Math.min(count, MAX_SLICE); + const got = await read(offset, n); + return got.length <= n ? got : got.subarray(0, n); +} + +export async function readExact( + read: ByteRangeReader, + offset: number, + count: number, +): Promise { + const got = await readSlice(read, offset, count); + return got.length >= count ? got.subarray(0, count) : null; +} diff --git a/src/fs/winicon/rt.ts b/src/fs/winicon/rt.ts new file mode 100644 index 0000000..e88fedd --- /dev/null +++ b/src/fs/winicon/rt.ts @@ -0,0 +1,93 @@ +/** Windows PE/NE predefined resource type ids (RT_*). */ + +export const RT_CURSOR = 1; +export const RT_BITMAP = 2; +export const RT_ICON = 3; +export const RT_MENU = 4; +export const RT_DIALOG = 5; +export const RT_STRING = 6; +export const RT_FONTDIR = 7; +export const RT_FONT = 8; +export const RT_ACCELERATOR = 9; +export const RT_RCDATA = 10; +export const RT_MESSAGETABLE = 11; +export const RT_GROUP_CURSOR = 12; +export const RT_GROUP_ICON = 14; +export const RT_VERSION = 16; +/** Same as RT_VERSION; VERSIONINFO / VS_VERSION_INFO in .rc files. */ +export const RT_VERSIONINFO = RT_VERSION; +export const RT_DLGINCLUDE = 17; +export const RT_PLUGPLAY = 19; +export const RT_VXD = 20; +export const RT_ANICURSOR = 21; +export const RT_ANIICON = 22; +export const RT_HTML = 23; +export const RT_MANIFEST = 24; + +export const RT_LABELS: Record = { + [RT_CURSOR]: 'Cursor', + [RT_BITMAP]: 'Bitmap', + [RT_ICON]: 'Icon', + [RT_MENU]: 'Menu', + [RT_DIALOG]: 'Dialog', + [RT_STRING]: 'String table', + [RT_FONTDIR]: 'Font directory', + [RT_FONT]: 'Font', + [RT_ACCELERATOR]: 'Accelerator', + [RT_RCDATA]: 'Raw data', + [RT_MESSAGETABLE]: 'Message table', + [RT_GROUP_CURSOR]: 'Cursor group', + [RT_GROUP_ICON]: 'Icon group', + [RT_VERSION]: 'Version', + [RT_DLGINCLUDE]: 'Dialog include', + [RT_PLUGPLAY]: 'Plug and Play', + [RT_VXD]: 'VxD', + [RT_ANICURSOR]: 'Animated cursor', + [RT_ANIICON]: 'Animated icon', + [RT_HTML]: 'HTML', + [RT_MANIFEST]: 'Manifest', +}; + +export const RT_ICON_TYPES = new Set([RT_ICON, RT_GROUP_ICON, RT_CURSOR, RT_GROUP_CURSOR, RT_BITMAP]); + +export function rtTypeKey(id: number | null, name: string | null): string { + if (name) return `n:${name}`; + if (id != null) return `id:${id}`; + return 'unknown'; +} + +export function rtTypeLabel(id: number | null, name: string | null): string { + if (name) return name; + if (id != null) return RT_LABELS[id] ?? `RT_${id}`; + return 'Unknown'; +} + +export const RT_NAMES: Record = { + [RT_CURSOR]: 'RT_CURSOR', + [RT_BITMAP]: 'RT_BITMAP', + [RT_ICON]: 'RT_ICON', + [RT_MENU]: 'RT_MENU', + [RT_DIALOG]: 'RT_DIALOG', + [RT_STRING]: 'RT_STRING', + [RT_FONTDIR]: 'RT_FONTDIR', + [RT_FONT]: 'RT_FONT', + [RT_ACCELERATOR]: 'RT_ACCELERATOR', + [RT_RCDATA]: 'RT_RCDATA', + [RT_MESSAGETABLE]: 'RT_MESSAGETABLE', + [RT_GROUP_CURSOR]: 'RT_GROUP_CURSOR', + [RT_GROUP_ICON]: 'RT_GROUP_ICON', + [RT_VERSION]: 'RT_VERSION', + [RT_DLGINCLUDE]: 'RT_DLGINCLUDE', + [RT_PLUGPLAY]: 'RT_PLUGPLAY', + [RT_VXD]: 'RT_VXD', + [RT_ANICURSOR]: 'RT_ANICURSOR', + [RT_ANIICON]: 'RT_ANIICON', + [RT_HTML]: 'RT_HTML', + [RT_MANIFEST]: 'RT_MANIFEST', +}; + +export function rtTypeCode(id: number | null, name: string | null): string { + if (name) return name; + if (id != null) return RT_NAMES[id] ?? `RT_${id}`; + return '?'; +} diff --git a/src/fs/winicon/table.ts b/src/fs/winicon/table.ts new file mode 100644 index 0000000..5009ddf --- /dev/null +++ b/src/fs/winicon/table.ts @@ -0,0 +1,215 @@ +/** + * Grouped PE / NE / ICO resource table for the Windows resource explorer. + */ + +import { le16, le32 } from '../../protocol/binary'; +import type { ByteRangeReader } from '../byte-range'; +import { enumerateIcoFrames } from './ico'; +import { enumerateNeResources, type NeResourceLeaf } from './ne'; +import { enumeratePeResources, type PeResourceLeaf } from './pe'; +import { readExact } from './read'; +import { + RT_BITMAP, + RT_CURSOR, + RT_GROUP_CURSOR, + RT_GROUP_ICON, + RT_ICON, + RT_ICON_TYPES, + RT_MANIFEST, + RT_STRING, + RT_VERSION, + rtTypeCode, + rtTypeKey, + rtTypeLabel, +} from './rt'; +import { sniffWinIcon } from './extract'; + +const NE_SIG = 0x454e; + +const TYPE_ORDER = [ + RT_VERSION, + RT_GROUP_ICON, + RT_MANIFEST, + RT_ICON, + RT_BITMAP, + RT_STRING, + RT_GROUP_CURSOR, +]; + +const MACHINE: Record = { + 0x14c: 'i386', + 0x8664: 'amd64', + 0x1c0: 'ARM', + 0xaa64: 'ARM64', + 0x1c4: 'ARMv7', + 0x200: 'IA64', +}; + +export type WinResKind = 'pe' | 'ne' | 'ico' | 'cur'; + +export type WinResEntry = { + typeKey: string; + typeId: number | null; + typeName: string | null; + id: number | null; + name: string | null; + language: number; + length: number; + bytes: Uint8Array; +}; + +export type WinResTypeGroup = { + key: string; + typeId: number | null; + typeName: string | null; + label: string; + code: string; + count: number; + icon: boolean; + entries: WinResEntry[]; +}; + +export type WinResInspect = { + kind: WinResKind | null; + magic?: string; + machine?: number; + machineName?: string; + shift?: number; + types: WinResTypeGroup[]; + entries: WinResEntry[]; +}; + +function emptyInspect(): WinResInspect { + return { kind: null, types: [], entries: [] }; +} + +function typeRank(id: number | null): number { + if (id == null) return 2000; + const i = TYPE_ORDER.indexOf(id); + return i >= 0 ? i : 1000 + id; +} + +function sortGroups(groups: WinResTypeGroup[]): WinResTypeGroup[] { + return [...groups].sort((a, b) => { + const d = typeRank(a.typeId) - typeRank(b.typeId); + if (d) return d; + return a.label.localeCompare(b.label); + }); +} + +function groupEntries(entries: WinResEntry[]): WinResTypeGroup[] { + const map = new Map(); + for (const e of entries) { + let g = map.get(e.typeKey); + if (!g) { + g = { + key: e.typeKey, + typeId: e.typeId, + typeName: e.typeName, + label: rtTypeLabel(e.typeId, e.typeName), + code: rtTypeCode(e.typeId, e.typeName), + count: 0, + icon: e.typeId != null && RT_ICON_TYPES.has(e.typeId), + entries: [], + }; + map.set(e.typeKey, g); + } + g.entries.push(e); + g.count = g.entries.length; + } + return sortGroups([...map.values()]); +} + +function fromPeLeaves(leaves: PeResourceLeaf[], magic: string, machine: number): WinResInspect { + const entries: WinResEntry[] = leaves.map((leaf) => ({ + typeKey: rtTypeKey(leaf.typeId, leaf.typeName), + typeId: leaf.typeId, + typeName: leaf.typeName, + id: leaf.id, + name: leaf.name, + language: leaf.language, + length: leaf.bytes.length, + bytes: leaf.bytes, + })); + return { + kind: 'pe', + magic, + machine, + machineName: MACHINE[machine] ?? `0x${machine.toString(16)}`, + types: groupEntries(entries), + entries, + }; +} + +function fromNeLeaves(leaves: NeResourceLeaf[], shift: number): WinResInspect { + const entries: WinResEntry[] = leaves.map((leaf) => ({ + typeKey: rtTypeKey(leaf.typeId, leaf.typeName), + typeId: leaf.typeId, + typeName: leaf.typeName, + id: leaf.id, + name: leaf.name, + language: 0, + length: leaf.bytes.length, + bytes: leaf.bytes, + })); + return { kind: 'ne', magic: 'NE', shift, types: groupEntries(entries), entries }; +} + +/** First type to show: version / icon group when present. */ +export function preferredWinType(types: WinResTypeGroup[]): string | null { + if (!types.length) return null; + return sortGroups(types)[0]?.key ?? null; +} + +/** Parse PE, NE, or standalone ICO/CUR into grouped resource types. */ +export async function inspectWinResources(read: ByteRangeReader): Promise { + const header = await readExact(read, 0, 64); + if (!header) return emptyInspect(); + const kind = sniffWinIcon(header); + if (kind === 'ico') { + const listed = await enumerateIcoFrames(read); + if (!listed) return emptyInspect(); + const typeId = listed.kind === 'cur' ? RT_CURSOR : RT_ICON; + const typeKey = rtTypeKey(typeId, null); + const entries: WinResEntry[] = listed.frames.map((f) => ({ + typeKey, + typeId, + typeName: null, + id: f.index + 1, + name: `${f.width}×${f.height}`, + language: 0, + length: f.bytes.length, + bytes: f.bytes, + })); + return { + kind: listed.kind, + magic: listed.kind.toUpperCase(), + types: groupEntries(entries), + entries, + }; + } + if (kind === 'ne') { + const lfanew = le32(header, 0x3c); + const ne = await readExact(read, lfanew, 2); + if (ne && le16(ne, 0) === NE_SIG) { + const table = await enumerateNeResources(read); + if (!table) return emptyInspect(); + return fromNeLeaves(table.leaves, table.shift); + } + } + if (kind === 'pe' || kind === 'ne') { + const lfanew = header.length >= 64 ? le32(header, 0x3c) : 0; + if (lfanew) { + const sig = await readExact(read, lfanew, 2); + if (sig && le16(sig, 0) === NE_SIG) { + const table = await enumerateNeResources(read); + if (!table) return emptyInspect(); + return fromNeLeaves(table.leaves, table.shift); + } + } + const table = await enumeratePeResources(read); + if (!table) return emptyInspect(); + return fromPeLeaves(table.leaves, table.magic, table.machine); + } + return emptyInspect(); +} diff --git a/src/fs/winicon/version-info.test.ts b/src/fs/winicon/version-info.test.ts new file mode 100644 index 0000000..633adf7 --- /dev/null +++ b/src/fs/winicon/version-info.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { bufferRangeReader } from '../byte-range'; +import { encodeIco, extractWinVersion, isWinVersionName, winVersionForGetInfo } from './index'; +import { buildPeWithIco } from './exe-fixtures'; +import type { DecodedIcon } from '../resource-types/icon-decoder'; + +function solid(width: number, r: number, g: number, b: number): DecodedIcon { + const pixels = new Uint8ClampedArray(width * width * 4); + for (let i = 0; i < width * width; i++) { + pixels[i * 4] = r; + pixels[i * 4 + 1] = g; + pixels[i * 4 + 2] = b; + pixels[i * 4 + 3] = 255; + } + return { typeCode: 'ICO', isColor: true, width, height: width, pixels }; +} + +describe('Win VERSIONINFO Get Info', () => { + it('matches .exe / .dll / .scr / .ocx names', () => { + expect(isWinVersionName('NOTEPAD.EXE')).toBe(true); + expect(isWinVersionName('foo.DLL')).toBe(true); + expect(isWinVersionName('ssstars.scr')).toBe(true); + expect(isWinVersionName('ctrl.ocx')).toBe(true); + expect(isWinVersionName('app.ico')).toBe(false); + expect(isWinVersionName('shell32.cpl')).toBe(false); + }); + + it('maps VERSIONINFO strings to Get Info rows', () => { + expect( + winVersionForGetInfo([ + { key: 'FileVersion', value: '4.0.0.0' }, + { key: 'ProductVersion', value: '4.0' }, + { key: 'ProductName', value: 'Windows NT' }, + { key: 'FileDescription', value: 'Notepad' }, + { key: 'LegalCopyright', value: '© Microsoft Corp.' }, + { key: 'CompanyName', value: 'Microsoft Corporation' }, + ]), + ).toEqual({ + version: '4.0.0.0', + productVersion: '4.0', + product: 'Windows NT', + description: 'Notepad', + copyright: '© Microsoft Corp.', + company: 'Microsoft Corporation', + }); + }); + + it('ignores 0.0.0.0 binary versions', () => { + expect(winVersionForGetInfo([{ key: 'FileVersion', value: '0.0.0.0' }])).toBeNull(); + expect( + winVersionForGetInfo([ + { key: 'FileVersion', value: '0.0.0.0' }, + { key: 'FileDescription', value: 'Setup' }, + ]), + ).toEqual({ + version: '', + productVersion: '', + product: '', + description: 'Setup', + copyright: '', + company: '', + }); + }); + + it('returns null when a PE has no RT_VERSION', async () => { + const ico = encodeIco([solid(16, 1, 0, 0)]); + expect(await extractWinVersion(bufferRangeReader(buildPeWithIco(ico)))).toBeNull(); + }); +}); diff --git a/src/fs/winicon/version-info.ts b/src/fs/winicon/version-info.ts new file mode 100644 index 0000000..0676da5 --- /dev/null +++ b/src/fs/winicon/version-info.ts @@ -0,0 +1,95 @@ +/** + * RT_VERSION / VERSIONINFO (VS_VERSION_INFO) for Finder Get Info. + */ + +import { le16, le32 } from '../../protocol/binary'; +import type { ByteRangeReader } from '../byte-range'; +import { decodeVersionInfo } from './decode-res'; +import { sniffWinIcon } from './extract'; +import { enumerateNeResources } from './ne'; +import { enumeratePeResources } from './pe'; +import { readExact } from './read'; +import { RT_VERSION } from './rt'; + +const NE_SIG = 0x454e; +const LANG_NEUTRAL = 0; +const LANG_US_ENGLISH = 0x0409; + +export interface WinVersionGetInfo { + version: string; + productVersion: string; + product: string; + description: string; + copyright: string; + company: string; +} + +function field(map: Map, key: string): string { + return (map.get(key) ?? '').trim(); +} + +function isBlankVersion(s: string): boolean { + return !s || /^[0.]+$/.test(s); +} + +/** Map VERSIONINFO strings to Get Info rows. */ +export function winVersionForGetInfo(fields: { key: string; value: string }[]): WinVersionGetInfo | null { + const map = new Map(fields.map((f) => [f.key, f.value])); + const fileVer = field(map, 'FileVersion'); + const prodVer = field(map, 'ProductVersion'); + const version = !isBlankVersion(fileVer) ? fileVer : prodVer; + const productVersion = !isBlankVersion(prodVer) && prodVer !== version ? prodVer : ''; + const info: WinVersionGetInfo = { + version: isBlankVersion(version) ? '' : version, + productVersion, + product: field(map, 'ProductName'), + description: field(map, 'FileDescription'), + copyright: field(map, 'LegalCopyright'), + company: field(map, 'CompanyName'), + }; + if (!info.version && !info.product && !info.description && !info.copyright && !info.company) return null; + return info; +} + +function pickVersionBytes( + leaves: { typeId: number | null; language?: number; bytes: Uint8Array }[], +): Uint8Array | null { + const vers = leaves.filter((l) => l.typeId === RT_VERSION && l.bytes.length); + if (!vers.length) return null; + const prefer = + vers.find((l) => l.language === LANG_US_ENGLISH) ?? + vers.find((l) => (l.language ?? 0) === LANG_NEUTRAL) ?? + vers[0]; + return prefer?.bytes ?? null; +} + +/** RT_VERSION payload from a PE or NE image, or null. */ +export async function extractWinVersion(read: ByteRangeReader): Promise { + const header = await readExact(read, 0, 64); + if (!header) return null; + const kind = sniffWinIcon(header); + if (kind === 'ne') { + const lfanew = le32(header, 0x3c); + const ne = await readExact(read, lfanew, 2); + if (ne && le16(ne, 0) === NE_SIG) { + const table = await enumerateNeResources(read); + const bytes = table ? pickVersionBytes(table.leaves) : null; + return bytes ? winVersionForGetInfo(decodeVersionInfo(bytes)) : null; + } + } + if (kind === 'pe' || kind === 'ne') { + const lfanew = header.length >= 64 ? le32(header, 0x3c) : 0; + if (lfanew) { + const sig = await readExact(read, lfanew, 4); + if (sig && le16(sig, 0) === NE_SIG) { + const table = await enumerateNeResources(read); + const bytes = table ? pickVersionBytes(table.leaves) : null; + return bytes ? winVersionForGetInfo(decodeVersionInfo(bytes)) : null; + } + } + const table = await enumeratePeResources(read); + const bytes = table ? pickVersionBytes(table.leaves) : null; + return bytes ? winVersionForGetInfo(decodeVersionInfo(bytes)) : null; + } + return null; +} diff --git a/src/fs/zip-export.test.ts b/src/fs/zip-export.test.ts new file mode 100644 index 0000000..b66c42f --- /dev/null +++ b/src/fs/zip-export.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { CapabilityCatalog } from './capability-catalog'; +import { afpVolumeCaps } from './catalog-caps'; +import { parseAppleDouble } from './appledouble'; +import { collectZipEntries, enumerateZipFiles, type ZipSearchProgress } from './zip-export'; +import { nodeRef, type VNode } from './virtual-fs'; + +function ascii(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +describe('enumerateZipFiles', () => { + it('counts folders and files and reports growing totals without reading forks', async () => { + const cat = new CapabilityCatalog(afpVolumeCaps); + const docs = await cat.mkdir(2, 'Docs'); + await cat.createFile(nodeRef(docs), 'Read Me', ascii('hello'), ascii('rsrc')); + const nested = await cat.mkdir(nodeRef(docs), 'Nested'); + await cat.createFile(nodeRef(nested), 'Notes', ascii('xy')); + + const reads: ReturnType[] = []; + const origEnsure = cat.ensureContent.bind(cat); + cat.ensureContent = async (ref, onBytes, signal) => { + reads.push(ref); + return origEnsure(ref, onBytes, signal); + }; + + const ticks: ZipSearchProgress[] = []; + const listed = await enumerateZipFiles(cat, docs, '', (p) => ticks.push({ ...p })); + + expect(reads).toEqual([]); + expect(listed.items).toBe(4); + expect(listed.bytes).toBe(5 + 4 + 2); + expect(listed.files.map((f) => f.path)).toEqual(['Docs/Read Me', 'Docs/Nested/Notes']); + expect(ticks[0]).toEqual({ items: 1, bytes: 0 }); + expect(ticks.at(-1)).toEqual({ items: 4, bytes: 11 }); + expect(ticks.map((t) => t.items)).toEqual([1, 2, 3, 4]); + }); + + it('walks new children as listing pages arrive', async () => { + const cat = new CapabilityCatalog(afpVolumeCaps); + const docs = await cat.mkdir(2, 'Docs'); + await cat.createFile(nodeRef(docs), 'A', ascii('a')); + await cat.createFile(nodeRef(docs), 'B', ascii('bb')); + + const origChildren = cat.children.bind(cat); + cat.children = async (parent, onBatch, signal) => { + const kids = await origChildren(parent, undefined, signal); + const acc: VNode[] = []; + for (const kid of kids) { + acc.push(kid); + await onBatch?.(acc); + } + return kids; + }; + + const ticks: ZipSearchProgress[] = []; + const listed = await enumerateZipFiles(cat, docs, '', (p) => ticks.push({ ...p })); + expect(listed.items).toBe(3); + expect(listed.bytes).toBe(1 + 2); + expect(ticks.map((t) => t.items)).toEqual([1, 2, 3]); + }); +}); + +describe('collectZipEntries', () => { + it('downloads planned files after enumerate and writes AppleDouble pairs', async () => { + const cat = new CapabilityCatalog(afpVolumeCaps); + const docs = await cat.mkdir(2, 'Docs'); + const fi = new Uint8Array(32); + fi.set(ascii('TEXT'), 0); + fi.set(ascii('ttxt'), 4); + await cat.createFile(nodeRef(docs), 'Read Me', ascii('hello'), ascii('rsrc'), fi); + + const listed = await enumerateZipFiles(cat, docs); + const reads: ReturnType[] = []; + const origEnsure = cat.ensureContent.bind(cat); + cat.ensureContent = async (ref, onBytes, signal) => { + reads.push(ref); + return origEnsure(ref, onBytes, signal); + }; + + let downloaded = 0; + const entries = await collectZipEntries(cat, listed.files, 'appledouble', (n) => { + downloaded += n; + }); + expect(reads).toHaveLength(1); + expect(downloaded).toBe(5 + 4); + expect(entries.map((e) => e.name)).toEqual(['Docs/Read Me', 'Docs/._Read Me']); + expect([...entries[0]!.data]).toEqual([...ascii('hello')]); + const ad = parseAppleDouble(entries[1]!.data); + expect(ad).not.toBeNull(); + expect([...ad!.resource]).toEqual([...ascii('rsrc')]); + }); +}); diff --git a/src/fs/zip-export.ts b/src/fs/zip-export.ts new file mode 100644 index 0000000..b2ed144 --- /dev/null +++ b/src/fs/zip-export.ts @@ -0,0 +1,106 @@ +/** Enumerate a Finder catalog tree, then stream files into an AppleDouble zip. */ + +import { buildAppleDouble, zipSidecarPath, type ZipExportStyle } from './appledouble'; +import { refKey, type NodeRef } from './catalog-caps'; +import { throwIfAborted } from '../util/abort'; +import { nodeRef, type Catalog, type VNode } from './virtual-fs'; + +/** One catalog file to fetch when building the zip. Directories are not members. */ +export type ZipFilePlan = { + node: VNode; + /** Data-fork path inside the zip (`Docs/Read Me`). */ + path: string; + bytes: number; +}; + +export type ZipSearchProgress = { + items: number; + bytes: number; +}; + +/** On-disk size from a listing row (no fork download). */ +export function zipListingBytes(node: VNode): number { + if (node.isDir) return 0; + return (node.dataBytes ?? node.data.length) + (node.resourceBytes ?? node.resource.length); +} + +/** + * Walk directories (and paged listings) to count items and bytes before any + * fork download. `onProgress` fires as each file or folder is found. + */ +export async function enumerateZipFiles( + vfs: Catalog, + node: VNode, + prefix = '', + onProgress?: (p: ZipSearchProgress) => void, + signal?: AbortSignal, +): Promise<{ files: ZipFilePlan[]; items: number; bytes: number }> { + const files: ZipFilePlan[] = []; + let items = 0; + let bytes = 0; + + const note = (): void => { + onProgress?.({ items, bytes }); + }; + + const walk = async (n: VNode, pre: string): Promise => { + throwIfAborted(signal); + items++; + if (!n.isDir) { + const size = zipListingBytes(n); + bytes += size; + files.push({ node: n, path: pre ? `${pre}${n.name}` : n.name, bytes: size }); + note(); + return; + } + note(); + const dirPrefix = pre ? `${pre}${n.name}/` : `${n.name}/`; + const seen = new Set(); + const consider = async (kid: VNode): Promise => { + const key = childKey(kid); + if (seen.has(key)) return; + seen.add(key); + await walk(kid, dirPrefix); + }; + const kids = await vfs.children( + nodeRef(n), + async (batch) => { + for (const kid of batch) await consider(kid); + }, + signal, + ); + for (const kid of kids) await consider(kid); + }; + + await walk(node, prefix); + return { files, items, bytes }; +} + +function childKey(node: VNode): string { + const ref: NodeRef = nodeRef(node); + return refKey(ref); +} + +/** Download planned files and emit AppleDouble pairs for zipStore. */ +export async function collectZipEntries( + vfs: Catalog, + files: ZipFilePlan[], + style: ZipExportStyle = 'appledouble', + onBytes?: (n: number) => void, + signal?: AbortSignal, +): Promise<{ name: string; data: Uint8Array }[]> { + const out: { name: string; data: Uint8Array }[] = []; + const creditRead = vfs.reportsChunkedBytes ? onBytes : undefined; + const creditLoaded = !vfs.reportsChunkedBytes ? onBytes : undefined; + for (const f of files) { + throwIfAborted(signal); + const full = (await vfs.ensureContent(nodeRef(f.node), creditRead, signal)) ?? f.node; + creditLoaded?.(full.data.length + full.resource.length); + out.push({ name: f.path, data: full.data }); + out.push({ + name: zipSidecarPath(f.path, style), + data: buildAppleDouble(full.finderInfo, full.resource), + }); + } + return out; +} diff --git a/src/main.ts b/src/main.ts index c56693e..29d632c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,452 +1,248 @@ -import './ui/styles/tokens.css'; -import { WebSerialPort } from './transport/webserial'; -import { LocalTalkStack } from './net/stack'; -import { NbpService, type LookupResult } from './services/nbp'; -import { AtpClient } from './services/atp-client'; -import { AfpServer } from './services/afp-server/server'; -import { AfpClient, type AfpCredentials, type AfpServerNotice } from './services/afp-client/client'; -import { VirtualFS } from './fs/virtual-fs'; -import { addWelcomePack, seedWelcomePackIfNeeded, skipWelcomePackSeed } from './fs/welcome-pack'; -import { resetExtensionMap } from './fs/extension-map'; -import { RemoteVfs } from './fs/remote-vfs'; -import { FinderWindow, type FinderHost } from './ui/finder-window'; -import { AppMenuBar } from './ui/app-menubar'; -import { LogPanel } from './ui/log-panel'; -import { ActivityWindow } from './ui/activity-window'; -import { FileActivityWindow } from './ui/file-activity-window'; -import { AlertDialog } from './ui/alert-dialog'; -import { AboutDialog } from './ui/about-dialog'; -import { AfpSessionsDialog } from './ui/afp-sessions-dialog'; -import { LoginDialog } from './ui/login-dialog'; -import { NameConflictDialog } from './ui/name-conflict-dialog'; -import { ExtensionEditorDialog } from './ui/extension-editor-dialog'; -import { ResourceForkExplorer } from './ui/resource-fork-explorer'; -import { GetInfoWindow } from './ui/get-info-window'; -import { - NetbootDialog, - BUNDLED_BLOCK_SIZE, - BUNDLED_PAYLOAD_URL, - type NetbootState, -} from './ui/netboot-dialog'; -import { PcapCapture } from './util/pcap'; -import { TrafficStats } from './util/traffic-stats'; -import { log } from './util/logger'; -import { clearPrefs } from './util/prefs'; -import { iconCache } from './fs/icon-cache'; -import { clearWindowLayouts, loadWindowLayouts } from './ui/window-layout'; -import { startLayoutMode } from './ui/layout-mode'; -import * as asp from './protocol/asp'; -import { assemblePayload, MemoryDisk, NetbootService } from './services/netboot'; -import { registerPwa } from './pwa'; - -async function fileBytes(file: File): Promise { - return new Uint8Array(await file.arrayBuffer()); -} - -async function fetchBytes(url: string): Promise { - const res = await fetch(url); - if (!res.ok) throw new Error(`failed to fetch ${url}: ${res.status}`); - return new Uint8Array(await res.arrayBuffer()); -} - -const WEB_SERIAL_HELP = - 'ClassicStack needs the Web Serial API to connect a TashTalk adaptor. Use Google Chrome (desktop or Android) or Microsoft Edge, over HTTPS or localhost. On a phone, plug the adaptor in with USB-C / OTG; 1 Mbaud with hardware flow control may fail on some Android USB stacks.'; - -async function main(): Promise { - log.installConsoleBridge(); - startLayoutMode(); - registerPwa(); - log.info('ClassicStack starting', 'app'); - - const app = document.querySelector('#app')!; - app.innerHTML = ''; - - const menubar = new AppMenuBar(); - const stage = document.createElement('div'); - stage.className = 'app-stage'; - const finder = new FinderWindow(); - const logPanel = new LogPanel(); - logPanel.hidden = true; - const activityWindow = new ActivityWindow(); - activityWindow.hidden = true; - const fileActivityWindow = new FileActivityWindow(); - fileActivityWindow.hidden = true; - const netboot = new NetbootDialog(); - const about = new AboutDialog(); - const alertDialog = new AlertDialog(); - const afpSessions = new AfpSessionsDialog(); - const loginDialog = new LoginDialog(); - const nameConflictDialog = new NameConflictDialog(); - const extensionEditor = new ExtensionEditorDialog(); - const resourceExplorer = new ResourceForkExplorer(); - resourceExplorer.hidden = true; - const getInfoWindow = new GetInfoWindow(); - getInfoWindow.hidden = true; - - stage.appendChild(finder); - app.append(menubar, stage, logPanel, activityWindow, fileActivityWindow, netboot, about, alertDialog, afpSessions, loginDialog, nameConflictDialog, extensionEditor, resourceExplorer, getInfoWindow); - - const serial = new WebSerialPort(); - const pcap = new PcapCapture(); - const traffic = new TrafficStats(); - const CAPTURE_STATUS_MS = 5_000; - let lastCaptureStatusAt = 0; - serial.tapFrames((frame, direction) => { - traffic.record(frame.length, direction); - pcap.record(frame, direction); - if (!pcap.capturing) return; - const now = Date.now(); - if (now - lastCaptureStatusAt < CAPTURE_STATUS_MS) return; - lastCaptureStatusAt = now; - menubar.refreshCaptureStatus(); - }); - - let stack: LocalTalkStack | null = null; - let nbp: NbpService | null = null; - let atp: AtpClient | null = null; - let afpServer: AfpServer | null = null; - let netbootSvc: NetbootService | null = null; - let bundledPayload: Uint8Array | null = null; - const vfs = new VirtualFS(); - let remote: AfpClient | null = null; - let remoteNbpName = ''; - let afpScanTimer: ReturnType | null = null; - let afpScanBusy = false; - let lastAfpScanKey = ''; - const AFP_SCAN_MS = 10_000; - - activityWindow.bind({ - traffic, - getAfpServer: () => afpServer, - }); - - await vfs.init(); - - function attachRemoteNotices(client: AfpClient): void { - client.onNotice = (n: AfpServerNotice) => { - if (n.kind === 'closed') { - void remote?.close().catch(() => undefined); - if (remote === client) { - remote = null; - remoteNbpName = ''; - } - finder.unmountRemote(n.text || 'The AFP server closed this session.'); - if (n.text) alertDialog.show(n.title, n.text); - log.info(`Remote AFP session closed: ${n.text || n.title}`, 'afp'); - return; - } - if (n.text) alertDialog.show(n.title, n.text); - log.info(`AFP ${n.kind} from ${n.title}: ${n.text.replace(/\n/g, ' ')}`, 'afp'); - }; - } - - afpSessions.bind({ - listSessions: () => afpServer?.listSessions() ?? [], - sendMessage: async (sessionId, text) => { - if (!afpServer) throw new Error('Not connected'); - await afpServer.sendMessage(sessionId, text); - }, - disconnectSession: async (sessionId, text, minutes) => { - if (!afpServer) throw new Error('Not connected'); - await afpServer.disconnectSession(sessionId, text, minutes); - }, - }); - - async function loadBundledPayload(): Promise { - if (!bundledPayload) { - bundledPayload = await fetchBytes(BUNDLED_PAYLOAD_URL); - log.info(`Loaded bundled ChainLoader.bin (${bundledPayload.length} bytes)`, 'netboot'); - } - return bundledPayload; - } - - async function applyNetboot(state: NetbootState): Promise { - netbootSvc?.stop(); - netbootSvc = null; - if (!state.enabled || !stack || !nbp) return; - if (!state.diskImage) { - log.warn('Netboot enabled but no ChainBoot HFS disk selected — not advertising', 'netboot'); - return; - } - try { - const payloadRaw = await loadBundledPayload(); - const diskBytes = await fileBytes(state.diskImage); - const payload = assemblePayload(payloadRaw, BUNDLED_BLOCK_SIZE); - netbootSvc = new NetbootService( - stack, - { - payload, - blockSize: BUNDLED_BLOCK_SIZE, - disk: new MemoryDisk(diskBytes), - paceMs: state.paceMs, - chainPaceMs: state.chainPaceMs, - }, - nbp, - ); - netbootSvc.start(); - } catch (err) { - log.error(`Netboot failed to start: ${err instanceof Error ? err.message : String(err)}`, 'netboot'); - netbootSvc = null; - } - } - - netboot.bind((state) => { - void applyNetboot(state); - }); - - function afpServerKey(s: LookupResult): string { - return `${s.object}\0${s.network}.${s.node}:${s.socket}`; - } - - function afpServerListKey(list: LookupResult[]): string { - return list.map(afpServerKey).sort().join('|'); - } - - function stopAfpServerScan(): void { - if (afpScanTimer != null) { - clearInterval(afpScanTimer); - afpScanTimer = null; - } - afpScanBusy = false; - lastAfpScanKey = ''; - finder.setNetworkScanning(false); - } - - async function scanAfpServers(kind: 'auto' | 'manual'): Promise { - if (!nbp) return []; - while (afpScanBusy) { - if (kind === 'auto' || !nbp) return []; - await new Promise((r) => setTimeout(r, 50)); - } - if (!nbp) return []; - afpScanBusy = true; - finder.setNetworkScanning(true); - try { - const list = await nbp.lookup('=', 'AFPServer'); - if (!nbp) return []; - const prevKey = lastAfpScanKey; - const key = afpServerListKey(list); - const changed = key !== prevKey; - lastAfpScanKey = key; - finder.setServers(list); - if (kind === 'manual' || !prevKey || changed) { - log.info(`NBP found ${list.length} AFPServer(s)`, 'nbp'); - } - if (kind === 'auto' && prevKey && changed) { - finder.setStatus( - list.length ? `Found ${list.length} AFP server(s)` : 'No AFP servers on the network', - ); - } - return list; - } finally { - afpScanBusy = false; - finder.setNetworkScanning(false); - } - } - - function startAfpServerScan(): void { - stopAfpServerScan(); - void (async () => { - finder.setStatus('Looking up AFPServer…'); - const list = await scanAfpServers('auto'); - if (!nbp) return; - finder.setStatus( - list.length - ? `Found ${list.length} AFP server(s)` - : 'No AFP servers found — scanning every 10s', - ); - })(); - afpScanTimer = setInterval(() => { - void scanAfpServers('auto'); - }, AFP_SCAN_MS); - } - - const host: FinderHost = { - isConnected: () => serial.connected, - nodeLabel: () => - stack && stack.node - ? `node ${stack.node.toString(16).padStart(2, '0').toUpperCase()} net ${stack.network}` - : '', - - remoteMeta: () => - remote - ? { - nbpName: remoteNbpName || remote.serverName, - serverName: remote.serverName, - volumeName: remote.volumeName, - volumes: remote.volumes.map((v) => v.name), - loggedIn: remote.loggedIn, - } - : null, - - async connectSerial() { - if (!WebSerialPort.supported()) { - alertDialog.show('Web Serial is not supported', WEB_SERIAL_HELP); - throw new Error('Web Serial is not supported'); - } - await serial.connect(); - stack = new LocalTalkStack(serial); - nbp = new NbpService(stack); - atp = new AtpClient(stack); - afpServer = new AfpServer(stack, vfs, { volumeName: 'Browser Share', serverName: 'ClassicStack' }); - nbp.register('ClassicStack', 'AFPServer', afpServer.socket()); - stack.onClaimed((net, node) => { - log.info(`LocalTalk node claimed: ${node} (net ${net})`, 'stack'); - finder.setStatus(`LocalTalk node claimed: ${node} (net ${net}). Sharing “Browser Share”.`); - void applyNetboot(netboot.getState()); - startAfpServerScan(); - }); - log.info('Serial connected; starting node claim', 'serial'); - await stack.startClaim(); - }, - - async disconnectSerial() { - stopAfpServerScan(); - await remote?.close().catch(() => undefined); - remote = null; - remoteNbpName = ''; - netbootSvc?.stop(); - netbootSvc = null; - stack?.stop(); - stack = null; - nbp = null; - atp = null; - afpServer = null; - traffic.reset(); - await serial.disconnect(); - log.info('Serial disconnected', 'serial'); - }, - - async refreshNetwork() { - return scanAfpServers('manual'); - }, - - async beginRemote(h: LookupResult) { - if (!atp) throw new Error('not connected'); - log.info(`AFP GetStatus/OpenSess ${h.object} (${h.network}.${h.node}:${h.socket || asp.DefaultSLS})`, 'afp'); - await remote?.close().catch(() => undefined); - remote = await AfpClient.openSession(atp, h.network, h.node, h.socket || asp.DefaultSLS); - remoteNbpName = h.object; - attachRemoteNotices(remote); - return { - serverName: remote.serverName, - versions: remote.versions, - uams: remote.uams, - }; - }, - - async loginRemote(creds: AfpCredentials) { - if (!remote) throw new Error('no AFP session'); - await remote.login(creds); - return remote.volumes.map((v) => v.name); - }, - - async openRemoteVolume(name: string) { - if (!remote) throw new Error('not logged in'); - const volId = await remote.openVolume(name); - log.info(`Mounted remote ${remote.serverName || remoteNbpName}:${name} (vol ${volId})`, 'afp'); - return new RemoteVfs(remote, name, volId); - }, - - localCatalog() { - return vfs; - }, - - installWelcomePack(opts) { - return addWelcomePack(vfs, opts); - }, - - seedWelcomePack(opts) { - return seedWelcomePackIfNeeded(vfs, opts); - }, - - promptCredentials(opts) { - return loginDialog.prompt(opts); - }, - - dismissLogin() { - loginDialog.close(); - }, - - showAlert(title: string, text: string) { - alertDialog.show(title, text); - }, - - promptNameConflict(opts) { - return nameConflictDialog.prompt(opts); - }, - - async findServer(nbpName: string) { - if (!nbp) return null; - let list = await nbp.lookup(nbpName, 'AFPServer'); - if (!list.length) list = await nbp.lookup('=', 'AFPServer'); - const hit = - list.find((x) => x.object.toLowerCase() === nbpName.toLowerCase()) ?? - list.find((x) => x.object.toLowerCase().includes(nbpName.toLowerCase())); - if (hit) finder.setServers(list); - return hit ?? null; - }, - - async closeRemote() { - await remote?.close().catch(() => undefined); - remote = null; - remoteNbpName = ''; - log.info('Ejected AFP server', 'afp'); - }, - }; - - menubar.bind({ - pcap, - logPanel, - activityWindow, - netboot, - afpSessions, - extensionEditor, - resourceExplorer, - getInfoWindow, - about, - alertDialog, - finder, - onCaptureChanged() { - menubar.refreshCaptureStatus(); - }, - async resetEnvironment(eraseShare) { - log.info(eraseShare ? 'Resetting environment (including Browser Share)' : 'Resetting environment', 'app'); - try { - await host.disconnectSerial(); - } catch { - /* already disconnected */ - } - clearPrefs(); - clearWindowLayouts(); - resetExtensionMap(); - await iconCache.clear().catch(() => undefined); - if (eraseShare) { - try { - await vfs.eraseAllItems(); - await skipWelcomePackSeed(vfs); - } catch (err) { - log.warn(`Failed to erase Browser Share: ${err instanceof Error ? err.message : String(err)}`, 'fs'); - } - } - location.reload(); - }, - }); - - const savedWindows = loadWindowLayouts(); - if (savedWindows.log?.open) logPanel.show(); - if (savedWindows.activity?.open) activityWindow.show(); - if (savedWindows.resource?.open) resourceExplorer.show(); - - finder.bind(vfs, host); - finder.bindResourceExplorer(resourceExplorer); - finder.bindGetInfoWindow(getInfoWindow); - - if (!WebSerialPort.supported()) { - log.warn('WebSerial unavailable — use Chrome/Edge over HTTPS or localhost', 'serial'); - finder.setStatus('WebSerial unavailable — use Chrome/Edge over HTTPS or localhost.'); - alertDialog.show('Web Serial is not supported', WEB_SERIAL_HELP); - } -} - -void main(); +import './ui/styles/tokens.css'; +import { WebSerialPort } from './transport/webserial'; +import { VirtualFS } from './fs/virtual-fs'; +import { skipWelcomePackSeed } from './fs/welcome-pack'; +import { resetExtensionMap } from './fs/extension-map'; +import { FinderWindow } from './ui/finder-window'; +import { AfpFinderHost, WEB_SERIAL_HELP } from './finder/afp-finder-host'; +import { AppMenuBar } from './ui/app-menubar'; +import { LogPanel } from './ui/log-panel'; +import { ActivityWindow } from './ui/activity-window'; +import { FileActivityWindow } from './ui/file-activity-window'; +import { AlertDialog } from './ui/alert-dialog'; +import { AboutDialog } from './ui/about-dialog'; +import { AfpSessionsDialog } from './ui/afp-sessions-dialog'; +import { LoginDialog } from './ui/login-dialog'; +import { NameConflictDialog } from './ui/name-conflict-dialog'; +import { ExtensionEditorDialog } from './ui/extension-editor-dialog'; +import { ResourceForkExplorer } from './ui/resource-fork-explorer'; +import { WinResourceExplorer } from './ui/win-resource-explorer'; +import { GetInfoWindow } from './ui/get-info-window'; +import { + NetbootDialog, + BUNDLED_BLOCK_SIZE, + BUNDLED_PAYLOAD_URL, + type NetbootState, +} from './ui/netboot-dialog'; +import { SettingsWindow } from './ui/settings-window'; +import { PcapCapture } from './util/pcap'; +import { TrafficStats } from './util/traffic-stats'; +import { log } from './util/logger'; +import { clearPrefs } from './util/prefs'; +import { iconCache } from './fs/icon-cache'; +import { clearWindowLayouts, loadWindowLayouts } from './ui/window-layout'; +import { startLayoutMode } from './ui/layout-mode'; +import { assemblePayload, MemoryDisk, NetbootService } from './services/netboot'; +import { registerPwa } from './pwa'; + +async function fileBytes(file: File): Promise { + return new Uint8Array(await file.arrayBuffer()); +} + +async function fetchBytes(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`failed to fetch ${url}: ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +} + +async function main(): Promise { + log.installConsoleBridge(); + startLayoutMode(); + registerPwa(); + log.info('ClassicStack starting', 'app'); + + const app = document.querySelector('#app')!; + app.innerHTML = ''; + + const menubar = new AppMenuBar(); + const stage = document.createElement('div'); + stage.className = 'app-stage'; + const finder = new FinderWindow(); + const logPanel = new LogPanel(); + logPanel.hidden = true; + const activityWindow = new ActivityWindow(); + activityWindow.hidden = true; + const fileActivityWindow = new FileActivityWindow(); + fileActivityWindow.hidden = true; + const netboot = new NetbootDialog(); + const about = new AboutDialog(); + const alertDialog = new AlertDialog(); + const afpSessions = new AfpSessionsDialog(); + const loginDialog = new LoginDialog(); + const nameConflictDialog = new NameConflictDialog(); + const extensionEditor = new ExtensionEditorDialog(); + const resourceExplorer = new ResourceForkExplorer(); + resourceExplorer.hidden = true; + const winResourceExplorer = new WinResourceExplorer(); + winResourceExplorer.hidden = true; + const getInfoWindow = new GetInfoWindow(); + getInfoWindow.hidden = true; + const settings = new SettingsWindow(); + + stage.appendChild(finder); + app.append(menubar, stage, logPanel, activityWindow, fileActivityWindow, netboot, about, alertDialog, afpSessions, loginDialog, nameConflictDialog, extensionEditor, resourceExplorer, winResourceExplorer, getInfoWindow, settings); + + const serial = new WebSerialPort(); + const pcap = new PcapCapture(); + const traffic = new TrafficStats(); + const CAPTURE_STATUS_MS = 5_000; + let lastCaptureStatusAt = 0; + serial.tapFrames((frame, direction) => { + traffic.record(frame.length, direction); + pcap.record(frame, direction); + if (!pcap.capturing) return; + const now = Date.now(); + if (now - lastCaptureStatusAt < CAPTURE_STATUS_MS) return; + lastCaptureStatusAt = now; + menubar.refreshCaptureStatus(); + }); + + let netbootSvc: NetbootService | null = null; + let bundledPayload: Uint8Array | null = null; + const vfs = new VirtualFS(); + await vfs.init(); + + const host = new AfpFinderHost(serial, vfs, { + finder, + login: loginDialog, + alert: alertDialog, + nameConflict: nameConflictDialog, + onClaimed() { + void applyNetboot(netboot.getState()); + }, + onDisconnect() { + netbootSvc?.stop(); + netbootSvc = null; + traffic.reset(); + }, + }); + + activityWindow.bind({ + traffic, + getAfpServer: () => host.afpServer, + }); + + afpSessions.bind({ + listSessions: () => host.afpServer?.listSessions() ?? [], + sendMessage: async (sessionId, text) => { + if (!host.afpServer) throw new Error('Not connected'); + await host.afpServer.sendMessage(sessionId, text); + }, + disconnectSession: async (sessionId, text, minutes) => { + if (!host.afpServer) throw new Error('Not connected'); + await host.afpServer.disconnectSession(sessionId, text, minutes); + }, + }); + + async function loadBundledPayload(): Promise { + if (!bundledPayload) { + bundledPayload = await fetchBytes(BUNDLED_PAYLOAD_URL); + log.info(`Loaded bundled ChainLoader.bin (${bundledPayload.length} bytes)`, 'netboot'); + } + return bundledPayload; + } + + async function applyNetboot(state: NetbootState): Promise { + netbootSvc?.stop(); + netbootSvc = null; + if (!state.enabled || !host.stack || !host.nbp) return; + if (!state.diskImage) { + log.warn('Netboot enabled but no ChainBoot HFS disk selected — not advertising', 'netboot'); + return; + } + try { + const payloadRaw = await loadBundledPayload(); + const diskBytes = await fileBytes(state.diskImage); + const payload = assemblePayload(payloadRaw, BUNDLED_BLOCK_SIZE); + netbootSvc = new NetbootService( + host.stack, + { + payload, + blockSize: BUNDLED_BLOCK_SIZE, + disk: new MemoryDisk(diskBytes), + paceMs: state.paceMs, + chainPaceMs: state.chainPaceMs, + }, + host.nbp, + ); + netbootSvc.start(); + } catch (err) { + log.error(`Netboot failed to start: ${err instanceof Error ? err.message : String(err)}`, 'netboot'); + netbootSvc = null; + } + } + + netboot.bind((state) => { + void applyNetboot(state); + }); + + async function resetEnvironment(eraseShare: boolean): Promise { + log.info(eraseShare ? 'Resetting environment (including Browser Share)' : 'Resetting environment', 'app'); + try { + await host.disconnectTransport?.(); + } catch { + /* already disconnected */ + } + clearPrefs(); + clearWindowLayouts(); + resetExtensionMap(); + await iconCache.clear().catch(() => undefined); + if (eraseShare) { + try { + await vfs.eraseAllItems(); + await skipWelcomePackSeed(vfs); + } catch (err) { + log.warn(`Failed to erase Browser Share: ${err instanceof Error ? err.message : String(err)}`, 'fs'); + } + } + location.reload(); + } + + menubar.bind({ + pcap, + logPanel, + activityWindow, + afpSessions, + resourceExplorer, + winResourceExplorer, + getInfoWindow, + about, + alertDialog, + settings, + finder, + extensionEditor, + onCaptureChanged() { + menubar.refreshCaptureStatus(); + }, + }); + + settings.bind({ + finder, + netboot, + extensionEditor, + alertDialog, + exportPreferences: () => menubar.exportPreferences(), + importPreferences: () => menubar.importPreferences(), + resetEnvironment, + onPrefsChanged: () => menubar.refresh(), + }); + + const savedWindows = loadWindowLayouts(); + if (savedWindows.log?.open) logPanel.show(); + if (savedWindows.activity?.open) activityWindow.show(); + if (savedWindows.resource?.open) resourceExplorer.show(); + if (savedWindows.winresource?.open) winResourceExplorer.show(); + + finder.bind(vfs, host); + finder.bindResourceExplorer(resourceExplorer); + finder.bindWinResourceExplorer(winResourceExplorer); + finder.bindGetInfoWindow(getInfoWindow); + + if (!WebSerialPort.supported()) { + log.warn('WebSerial unavailable — use Chrome/Edge over HTTPS or localhost', 'serial'); + finder.setStatus('WebSerial unavailable — use Chrome/Edge over HTTPS or localhost.'); + alertDialog.show('Web Serial is not supported', WEB_SERIAL_HELP); + } +} + +void main(); diff --git a/src/protocol/afp/constants.ts b/src/protocol/afp/constants.ts index e7270b5..775c6eb 100644 --- a/src/protocol/afp/constants.ts +++ b/src/protocol/afp/constants.ts @@ -2,6 +2,7 @@ export const CmdCloseVol = 2; export const CmdCloseFork = 4; +export const CmdCopyFile = 5; export const CmdCreateDir = 6; export const CmdCreateFile = 7; export const CmdDelete = 8; @@ -50,6 +51,7 @@ export const IconTypeIcs8 = 6; const AFP_CMD_NAME: Record = { [CmdCloseVol]: 'FPCloseVol', [CmdCloseFork]: 'FPCloseFork', + [CmdCopyFile]: 'FPCopyFile', [CmdCreateDir]: 'FPCreateDir', [CmdCreateFile]: 'FPCreateFile', [CmdDelete]: 'FPDelete', diff --git a/src/protocol/binary.ts b/src/protocol/binary.ts index 48c3c99..93d49ac 100644 --- a/src/protocol/binary.ts +++ b/src/protocol/binary.ts @@ -4,6 +4,36 @@ export function be16(b: Uint8Array, o = 0): number { return ((b[o]! << 8) | b[o + 1]!) >>> 0; } +/** Little-endian uint16 (PE / NE / ICO / ZIP). */ +export function le16(b: Uint8Array, o = 0): number { + return (b[o]! | (b[o + 1]! << 8)) >>> 0; +} + +/** Little-endian uint32 (PE / NE / ICO / ZIP). */ +export function le32(b: Uint8Array, o = 0): number { + return (b[o]! | (b[o + 1]! << 8) | (b[o + 2]! << 16) | (b[o + 3]! << 24)) >>> 0; +} + +export function writeLe16(dst: Uint8Array, o: number, v: number): void { + dst[o] = v & 0xff; + dst[o + 1] = (v >>> 8) & 0xff; +} + +export function writeLe32(dst: Uint8Array, o: number, v: number): void { + dst[o] = v & 0xff; + dst[o + 1] = (v >>> 8) & 0xff; + dst[o + 2] = (v >>> 16) & 0xff; + dst[o + 3] = (v >>> 24) & 0xff; +} + +export function appendLe16(dst: number[], v: number): void { + dst.push(v & 0xff, (v >>> 8) & 0xff); +} + +export function appendLe32(dst: number[], v: number): void { + dst.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff); +} + export function be32(b: Uint8Array, o = 0): number { return ( ((b[o]! << 24) | (b[o + 1]! << 16) | (b[o + 2]! << 8) | b[o + 3]!) >>> 0 diff --git a/src/services/afp-client/client.ts b/src/services/afp-client/client.ts index 5198f7b..7302fc9 100644 --- a/src/services/afp-client/client.ts +++ b/src/services/afp-client/client.ts @@ -1,4 +1,4 @@ -/** High-level AFP client: session login, then one open volume. */ +/** High-level AFP client: login, then one or more open volumes. */ import { AspSession } from '../asp-client'; import type { AtpClient } from '../atp-client'; @@ -427,6 +427,46 @@ export class AfpClient { return volId; } + /** + * FPCloseDT + FPCloseVol for one opened volume. The AFP login stays up so + * other volumes can still be opened; classic servers have few volume/DT slots. + */ + async closeVolume(name: string): Promise { + const volId = this.openVolIds.get(name); + if (volId == null) return; + await this.releaseVolume(name, volId); + } + + private async releaseVolume(name: string, volId: number): Promise { + const dtRef = this.dtRefs.get(volId); + if (dtRef != null) { + log.info(`FPCloseDT “${name}” (dt ${dtRef})`, 'afp'); + await this.fp(cmd.closeDT(dtRef)).catch(() => undefined); + this.dtRefs.delete(volId); + } + this.dtUnavailable.delete(volId); + this.dropDesktopCaches(volId); + log.info(`FPCloseVol “${name}” (id ${volId})`, 'afp'); + await this.fp(cmd.closeVol(volId)).catch(() => undefined); + this.openVolIds.delete(name); + if (this.volId === volId || this.volumeName === name) { + this.volId = 0; + this.volumeName = ''; + } + } + + private dropDesktopCaches(volId: number): void { + const prefix = `${volId}:`; + const drop = (map: Map) => { + for (const key of [...map.keys()]) { + if (key.startsWith(prefix)) map.delete(key); + } + }; + drop(this.desktopInfo); + drop(this.desktopInfoInflight); + drop(this.desktopIconInflight); + } + /** Volume id for an already-opened volume name. */ volumeId(name: string): number { return this.openVolIds.get(name) ?? 0; @@ -644,6 +684,26 @@ export class AfpClient { if (r.result !== C.NoErr) throw new Error(`FPRename ${r.result}`); } + async copyFile( + srcDir: number, + srcName: string, + dstVolId: number, + dstDir: number, + newName: string, + srcVolId?: number, + ): Promise { + log.trace( + `copyFile “${srcName}” src=${srcDir} dstVol=${dstVolId} dst=${dstDir} “${newName}” vol=${this.vid(srcVolId)}`, + 'afp', + ); + const r = await this.fp( + cmd.copyFile(this.vid(srcVolId), srcDir, srcName, dstVolId, dstDir, newName), + ); + if (r.result !== C.NoErr) { + throw new Error(`FPCopyFile ${C.afpResultName(r.result)} (${r.result})`); + } + } + async moveAndRename( srcDir: number, srcName: string, @@ -959,12 +1019,14 @@ export class AfpClient { async close(): Promise { log.trace('close', 'afp'); - for (const dtRef of this.dtRefs.values()) { - await this.fp(cmd.closeDT(dtRef)).catch(() => undefined); + for (const [name, volId] of [...this.openVolIds]) { + await this.releaseVolume(name, volId); } this.dtRefs.clear(); this.dtUnavailable.clear(); this.desktopInfo.clear(); + this.desktopInfoInflight.clear(); + this.desktopIconInflight.clear(); if (this.loggedIn) { await this.fp(cmd.logout()).catch(() => undefined); this.loggedIn = false; diff --git a/src/services/afp-client/commands.test.ts b/src/services/afp-client/commands.test.ts index c1eb957..c50a66b 100644 --- a/src/services/afp-client/commands.test.ts +++ b/src/services/afp-client/commands.test.ts @@ -32,6 +32,7 @@ import { closeFork, rename, moveAndRename, + copyFile, openDT, parseOpenDT, closeDT, @@ -97,6 +98,26 @@ describe('AFP client wirePath + Pascal framing', () => { expect(b[o + 1]).toBe(3); expect([...b.subarray(o + 2, o + 5)]).toEqual([...encodeMacRoman('New')]); }); + + it('CopyFile is srcVol/srcDir/dstVol/dstDir + dest path type 0', () => { + const b = copyFile(1, 2, 'orig.txt', 1, 2, 'copy.txt'); + expect(b[0]).toBe(C.CmdCopyFile); + expect(be16(b, 2)).toBe(1); + expect(be32(b, 4)).toBe(2); + expect(be16(b, 8)).toBe(1); + expect(be32(b, 10)).toBe(2); + expect(b[14]).toBe(C.PathTypeLongNames); + const src = wirePath('orig.txt'); + expect(b[15]).toBe(src.length); + let o = 16 + src.length; + if (o % 2) o++; + expect(b[o]).toBe(0); + expect(b[o + 1]).toBe(0); + o += 2; + expect(b[o]).toBe(C.PathTypeLongNames); + expect(b[o + 1]).toBe(8); + expect([...b.subarray(o + 2, o + 10)]).toEqual([...encodeMacRoman('copy.txt')]); + }); }); describe('AFP client FPWrite / FPRead headers', () => { diff --git a/src/services/afp-client/commands.ts b/src/services/afp-client/commands.ts index 6123fbf..395a82d 100644 --- a/src/services/afp-client/commands.ts +++ b/src/services/afp-client/commands.ts @@ -418,6 +418,32 @@ export function rename(volId: number, dirId: number, path: string, newName: stri return new Uint8Array(out); } +/** + * FPCopyFile: cmd pad srcVol srcDir dstVol dstDir srcPath dstPathType0 newName. + * Dest path type 0 means dstDirID is the destination directory (Inside Macintosh AFP). + */ +export function copyFile( + srcVolId: number, + srcDirId: number, + srcName: string, + dstVolId: number, + dstDirId: number, + newName: string, +): Uint8Array { + const out: number[] = [C.CmdCopyFile, 0]; + appendBe16(out, srcVolId); + appendBe32(out, srcDirId); + appendBe16(out, dstVolId); + appendBe32(out, dstDirId); + putPath(out, srcName); + even(out); + out.push(0); + putPString(out, ''); + if (!newName || newName === srcName) putNullPath(out); + else putCNodeName(out, newName); + return new Uint8Array(out); +} + export function moveAndRename( volId: number, srcDir: number, diff --git a/src/services/afp-server/server.ts b/src/services/afp-server/server.ts index 807f62d..c741548 100644 --- a/src/services/afp-server/server.ts +++ b/src/services/afp-server/server.ts @@ -1,1222 +1,1224 @@ -/** AFP server over ASP/ATP — one volume backed by VirtualFS. */ - -import * as asp from '../../protocol/asp'; -import * as C from '../../protocol/afp/constants'; -import { appendBe16, appendBe32, be16, be32, writeBe16, writeBe32 } from '../../protocol/binary'; -import { encodeMacRoman, decodeMacRoman } from '../../protocol/macroman'; -import { AtpServer, type AtpIncoming, type AtpResponse } from '../atp-server'; -import type { LocalTalkStack } from '../../net/stack'; -import type { VirtualFS, VNode } from '../../fs/virtual-fs'; -import { log } from '../../util/logger'; -import * as atp from '../../protocol/atp'; - -function afpCmdName(cmd: number): string { - return C.afpCmdName(cmd); -} - -function afpResultName(result: number): string { - const s = result | 0; - if (s === C.NoErr) return 'NoErr'; - if (s === C.ErrObjectNotFnd) return 'ObjectNotFound'; - if (s === C.ErrItemNotFound) return 'ItemNotFound'; - if (s === C.ErrParamErr) return 'ParamErr'; - if (s === C.ErrCallNotSuppt) return 'CallNotSupported'; - if (s === C.ErrAccessDenied) return 'AccessDenied'; - if (s === C.ErrEOFErr) return 'EOFErr'; - if (s === C.ErrBitmapErr) return 'BitmapErr'; - return String(s); -} - -interface OpenFork { - nodeId: number; - resource: boolean; - offset: number; - name: string; - /** Fork bytes at open (and after our writes); avoids reloading the node on every FPRead. */ - bytes: Uint8Array; -} - -interface Session { - id: number; - wss: number; - sss: number; - peerNet: number; - peerNode: number; - loggedIn: boolean; - userName: string; - volId: number; - forks: Map; - nextFork: number; - dtRef: number; - atp: AtpServer; - serverMsg: string; - lastRx: number; - closed: boolean; - disconnectTimer: ReturnType | null; -} - -export interface AfpSessionInfo { - id: number; - network: number; - node: number; - loggedIn: boolean; - userName: string; - lastSeen: number; -} - -export interface AfpOpenFileInfo { - sessionId: number; - userName: string; - forkRef: number; - name: string; - resource: boolean; -} - -const MESSAGE_FETCH_GRACE_MS = 1500; - -/** In-flight ASP two-phase write (ClassicStack pendingWrite). */ -interface PendingWrite { - reply: (userData: number, data: Uint8Array) => Promise; - sess: Session; - cmdBlk: Uint8Array; - hdrLen: number; - want: number; - seq: number; - data: Uint8Array; - attempts: number; - timer: ReturnType | null; -} - -export class AfpServer { - private stack: LocalTalkStack; - private fs: VirtualFS; - private sls = asp.DefaultSLS; - private sessionTable = new Map(); - private nextSession = 1; - private nextSss = 200; - private volumeName: string; - private serverName: string; - private pendingWrites = new Map(); - private nextWriteTid = 1; - - constructor(stack: LocalTalkStack, fs: VirtualFS, opts?: { volumeName?: string; serverName?: string }) { - this.stack = stack; - this.fs = fs; - this.volumeName = opts?.volumeName ?? 'Browser Share'; - this.serverName = opts?.serverName ?? 'ClassicStack'; - new AtpServer(stack, this.sls, (req) => this.onSls(req)); - } - - socket(): number { - return this.sls; - } - - listSessions(): AfpSessionInfo[] { - return [...this.sessionTable.values()] - .filter((s) => !s.closed) - .sort((a, b) => a.id - b.id) - .map((s) => ({ - id: s.id, - network: s.peerNet, - node: s.peerNode, - loggedIn: s.loggedIn, - userName: s.userName, - lastSeen: s.lastRx, - })); - } - - listOpenFiles(): AfpOpenFileInfo[] { - const out: AfpOpenFileInfo[] = []; - for (const s of this.sessionTable.values()) { - if (s.closed) continue; - for (const [forkRef, fork] of s.forks) { - out.push({ - sessionId: s.id, - userName: s.userName, - forkRef, - name: fork.name, - resource: fork.resource, - }); - } - } - out.sort((a, b) => a.sessionId - b.sessionId || a.forkRef - b.forkRef); - return out; - } - - /** Push a server message (id 0 = every live session). Macintosh clients fetch via FPGetSrvrMsg. */ - async sendMessage(sessionId: number, text: string): Promise { - const targets = this.targetSessions(sessionId); - for (const sess of targets) { - sess.serverMsg = text; - await this.sendAttention(sess, asp.AttnMsg); - } - } - - /** - * Disconnect with optional warning text (AppleShare two-phase attention). - * minutes 0 = disconnect after the fetch grace. - */ - async disconnectSession(sessionId: number, text: string, minutes: number): Promise { - const targets = this.targetSessions(sessionId); - const mins = Math.max(0, minutes | 0); - let code = asp.AttnServerGoingDown | asp.AttnNoReconnect | asp.attnTime(mins); - if (text) code |= asp.AttnMsg; - for (const sess of targets) { - if (text) sess.serverMsg = text; - await this.sendAttention(sess, code); - this.scheduleDisconnect(sess, !!text, mins * 60_000); - } - } - - private targetSessions(sessionId: number): Session[] { - if (sessionId !== 0) { - const sess = this.sessionTable.get(sessionId); - if (!sess || sess.closed) throw new Error('AFP: no such session'); - return [sess]; - } - return [...this.sessionTable.values()].filter((s) => !s.closed); - } - - private scheduleDisconnect(sess: Session, hasMsg: boolean, waitMs: number): void { - if (sess.disconnectTimer) clearTimeout(sess.disconnectTimer); - const finish = () => { - void this.finishDisconnect(sess, hasMsg, waitMs > 0); - }; - if (waitMs > 0) { - sess.disconnectTimer = setTimeout(finish, waitMs); - } else { - finish(); - } - } - - private async finishDisconnect(sess: Session, hasMsg: boolean, sendFinal: boolean): Promise { - sess.disconnectTimer = null; - if (sess.closed) return; - if (sendFinal) { - let code = asp.AttnServerGoingDown | asp.AttnNoReconnect; - if (hasMsg) code |= asp.AttnMsg; - await this.sendAttention(sess, code); - } - if (hasMsg) { - await new Promise((r) => setTimeout(r, MESSAGE_FETCH_GRACE_MS)); - if (sess.closed) return; - } - await this.sendCloseSession(sess); - this.teardownSession(sess); - } - - private teardownSession(sess: Session): void { - if (sess.closed) return; - sess.closed = true; - if (sess.disconnectTimer) { - clearTimeout(sess.disconnectTimer); - sess.disconnectTimer = null; - } - this.sessionTable.delete(sess.id); - log.info(`AFP session ${sess.id} closed (${sess.peerNet}.${sess.peerNode})`, 'afp'); - } - - private allocTid(): number { - let tid = this.nextWriteTid++ & 0xffff; - if (tid === 0) tid = this.nextWriteTid++ & 0xffff; - while (this.pendingWrites.has(tid)) { - tid = this.nextWriteTid++ & 0xffff; - if (tid === 0) tid = this.nextWriteTid++ & 0xffff; - } - return tid; - } - - private async sendAttention(sess: Session, code: number): Promise { - if (sess.closed || !code) return; - const pkt = atp.encodePacket( - { - control: atp.TREQ, - bitmap: 0x01, - transId: this.allocTid(), - userData: asp.packAttention(sess.id, code), - }, - new Uint8Array(), - ); - await sess.atp.sendTo(sess.peerNet, sess.peerNode, sess.wss, pkt); - } - - private async sendCloseSession(sess: Session): Promise { - if (sess.closed) return; - const pkt = atp.encodePacket( - { - control: atp.TREQ, - bitmap: 0x01, - transId: this.allocTid(), - userData: asp.packClose(sess.id), - }, - new Uint8Array(), - ); - await sess.atp.sendTo(sess.peerNet, sess.peerNode, sess.wss, pkt); - } - - private async onSls(req: AtpIncoming): Promise { - const { spFunc, b1, word } = asp.unpackUserData(req.header.userData); - if (spFunc === asp.SPFuncTickle) { - const sess = this.sessionTable.get(b1); - if (sess && !sess.closed) sess.lastRx = Date.now(); - await req.reply(0, new Uint8Array()); - return; - } - if (spFunc === asp.SPFuncGetStatus) { - await req.reply(0, this.serverInfoBlock()); - return; - } - if (spFunc === asp.SPFuncOpenSess) { - if (word !== asp.Version) { - await req.reply(asp.packOpenReply(0, 0, asp.SPErrorBadVersNum), new Uint8Array()); - return; - } - const sid = this.nextSession++ & 0xff; - if (sid === 0) this.nextSession = 1; - const sss = this.nextSss++ & 0xff; - if (sss < 200) this.nextSss = 200; - let sess!: Session; - const atpServer = new AtpServer(this.stack, sss, (r) => this.onSession(sess, r)); - sess = { - id: sid, - wss: b1, - sss, - peerNet: req.dg.srcNetwork, - peerNode: req.dg.srcNode, - loggedIn: false, - userName: '', - volId: 0, - forks: new Map(), - nextFork: 1, - dtRef: 0, - atp: atpServer, - serverMsg: '', - lastRx: Date.now(), - closed: false, - disconnectTimer: null, - }; - atpServer.setResponseHandler((resp) => this.onWriteDataResponse(resp)); - this.sessionTable.set(sid, sess); - await req.reply(asp.packOpenReply(sss, sid, 0), new Uint8Array()); - return; - } - } - - private async onSession(sess: Session, req: AtpIncoming): Promise { - sess.lastRx = Date.now(); - const { spFunc, b1, word } = asp.unpackUserData(req.header.userData); - if (b1 !== sess.id && spFunc !== asp.SPFuncTickle) { - // still process tickles / close - } - if (spFunc === asp.SPFuncTickle) { - await req.reply(0, new Uint8Array()); - return; - } - if (spFunc === asp.SPFuncCloseSess) { - this.teardownSession(sess); - await req.reply(0, new Uint8Array()); - return; - } - if (spFunc === asp.SPFuncWrite) { - await this.handleAspWrite(sess, req, word); - return; - } - if (spFunc === asp.SPFuncCommand) { - const block = req.data; - if (block.length < 1) { - await req.reply(C.ErrParamErr >>> 0, new Uint8Array()); - return; - } - const [body, result] = await this.dispatch(sess, block, false); - await req.reply(result >>> 0, body); - return; - } - void word; - } - - /** - * ASP two-phase write (ClassicStack handleWrite): - * phase 1 — client sends FPWrite/FPAddIcon header only - * phase 2 — we TReq WriteContinue to WSS; client TResp's the data - * phase 3 — we reply to the original aspWrite with the AFP result - */ - private async handleAspWrite(sess: Session, req: AtpIncoming, seq: number): Promise { - const block = req.data; - if (block.length > asp.ATPMaxData) { - await req.reply(asp.SPErrorSizeErr >>> 0, new Uint8Array()); - return; - } - let { want, hdrLen } = writeDataCount(block); - if (want <= 0) { - const [body, result] = await this.dispatch(sess, block, true); - await req.reply(result >>> 0, body); - return; - } - if (want > asp.QuantumSize) want = asp.QuantumSize; - - const tid = this.allocTid(); - const pw: PendingWrite = { - reply: req.reply, - sess, - cmdBlk: block.slice(), - hdrLen, - want, - seq, - data: new Uint8Array(), - attempts: 0, - timer: null, - }; - this.pendingWrites.set(tid, pw); - await this.sendWriteContinue(sess, seq, tid, want); - pw.timer = setInterval(() => { - void this.retryWriteContinue(tid); - }, 2000); - } - - private async sendWriteContinue(sess: Session, seq: number, tid: number, want: number): Promise { - const nPackets = Math.min(Math.max(Math.ceil(want / atp.MaxATPData), 1), atp.MaxResponsePackets); - const bitmap = (1 << nPackets) - 1; - // TREQ|XO with TRel timeout indicator 0 (30s) in low bits. - const pkt = atp.encodePacket( - { - control: atp.TREQ | atp.XO, - bitmap, - transId: tid, - userData: asp.packWriteContinue(sess.id, seq), - }, - asp.writeContinuePayload(want), - ); - await sess.atp.sendTo(sess.peerNet, sess.peerNode, sess.wss, pkt); - } - - private async retryWriteContinue(tid: number): Promise { - const pw = this.pendingWrites.get(tid); - if (!pw) return; - pw.attempts++; - if (pw.attempts >= 8) { - this.clearPendingWrite(tid); - await pw.reply(asp.SPErrorParamErr >>> 0, new Uint8Array()); - return; - } - await this.sendWriteContinue(pw.sess, pw.seq, tid, pw.want); - } - - private clearPendingWrite(tid: number): void { - const pw = this.pendingWrites.get(tid); - if (!pw) return; - if (pw.timer) clearInterval(pw.timer); - this.pendingWrites.delete(tid); - } - - private async onWriteDataResponse(resp: AtpResponse): Promise { - const tid = resp.header.transId; - const pw = this.pendingWrites.get(tid); - if (!pw) return; - - const merged = new Uint8Array(pw.data.length + resp.data.length); - merged.set(pw.data); - merged.set(resp.data, pw.data.length); - pw.data = merged.length > pw.want ? merged.subarray(0, pw.want) : merged; - - if (!resp.eom && pw.data.length < pw.want) return; - - this.clearPendingWrite(tid); - // Release the XO WriteContinue transaction. - const trel = atp.encodePacket({ control: atp.TREL, bitmap: 0, transId: tid, userData: 0 }); - await pw.sess.atp.sendTo(pw.sess.peerNet, pw.sess.peerNode, pw.sess.wss, trel); - - const block = appendWriteData(pw.cmdBlk, pw.hdrLen, pw.data); - const [body, result] = await this.dispatch(pw.sess, block, true); - await pw.reply(result >>> 0, body); - } - - private async dispatch(sess: Session, block: Uint8Array, isWrite: boolean): Promise<[Uint8Array, number]> { - const cmd = block[0]!; - const detail = this.traceDetail(sess, cmd, block); - try { - let reply: [Uint8Array, number]; - switch (cmd) { - case C.CmdLogin: - reply = this.login(sess, block); - break; - case C.CmdLogout: - sess.loggedIn = false; - reply = [new Uint8Array(), C.NoErr]; - break; - case C.CmdGetSrvrParms: - reply = this.getSrvrParms(); - break; - case C.CmdGetSrvrMsg: - reply = this.getSrvrMsg(sess, block); - break; - case C.CmdOpenVol: - reply = this.openVol(sess, block); - break; - case C.CmdCloseVol: - sess.volId = 0; - reply = [new Uint8Array(), C.NoErr]; - break; - case C.CmdEnumerate: - reply = await this.enumerate(block); - break; - case C.CmdCreateDir: - reply = await this.createDir(block); - break; - case C.CmdCreateFile: - reply = await this.createFile(block); - break; - case C.CmdDelete: - reply = await this.deletePath(block); - break; - case C.CmdRename: - reply = await this.rename(block); - break; - case C.CmdOpenFork: - reply = await this.openFork(sess, block); - break; - case C.CmdRead: - reply = await this.readFork(sess, block); - break; - case C.CmdWrite: - reply = await this.writeFork(sess, block, isWrite); - break; - case C.CmdCloseFork: - reply = this.closeFork(sess, block); - break; - case C.CmdSetForkParms: - reply = await this.setForkParms(sess, block); - break; - case C.CmdGetForkParms: - reply = await this.getForkParms(sess, block); - break; - case C.CmdFlush: - case C.CmdFlushFork: - reply = [new Uint8Array(), C.NoErr]; - break; - case C.CmdSetFileDirParms: - case C.CmdSetFileParms: - case C.CmdSetDirParms: - reply = await this.setParms(block); - break; - case C.CmdGetFileDirParms: - reply = await this.getParms(block); - break; - case C.CmdGetVolParms: - reply = this.getVolParms(sess, block); - break; - case C.CmdOpenDT: { - const vol = block.length >= 4 ? be16(block, 2) : 1; - sess.dtRef = vol || 1; - { - const out = new Uint8Array(2); - writeBe16(out, 0, sess.dtRef); - reply = [out, C.NoErr]; - } - break; - } - case C.CmdCloseDT: - sess.dtRef = 0; - reply = [new Uint8Array(), C.NoErr]; - break; - case C.CmdAddIcon: - reply = await this.addIcon(block, isWrite); - break; - case C.CmdGetIcon: - reply = await this.getIcon(block); - break; - case C.CmdGetIconInfo: - reply = await this.getIconInfo(block); - break; - case C.CmdAddAPPL: - case C.CmdRemoveAPPL: - case C.CmdGetAPPL: - reply = [new Uint8Array(), C.ErrObjectNotFnd]; - break; - default: - reply = [new Uint8Array(), C.ErrCallNotSuppt]; - break; - } - const [body, result] = reply; - log.info( - `${afpCmdName(cmd)}${detail} → ${afpResultName(result)} (${body.length}b)`, - 'afp', - ); - return reply; - } catch (e) { - log.error(`${afpCmdName(cmd)}${detail} threw: ${e instanceof Error ? e.message : String(e)}`, 'afp'); - console.error('AFP dispatch', cmd, e); - return [new Uint8Array(), C.ErrParamErr]; - } - } - - private forkFile(sess: Session, ref: number): string { - const fork = sess.forks.get(ref); - if (!fork) return ''; - return ` “${fork.name}”${fork.resource ? ' rsrc' : ' data'}`; - } - - private traceDetail(sess: Session, cmd: number, block: Uint8Array): string { - try { - if (cmd === C.CmdOpenVol && block.length >= 5) { - const nameLen = block[4]!; - const name = decodeMacRoman(block.subarray(5, 5 + nameLen)); - return ` "${name}" bm=0x${be16(block, 2).toString(16)}`; - } - if (cmd === C.CmdCreateFile && block.length >= 9) { - return ` did=${be32(block, 4)} name=${JSON.stringify(this.readPathName(block, 8))}`; - } - if (cmd === C.CmdOpenFork && block.length >= 13) { - return ` did=${be32(block, 4)} name=${JSON.stringify(this.readPathName(block, 12))} bm=0x${be16(block, 8).toString(16)}`; - } - if (cmd === C.CmdRead && block.length >= 12) { - const ref = be16(block, 2); - return ` ref=${ref} off=${be32(block, 4)} count=${be32(block, 8)}${this.forkFile(sess, ref)}`; - } - if (cmd === C.CmdWrite && block.length >= 12) { - const ref = be16(block, 2); - return ` ref=${ref} off=${be32(block, 4)} count=${be32(block, 8)} data=${Math.max(0, block.length - 12)}b${this.forkFile(sess, ref)}`; - } - if (cmd === C.CmdCloseFork && block.length >= 4) { - const ref = be16(block, 2); - return ` ref=${ref}${this.forkFile(sess, ref)}`; - } - if (cmd === C.CmdGetFileDirParms && block.length >= 12) { - return ` vol=${be16(block, 2)} did=${be32(block, 4)} fbm=0x${be16(block, 8).toString(16)} dbm=0x${be16(block, 10).toString(16)}`; - } - if (cmd === C.CmdGetVolParms && block.length >= 6) { - return ` vol=${be16(block, 2)} bm=0x${be16(block, 4).toString(16)}`; - } - if (cmd === C.CmdEnumerate && block.length >= 16) { - return ` did=${be32(block, 4)} idx=${be16(block, 14)}`; - } - } catch { - /* ignore */ - } - return ''; - } - - private login(sess: Session, block: Uint8Array): [Uint8Array, number] { - // FPLogin: cmd + AFPVersion pstring + UAM pstring + optional userName pstring - sess.userName = parseLoginUserName(block); - sess.loggedIn = true; - return [new Uint8Array(), C.NoErr]; - } - - private getSrvrMsg(sess: Session, block: Uint8Array): [Uint8Array, number] { - const msgType = block.length >= 4 ? be16(block, 2) : 0; - let text = ''; - if (msgType === C.SrvrMsgTypeLogin) text = ''; - else if (msgType === C.SrvrMsgTypeServer) text = sess.serverMsg; - let bytes = encodeMacRoman(text); - if (bytes.length > C.MaxSrvrMsgLen) bytes = bytes.subarray(0, C.MaxSrvrMsgLen); - const out: number[] = []; - appendBe16(out, msgType); - appendBe16(out, C.SrvrMsgBitmapText); - out.push(bytes.length, ...bytes); - return [new Uint8Array(out), C.NoErr]; - } - - private getSrvrParms(): [Uint8Array, number] { - const name = encodeMacRoman(this.volumeName); - const out: number[] = []; - appendBe32(out, C.macTime()); - out.push(1); // vol count - out.push(0); // flags - out.push(name.length, ...name); - return [new Uint8Array(out), C.NoErr]; - } - - private openVol(sess: Session, block: Uint8Array): [Uint8Array, number] { - const reqBitmap = be16(block, 2); - sess.volId = 1; - // Always include VolumeID so the client has a usable handle (ClassicStack). - const bitmap = reqBitmap | C.VolBitmapID; - return [this.packVolReply(bitmap), C.NoErr]; - } - - /** FPGetVolParms — Finder's first post-mount call; missing → mount stalls. */ - private getVolParms(sess: Session, block: Uint8Array): [Uint8Array, number] { - if (block.length < 6) return [new Uint8Array(), C.ErrParamErr]; - const volId = be16(block, 2); - const reqBitmap = be16(block, 4); - if (volId !== sess.volId || sess.volId === 0) return [new Uint8Array(), C.ErrParamErr]; - // Echo exactly the requested bitmap (do not force VolumeID — unlike OpenVol). - return [this.packVolReply(reqBitmap), C.NoErr]; - } - - private packVolReply(bitmap: number): Uint8Array { - const out: number[] = []; - appendBe16(out, bitmap); - if (bitmap & C.VolBitmapAttributes) appendBe16(out, 0); - if (bitmap & C.VolBitmapSignature) appendBe16(out, 2); // Fixed Directory ID - if (bitmap & C.VolBitmapCreateDate) appendBe32(out, C.macTime()); - if (bitmap & C.VolBitmapModDate) appendBe32(out, C.macTime()); - if (bitmap & (1 << 4)) appendBe32(out, C.NoBackupDate); - if (bitmap & C.VolBitmapID) appendBe16(out, 1); - if (bitmap & C.VolBitmapBytesFree) appendBe32(out, 64 * 1024 * 1024); - if (bitmap & C.VolBitmapBytesTotal) appendBe32(out, 128 * 1024 * 1024); - if (bitmap & C.VolBitmapName) { - const name = encodeMacRoman(this.volumeName); - const fixedAfterPtr = 2; // name field is a 2-byte offset into params - // Offset is from start of params (after reply bitmap). Compute fixed size. - let fixed = 0; - if (bitmap & C.VolBitmapAttributes) fixed += 2; - if (bitmap & C.VolBitmapSignature) fixed += 2; - if (bitmap & C.VolBitmapCreateDate) fixed += 4; - if (bitmap & C.VolBitmapModDate) fixed += 4; - if (bitmap & (1 << 4)) fixed += 4; - if (bitmap & C.VolBitmapID) fixed += 2; - if (bitmap & C.VolBitmapBytesFree) fixed += 4; - if (bitmap & C.VolBitmapBytesTotal) fixed += 4; - fixed += fixedAfterPtr; - appendBe16(out, fixed); - out.push(name.length, ...name); - } - return new Uint8Array(out); - } - - private async enumerate(block: Uint8Array): Promise<[Uint8Array, number]> { - // cmd pad volID(2) dirID(4) fileBitmap(2) dirBitmap(2) reqCount(2) startIndex(2) maxReply(2) … - const dirId = be32(block, 4); - const fileBitmap = be16(block, 8); - const dirBitmap = be16(block, 10); - const reqCount = be16(block, 12); - const startIndex = be16(block, 14); - const kids = await this.fs.children(dirId || C.CNIDRoot); - const slice = kids.slice(Math.max(0, startIndex - 1), Math.max(0, startIndex - 1) + reqCount); - if (slice.length === 0) return [new Uint8Array(), C.ErrObjectNotFnd]; - - const records: Uint8Array[] = []; - for (const n of slice) { - const bm = n.isDir ? dirBitmap : fileBitmap; - const offspring = n.isDir ? (await this.fs.children(n.id)).length : 0; - records.push(this.buildEnumRecord(n, bm, n.name, offspring)); - } - const body: number[] = []; - appendBe16(body, fileBitmap); - appendBe16(body, dirBitmap); - appendBe16(body, records.length); - const recBytes = concat(...records); - const out = new Uint8Array(body.length + recBytes.length); - out.set(new Uint8Array(body)); - out.set(recBytes, body.length); - return [out, C.NoErr]; - } - - /** - * ClassicStack enumEntry: [len:1][type:1][params…] padded to even length. - * Params start at entry offset 2 — name offsets are relative to params[0], NOT - * including a pad byte between type and params. - */ - private buildEnumRecord(n: VNode, bitmap: number, displayName: string, offspring = 0): Uint8Array { - const params = this.packParms(n, bitmap, displayName, offspring); - const type = n.isDir ? 0x80 : 0x00; - let len = 2 + params.length; - if (len % 2) len++; - const out = new Uint8Array(len); - out[0] = len; - out[1] = type; - out.set(params, 2); - return out; - } - - /** - * Pack file/dir params in ascending bitmap-bit order (ClassicStack parms.go). - * Long/Short name fields are 2-byte offsets into a trailing Pascal-string area; - * offsets are measured from the start of this parameter block. - */ - private packParms(n: VNode, bitmap: number, displayName: string, offspring = 0): Uint8Array { - const longName = encodeMacRoman(displayName.slice(0, 31)); - const shortName = encodeMacRoman(shortenMacName(displayName)); - - const fixedSize = this.parmsFixedSize(n.isDir, bitmap); - const fixed: number[] = []; - const variable: number[] = []; - - const appendName = (bytes: Uint8Array) => { - appendBe16(fixed, fixedSize + variable.length); - variable.push(bytes.length, ...bytes); - }; - - if (bitmap & C.FDBitmapAttributes) appendBe16(fixed, 0); - if (bitmap & C.FDBitmapParentDID) appendBe32(fixed, n.parentId); - if (bitmap & C.FDBitmapCreateDate) appendBe32(fixed, n.createDate); - if (bitmap & C.FDBitmapModDate) appendBe32(fixed, n.modDate); - if (bitmap & C.FDBitmapBackupDate) appendBe32(fixed, C.NoBackupDate); - if (bitmap & C.FDBitmapFinderInfo) fixed.push(...n.finderInfo.subarray(0, 32)); - if (bitmap & C.FDBitmapLongName) appendName(longName); - if (bitmap & C.FDBitmapShortName) appendName(shortName); - if (!n.isDir) { - if (bitmap & C.FileBitmapFileNum) appendBe32(fixed, n.id); - if (bitmap & C.FileBitmapDataForkLen) appendBe32(fixed, n.data.length); - if (bitmap & C.FileBitmapRsrcForkLen) appendBe32(fixed, n.resource.length); - } else { - if (bitmap & C.DirBitmapDirID) appendBe32(fixed, n.id); - if (bitmap & C.DirBitmapOffspring) appendBe16(fixed, offspring); - if (bitmap & C.DirBitmapOwnerID) appendBe32(fixed, 0); - if (bitmap & C.DirBitmapGroupID) appendBe32(fixed, 0); - if (bitmap & C.DirBitmapAccessRights) appendBe32(fixed, C.DirAccessRights); - } - - if (fixed.length !== fixedSize) { - // Defensive: keep wire self-consistent even if bit sizing drifts. - while (fixed.length < fixedSize) fixed.push(0); - } - return new Uint8Array([...fixed, ...variable]); - } - - private parmsFixedSize(isDir: boolean, bitmap: number): number { - let size = 0; - if (bitmap & C.FDBitmapAttributes) size += 2; - if (bitmap & C.FDBitmapParentDID) size += 4; - if (bitmap & C.FDBitmapCreateDate) size += 4; - if (bitmap & C.FDBitmapModDate) size += 4; - if (bitmap & C.FDBitmapBackupDate) size += 4; - if (bitmap & C.FDBitmapFinderInfo) size += 32; - if (bitmap & C.FDBitmapLongName) size += 2; - if (bitmap & C.FDBitmapShortName) size += 2; - if (!isDir) { - if (bitmap & C.FileBitmapFileNum) size += 4; - if (bitmap & C.FileBitmapDataForkLen) size += 4; - if (bitmap & C.FileBitmapRsrcForkLen) size += 4; - } else { - if (bitmap & C.DirBitmapDirID) size += 4; - if (bitmap & C.DirBitmapOffspring) size += 2; - if (bitmap & C.DirBitmapOwnerID) size += 4; - if (bitmap & C.DirBitmapGroupID) size += 4; - if (bitmap & C.DirBitmapAccessRights) size += 4; - } - return size; - } - - /** Catalog display name: root uses the share/volume name (Finder window title). */ - private displayNameFor(n: VNode): string { - if (n.id === C.CNIDRoot || n.parentId === 1) return this.volumeName; - return n.name; - } - - private async createDir(block: Uint8Array): Promise<[Uint8Array, number]> { - const dirId = be32(block, 4); - const name = this.readPathName(block, 8); - const n = await this.fs.mkdir(dirId || C.CNIDRoot, name); - const out = new Uint8Array(4); - writeBe32(out, 0, n.id); - return [out, C.NoErr]; - } - - private async createFile(block: Uint8Array): Promise<[Uint8Array, number]> { - const dirId = be32(block, 4); - const name = this.readPathName(block, 8); - await this.fs.createFile(dirId || C.CNIDRoot, name, new Uint8Array()); - return [new Uint8Array(), C.NoErr]; - } - - private async deletePath(block: Uint8Array): Promise<[Uint8Array, number]> { - const dirId = be32(block, 4); - const name = this.readPathName(block, 8); - const n = await this.fs.lookup(dirId || C.CNIDRoot, name); - if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; - await this.fs.remove(n.id); - return [new Uint8Array(), C.NoErr]; - } - - private async rename(block: Uint8Array): Promise<[Uint8Array, number]> { - const dirId = be32(block, 4); - // path then new name — simplified: find first path, then second - let o = 8; - if (block[o] === C.PathTypeLongNames) o++; - const oldName = this.readPathAt(block, o); - o = oldName.next; - if (o % 2) o++; - if (block[o] === C.PathTypeLongNames) o++; - const newName = this.readPathAt(block, o); - const n = await this.fs.lookup(dirId || C.CNIDRoot, oldName.name); - if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; - await this.fs.rename(n.id, newName.name); - return [new Uint8Array(), C.NoErr]; - } - - private async openFork(sess: Session, block: Uint8Array): Promise<[Uint8Array, number]> { - // cmd(1) flag(1) volID(2) dirID(4) bitmap(2) accessMode(2) pathType(1) pathname… - if (block.length < 13) return [new Uint8Array(), C.ErrParamErr]; - const forkFlag = block[1]!; - const dirId = be32(block, 4); - const bitmap = be16(block, 8); - const name = this.readPathName(block, 12); - const n = await this.fs.lookup(dirId || C.CNIDRoot, name); - if (!n || n.isDir) { - log.info(`FPOpenFork lookup miss did=${dirId || C.CNIDRoot} name=${JSON.stringify(name)}`, 'afp'); - return [new Uint8Array(), C.ErrObjectNotFnd]; - } - const ref = sess.nextFork++ & 0xffff; - if (ref === 0) sess.nextFork = 1; - const resource = (forkFlag & C.ForkFlagResource) !== 0; - sess.forks.set(ref, { - nodeId: n.id, - resource, - offset: 0, - name: n.name, - bytes: resource ? n.resource : n.data, - }); - const params = this.packParms(n, bitmap, n.name); - const out = new Uint8Array(4 + params.length); - writeBe16(out, 0, bitmap); - writeBe16(out, 2, ref); - out.set(params, 4); - return [out, C.NoErr]; - } - - private async readFork(sess: Session, block: Uint8Array): Promise<[Uint8Array, number]> { - const ref = be16(block, 2); - const offset = be32(block, 4); - const count = be32(block, 8); - const fork = sess.forks.get(ref); - if (!fork) return [new Uint8Array(), C.ErrParamErr]; - const src = fork.bytes; - if (offset >= src.length) return [new Uint8Array(), C.ErrEOFErr]; - const want = Math.min(count, asp.QuantumSize); - const end = Math.min(offset + want, src.length); - const slice = src.subarray(offset, end); - const result = end >= src.length && slice.length < count ? C.ErrEOFErr : C.NoErr; - return [slice.slice(), result]; - } - - private async writeFork(sess: Session, block: Uint8Array, _isWrite: boolean): Promise<[Uint8Array, number]> { - // cmd(1) flag(1) forkRef(2) offset(4) reqCount(4) data… (ClassicStack afpWrite) - // Reply: lastWritten(4) — fork offset one past the last byte written. - if (block.length < 12) return [new Uint8Array(), C.ErrParamErr]; - const fromEnd = (block[1]! & 0x80) !== 0; - const ref = be16(block, 2); - let offset = be32(block, 4); - const reqCount = be32(block, 8); - const fork = sess.forks.get(ref); - if (!fork) return [new Uint8Array(), C.ErrParamErr]; - const n = await this.fs.get(fork.nodeId); - if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; - let data = block.length > 12 ? block.subarray(12) : new Uint8Array(); - if (data.length > reqCount) data = data.subarray(0, reqCount); - const cur = fork.bytes; - if (fromEnd) offset = cur.length + offset; - const need = offset + data.length; - const next = new Uint8Array(Math.max(cur.length, need)); - next.set(cur); - next.set(data, offset); - fork.bytes = next; - if (fork.resource) n.resource = next; - else n.data = next; - n.modDate = C.macTime(); - await this.fs.put(n); - const out = new Uint8Array(4); - writeBe32(out, 0, offset + data.length); // lastWritten - return [out, C.NoErr]; - } - - private closeFork(sess: Session, block: Uint8Array): [Uint8Array, number] { - const ref = be16(block, 2); - sess.forks.delete(ref); - return [new Uint8Array(), C.NoErr]; - } - - /** FPSetForkParms: cmd pad forkRef(2) bitmap(2) forkLen(4). Reply empty. */ - private async setForkParms(sess: Session, block: Uint8Array): Promise<[Uint8Array, number]> { - if (block.length < 10) return [new Uint8Array(), C.ErrParamErr]; - const ref = be16(block, 2); - const bitmap = be16(block, 4); - const forkLen = be32(block, 6); - const fork = sess.forks.get(ref); - if (!fork) return [new Uint8Array(), C.ErrParamErr]; - if (!(bitmap & (C.FileBitmapDataForkLen | C.FileBitmapRsrcForkLen))) { - return [new Uint8Array(), C.ErrBitmapErr]; - } - const n = await this.fs.get(fork.nodeId); - if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; - // Truncate the fork that was opened; bitmap only names which length field is set. - const cur = fork.bytes; - const next = new Uint8Array(forkLen); - next.set(cur.subarray(0, Math.min(cur.length, forkLen))); - fork.bytes = next; - if (fork.resource) n.resource = next; - else n.data = next; - n.modDate = C.macTime(); - await this.fs.put(n); - log.info(`FPSetForkParms ref=${ref} len=${forkLen} ${fork.resource ? 'rsrc' : 'data'}`, 'afp'); - return [new Uint8Array(), C.NoErr]; - } - - /** FPGetForkParms: cmd pad forkRef(2) bitmap(2). Reply: bitmap(2) . */ - private async getForkParms(sess: Session, block: Uint8Array): Promise<[Uint8Array, number]> { - if (block.length < 6) return [new Uint8Array(), C.ErrParamErr]; - const ref = be16(block, 2); - const bitmap = be16(block, 4); - const fork = sess.forks.get(ref); - if (!fork) return [new Uint8Array(), C.ErrParamErr]; - const n = await this.fs.get(fork.nodeId); - if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; - const params = this.packParms(n, bitmap, n.name); - const out = new Uint8Array(2 + params.length); - writeBe16(out, 0, bitmap); - out.set(params, 2); - return [out, C.NoErr]; - } - - private async setParms(block: Uint8Array): Promise<[Uint8Array, number]> { - // cmd pad volID(2) dirID(4) bitmap(2) pathType(1) pathname [pad even] - // ClassicStack afpSetFileDirParms / setParamsFinderInfo — FinderInfo is NOT always first. - if (block.length < 11) return [new Uint8Array(), C.ErrParamErr]; - const dirId = be32(block, 4); - const bitmap = be16(block, 8); - const pathType = block[10]!; - const nameOff = pathType >= 1 && pathType <= 3 ? 11 : 10; - const path = this.readPathAt(block, nameOff); - let o = path.next; - if (o % 2) o++; // word-align params to even offset from start of command block - - let n: VNode | undefined; - if (path.name) { - n = await this.fs.lookup(dirId || C.CNIDRoot, path.name); - } else { - // Empty path → the dirID object itself (often a folder stamp during mount). - n = await this.fs.get(dirId || C.CNIDRoot); - } - if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; - - // Skip fixed fields that precede FinderInfo (bit 5) in ascending bit order. - if (bitmap & C.FDBitmapAttributes) o += 2; - if (bitmap & C.FDBitmapParentDID) o += 4; - if (bitmap & C.FDBitmapCreateDate) { - if (o + 4 <= block.length) n.createDate = be32(block, o); - o += 4; - } - if (bitmap & C.FDBitmapModDate) { - if (o + 4 <= block.length) n.modDate = be32(block, o); - o += 4; - } - if (bitmap & C.FDBitmapBackupDate) o += 4; - if (bitmap & C.FDBitmapFinderInfo) { - if (o + 32 > block.length) return [new Uint8Array(), C.ErrParamErr]; - n.finderInfo = block.subarray(o, o + 32).slice(); - const tc = decodeMacRoman(n.finderInfo.subarray(0, 8)); - log.info( - `FPSet*Parms did=${dirId} name=${JSON.stringify(path.name)} type/creator=${JSON.stringify(tc)} bm=0x${bitmap.toString(16)}`, - 'afp', - ); - } - n.modDate = C.macTime(); - await this.fs.put(n); - return [new Uint8Array(), C.NoErr]; - } - - private async getParms(block: Uint8Array): Promise<[Uint8Array, number]> { - // Request: cmd pad volID(2) dirID(4) fileBitmap(2) dirBitmap(2) pathType(1) pathname… - // Reply (ClassicStack FPGetFileDirParmsRes): fileBitmap(2) dirBitmap(2) type(1) pad(1) params - const dirId = be32(block, 4); - const fileBitmap = be16(block, 8); - const dirBitmap = be16(block, 10); - const name = this.readPathName(block, 12); - - let n: VNode | undefined; - // Parent-of-root (DID 1): Finder names the volume; only the share root is valid. - if (dirId === 1) { - const want = name.trim() || this.volumeName; - if (want.toLowerCase() === this.volumeName.toLowerCase() || name === '') { - n = await this.fs.get(C.CNIDRoot); - } - } else if (name) { - n = await this.fs.lookup(dirId || C.CNIDRoot, name); - } else { - n = await this.fs.get(dirId || C.CNIDRoot); - } - if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; - - const bm = n.isDir ? dirBitmap : fileBitmap; - const offspring = n.isDir ? (await this.fs.children(n.id)).length : 0; - const params = this.packParms(n, bm, this.displayNameFor(n), offspring); - const out = new Uint8Array(6 + params.length); - writeBe16(out, 0, fileBitmap); - writeBe16(out, 2, dirBitmap); - out[4] = n.isDir ? 0x80 : 0; - out[5] = 0; - out.set(params, 6); - return [out, C.NoErr]; - } - - private async addIcon(block: Uint8Array, isWrite: boolean): Promise<[Uint8Array, number]> { - // cmd(1) pad(1) DTRef(2) creator(4) type(4) iconType(1) pad(1) tag(4) size(4) + data - if (block.length < 20) return [new Uint8Array(), C.ErrParamErr]; - const creator = decodeMacRoman(block.subarray(4, 8)); - const type = decodeMacRoman(block.subarray(8, 12)); - const iconType = block[12]!; - const tag = be32(block, 14); - const size = be32(block, 18); - let data = new Uint8Array(); - if (isWrite && block.length > 22) { - data = block.subarray(22, 22 + size).slice(); - } - const key = `icon:${creator}:${type}:${iconType}:${tag}`; - await this.fs.desktopSet(key, data.length ? data : new Uint8Array(size)); - return [new Uint8Array(), C.NoErr]; - } - - private async getIcon(block: Uint8Array): Promise<[Uint8Array, number]> { - if (block.length < 16) return [new Uint8Array(), C.ErrParamErr]; - const creator = decodeMacRoman(block.subarray(4, 8)); - const type = decodeMacRoman(block.subarray(8, 12)); - const iconType = block[12]!; - const length = be16(block, 14); - const key = `icon:${creator}:${type}:${iconType}:`; - // try exact tag 0 first — scan keys is hard; store canonical without tag lookup - const data = (await this.fs.desktopGet(`${key}0`)) ?? (await this.fs.desktopGet(key + '0')); - if (!data) return [new Uint8Array(), C.ErrItemNotFound]; - return [data.subarray(0, length || data.length), C.NoErr]; - } - - private async getIconInfo(block: Uint8Array): Promise<[Uint8Array, number]> { - void block; - return [new Uint8Array(), C.ErrItemNotFound]; - } - - private readPathName(block: Uint8Array, start: number): string { - // ClassicStack: pathType(1) at `start`, then a Pascal string (length byte + bytes). - if (start >= block.length) return ''; - const pathType = block[start]!; - // pathType 1/2/3 are short/long/UTF8; anything else treat `start` as the Pascal length. - const o = pathType >= 1 && pathType <= 3 ? start + 1 : start; - return this.readPascalName(block, o); - } - - /** Pascal pathname at `o`: length byte then that many MacRoman bytes (ClassicStack pascalPathAt). */ - private readPascalName(block: Uint8Array, o: number): string { - if (o >= block.length) return ''; - const n = block[o]!; - if (o + 1 + n > block.length) return ''; - if (n === 0) return ''; - return this.normalizeWirePathName(block.subarray(o + 1, o + 1 + n)); - } - - /** - * AFP long-name path bodies are often leading-NUL + elements joined by NUL - * (ClassicStack afpWirePath). Strip edge NULs; for multi-level under a dirID that - * already names the parent, use the leaf element. - */ - private normalizeWirePathName(raw: Uint8Array): string { - let start = 0; - let end = raw.length; - while (start < end && raw[start] === 0) start++; - while (end > start && raw[end - 1] === 0) end--; - if (start >= end) return ''; - let leafStart = start; - for (let i = start; i < end; i++) { - if (raw[i] === 0) leafStart = i + 1; - } - return decodeMacRoman(raw.subarray(leafStart, end)); - } - - private readPathAt(block: Uint8Array, o: number): { name: string; next: number } { - if (o >= block.length) return { name: '', next: o }; - const n = block[o]!; - if (o + 1 + n > block.length) return { name: '', next: o }; - const name = n === 0 ? '' : this.normalizeWirePathName(block.subarray(o + 1, o + 1 + n)); - return { name, next: o + 1 + n }; - } - - private serverInfoBlock(): Uint8Array { - const serverName = encodeMacRoman(this.serverName); - const machine = encodeMacRoman('ClassicStackWeb'); - const versions = [encodeMacRoman('AFPVersion 2.1'), encodeMacRoman('AFPVersion 2.0')]; - const uams = [encodeMacRoman('No User Authent'), encodeMacRoman('Cleartxt Passwrd')]; - - const headerLen = 10; - let base = headerLen + 1 + serverName.length; - if (base % 2) base++; - const machineOff = base; - const versionsOff = machineOff + 1 + machine.length; - let versionsLen = 1; - for (const v of versions) versionsLen += 1 + v.length; - const uamOff = versionsOff + versionsLen; - let uamLen = 1; - for (const u of uams) uamLen += 1 + u.length; - const total = uamOff + uamLen; - const out = new Uint8Array(total); - writeBe16(out, 0, machineOff); - writeBe16(out, 2, versionsOff); - writeBe16(out, 4, uamOff); - writeBe16(out, 6, 0); - writeBe16(out, 8, C.SrvrInfoSupportsSrvrMsg); - out[headerLen] = serverName.length; - out.set(serverName, headerLen + 1); - out[machineOff] = machine.length; - out.set(machine, machineOff + 1); - out[versionsOff] = versions.length; - let o = versionsOff + 1; - for (const v of versions) { - out[o++] = v.length; - out.set(v, o); - o += v.length; - } - out[uamOff] = uams.length; - o = uamOff + 1; - for (const u of uams) { - out[o++] = u.length; - out.set(u, o); - o += u.length; - } - return out; - } -} - -function concat(...parts: Uint8Array[]): Uint8Array { - const len = parts.reduce((n, p) => n + p.length, 0); - const out = new Uint8Array(len); - let o = 0; - for (const p of parts) { - out.set(p, o); - o += p.length; - } - return out; -} - -/** Rough Mac Roman short name (≤12 chars); enough for AFP ShortName field. */ -function shortenMacName(name: string): string { - if (name.length <= 12) return name; - const dot = name.lastIndexOf('.'); - if (dot > 0 && name.length - dot <= 4) { - const ext = name.slice(dot, dot + 4); - return name.slice(0, Math.max(1, 12 - ext.length)) + ext; - } - return name.slice(0, 12); -} - -/** Bytes to pull in ASP WriteContinue + fixed header length (ClassicStack writeDataCount). */ -function writeDataCount(block: Uint8Array): { want: number; hdrLen: number } { - if (block.length >= 12 && block[0] === C.CmdWrite) { - const n = be32(block, 8) | 0; - if (n < 0) return { want: 0, hdrLen: 0 }; - return { want: n, hdrLen: 12 }; - } - if (block.length >= 20 && block[0] === C.CmdAddIcon) { - return { want: be16(block, 18), hdrLen: 20 }; - } - return { want: 0, hdrLen: 0 }; -} - -/** AFPVersion + UAM Pascal strings; leftover is typically the user name. */ -function parseLoginUserName(block: Uint8Array): string { - let o = 1; - for (let i = 0; i < 2; i++) { - if (o >= block.length) return ''; - const n = block[o]!; - o += 1 + n; - } - if (o >= block.length) return ''; - const n = block[o]!; - if (!n || o + 1 + n > block.length) return ''; - return decodeMacRoman(block.subarray(o + 1, o + 1 + n)); -} - -function appendWriteData(header: Uint8Array, headerLen: number, data: Uint8Array): Uint8Array { - const h = header.length > headerLen ? header.subarray(0, headerLen) : header; - const out = new Uint8Array(h.length + data.length); - out.set(h); - out.set(data, h.length); - return out; -} +/** AFP server over ASP/ATP — one volume backed by VirtualFS. */ + +import * as asp from '../../protocol/asp'; +import * as C from '../../protocol/afp/constants'; +import { appendBe16, appendBe32, be16, be32, writeBe16, writeBe32 } from '../../protocol/binary'; +import { encodeMacRoman, decodeMacRoman } from '../../protocol/macroman'; +import { AtpServer, type AtpIncoming, type AtpResponse } from '../atp-server'; +import type { LocalTalkStack } from '../../net/stack'; +import type { VirtualFS, VNode } from '../../fs/virtual-fs'; +import { cnidOf, requireCnid } from '../../fs/virtual-fs'; +import { fromUnixMs } from '../../fs/catalog-caps'; +import { log } from '../../util/logger'; +import * as atp from '../../protocol/atp'; + +function afpCmdName(cmd: number): string { + return C.afpCmdName(cmd); +} + +function afpResultName(result: number): string { + const s = result | 0; + if (s === C.NoErr) return 'NoErr'; + if (s === C.ErrObjectNotFnd) return 'ObjectNotFound'; + if (s === C.ErrItemNotFound) return 'ItemNotFound'; + if (s === C.ErrParamErr) return 'ParamErr'; + if (s === C.ErrCallNotSuppt) return 'CallNotSupported'; + if (s === C.ErrAccessDenied) return 'AccessDenied'; + if (s === C.ErrEOFErr) return 'EOFErr'; + if (s === C.ErrBitmapErr) return 'BitmapErr'; + return String(s); +} + +interface OpenFork { + nodeId: number; + resource: boolean; + offset: number; + name: string; + /** Fork bytes at open (and after our writes); avoids reloading the node on every FPRead. */ + bytes: Uint8Array; +} + +interface Session { + id: number; + wss: number; + sss: number; + peerNet: number; + peerNode: number; + loggedIn: boolean; + userName: string; + volId: number; + forks: Map; + nextFork: number; + dtRef: number; + atp: AtpServer; + serverMsg: string; + lastRx: number; + closed: boolean; + disconnectTimer: ReturnType | null; +} + +export interface AfpSessionInfo { + id: number; + network: number; + node: number; + loggedIn: boolean; + userName: string; + lastSeen: number; +} + +export interface AfpOpenFileInfo { + sessionId: number; + userName: string; + forkRef: number; + name: string; + resource: boolean; +} + +const MESSAGE_FETCH_GRACE_MS = 1500; + +/** In-flight ASP two-phase write (ClassicStack pendingWrite). */ +interface PendingWrite { + reply: (userData: number, data: Uint8Array) => Promise; + sess: Session; + cmdBlk: Uint8Array; + hdrLen: number; + want: number; + seq: number; + data: Uint8Array; + attempts: number; + timer: ReturnType | null; +} + +export class AfpServer { + private stack: LocalTalkStack; + private fs: VirtualFS; + private sls = asp.DefaultSLS; + private sessionTable = new Map(); + private nextSession = 1; + private nextSss = 200; + private volumeName: string; + private serverName: string; + private pendingWrites = new Map(); + private nextWriteTid = 1; + + constructor(stack: LocalTalkStack, fs: VirtualFS, opts?: { volumeName?: string; serverName?: string }) { + this.stack = stack; + this.fs = fs; + this.volumeName = opts?.volumeName ?? 'Browser Share'; + this.serverName = opts?.serverName ?? 'ClassicStack'; + new AtpServer(stack, this.sls, (req) => this.onSls(req)); + } + + socket(): number { + return this.sls; + } + + listSessions(): AfpSessionInfo[] { + return [...this.sessionTable.values()] + .filter((s) => !s.closed) + .sort((a, b) => a.id - b.id) + .map((s) => ({ + id: s.id, + network: s.peerNet, + node: s.peerNode, + loggedIn: s.loggedIn, + userName: s.userName, + lastSeen: s.lastRx, + })); + } + + listOpenFiles(): AfpOpenFileInfo[] { + const out: AfpOpenFileInfo[] = []; + for (const s of this.sessionTable.values()) { + if (s.closed) continue; + for (const [forkRef, fork] of s.forks) { + out.push({ + sessionId: s.id, + userName: s.userName, + forkRef, + name: fork.name, + resource: fork.resource, + }); + } + } + out.sort((a, b) => a.sessionId - b.sessionId || a.forkRef - b.forkRef); + return out; + } + + /** Push a server message (id 0 = every live session). Macintosh clients fetch via FPGetSrvrMsg. */ + async sendMessage(sessionId: number, text: string): Promise { + const targets = this.targetSessions(sessionId); + for (const sess of targets) { + sess.serverMsg = text; + await this.sendAttention(sess, asp.AttnMsg); + } + } + + /** + * Disconnect with optional warning text (AppleShare two-phase attention). + * minutes 0 = disconnect after the fetch grace. + */ + async disconnectSession(sessionId: number, text: string, minutes: number): Promise { + const targets = this.targetSessions(sessionId); + const mins = Math.max(0, minutes | 0); + let code = asp.AttnServerGoingDown | asp.AttnNoReconnect | asp.attnTime(mins); + if (text) code |= asp.AttnMsg; + for (const sess of targets) { + if (text) sess.serverMsg = text; + await this.sendAttention(sess, code); + this.scheduleDisconnect(sess, !!text, mins * 60_000); + } + } + + private targetSessions(sessionId: number): Session[] { + if (sessionId !== 0) { + const sess = this.sessionTable.get(sessionId); + if (!sess || sess.closed) throw new Error('AFP: no such session'); + return [sess]; + } + return [...this.sessionTable.values()].filter((s) => !s.closed); + } + + private scheduleDisconnect(sess: Session, hasMsg: boolean, waitMs: number): void { + if (sess.disconnectTimer) clearTimeout(sess.disconnectTimer); + const finish = () => { + void this.finishDisconnect(sess, hasMsg, waitMs > 0); + }; + if (waitMs > 0) { + sess.disconnectTimer = setTimeout(finish, waitMs); + } else { + finish(); + } + } + + private async finishDisconnect(sess: Session, hasMsg: boolean, sendFinal: boolean): Promise { + sess.disconnectTimer = null; + if (sess.closed) return; + if (sendFinal) { + let code = asp.AttnServerGoingDown | asp.AttnNoReconnect; + if (hasMsg) code |= asp.AttnMsg; + await this.sendAttention(sess, code); + } + if (hasMsg) { + await new Promise((r) => setTimeout(r, MESSAGE_FETCH_GRACE_MS)); + if (sess.closed) return; + } + await this.sendCloseSession(sess); + this.teardownSession(sess); + } + + private teardownSession(sess: Session): void { + if (sess.closed) return; + sess.closed = true; + if (sess.disconnectTimer) { + clearTimeout(sess.disconnectTimer); + sess.disconnectTimer = null; + } + this.sessionTable.delete(sess.id); + log.info(`AFP session ${sess.id} closed (${sess.peerNet}.${sess.peerNode})`, 'afp'); + } + + private allocTid(): number { + let tid = this.nextWriteTid++ & 0xffff; + if (tid === 0) tid = this.nextWriteTid++ & 0xffff; + while (this.pendingWrites.has(tid)) { + tid = this.nextWriteTid++ & 0xffff; + if (tid === 0) tid = this.nextWriteTid++ & 0xffff; + } + return tid; + } + + private async sendAttention(sess: Session, code: number): Promise { + if (sess.closed || !code) return; + const pkt = atp.encodePacket( + { + control: atp.TREQ, + bitmap: 0x01, + transId: this.allocTid(), + userData: asp.packAttention(sess.id, code), + }, + new Uint8Array(), + ); + await sess.atp.sendTo(sess.peerNet, sess.peerNode, sess.wss, pkt); + } + + private async sendCloseSession(sess: Session): Promise { + if (sess.closed) return; + const pkt = atp.encodePacket( + { + control: atp.TREQ, + bitmap: 0x01, + transId: this.allocTid(), + userData: asp.packClose(sess.id), + }, + new Uint8Array(), + ); + await sess.atp.sendTo(sess.peerNet, sess.peerNode, sess.wss, pkt); + } + + private async onSls(req: AtpIncoming): Promise { + const { spFunc, b1, word } = asp.unpackUserData(req.header.userData); + if (spFunc === asp.SPFuncTickle) { + const sess = this.sessionTable.get(b1); + if (sess && !sess.closed) sess.lastRx = Date.now(); + await req.reply(0, new Uint8Array()); + return; + } + if (spFunc === asp.SPFuncGetStatus) { + await req.reply(0, this.serverInfoBlock()); + return; + } + if (spFunc === asp.SPFuncOpenSess) { + if (word !== asp.Version) { + await req.reply(asp.packOpenReply(0, 0, asp.SPErrorBadVersNum), new Uint8Array()); + return; + } + const sid = this.nextSession++ & 0xff; + if (sid === 0) this.nextSession = 1; + const sss = this.nextSss++ & 0xff; + if (sss < 200) this.nextSss = 200; + let sess!: Session; + const atpServer = new AtpServer(this.stack, sss, (r) => this.onSession(sess, r)); + sess = { + id: sid, + wss: b1, + sss, + peerNet: req.dg.srcNetwork, + peerNode: req.dg.srcNode, + loggedIn: false, + userName: '', + volId: 0, + forks: new Map(), + nextFork: 1, + dtRef: 0, + atp: atpServer, + serverMsg: '', + lastRx: Date.now(), + closed: false, + disconnectTimer: null, + }; + atpServer.setResponseHandler((resp) => this.onWriteDataResponse(resp)); + this.sessionTable.set(sid, sess); + await req.reply(asp.packOpenReply(sss, sid, 0), new Uint8Array()); + return; + } + } + + private async onSession(sess: Session, req: AtpIncoming): Promise { + sess.lastRx = Date.now(); + const { spFunc, b1, word } = asp.unpackUserData(req.header.userData); + if (b1 !== sess.id && spFunc !== asp.SPFuncTickle) { + // still process tickles / close + } + if (spFunc === asp.SPFuncTickle) { + await req.reply(0, new Uint8Array()); + return; + } + if (spFunc === asp.SPFuncCloseSess) { + this.teardownSession(sess); + await req.reply(0, new Uint8Array()); + return; + } + if (spFunc === asp.SPFuncWrite) { + await this.handleAspWrite(sess, req, word); + return; + } + if (spFunc === asp.SPFuncCommand) { + const block = req.data; + if (block.length < 1) { + await req.reply(C.ErrParamErr >>> 0, new Uint8Array()); + return; + } + const [body, result] = await this.dispatch(sess, block, false); + await req.reply(result >>> 0, body); + return; + } + void word; + } + + /** + * ASP two-phase write (ClassicStack handleWrite): + * phase 1 — client sends FPWrite/FPAddIcon header only + * phase 2 — we TReq WriteContinue to WSS; client TResp's the data + * phase 3 — we reply to the original aspWrite with the AFP result + */ + private async handleAspWrite(sess: Session, req: AtpIncoming, seq: number): Promise { + const block = req.data; + if (block.length > asp.ATPMaxData) { + await req.reply(asp.SPErrorSizeErr >>> 0, new Uint8Array()); + return; + } + let { want, hdrLen } = writeDataCount(block); + if (want <= 0) { + const [body, result] = await this.dispatch(sess, block, true); + await req.reply(result >>> 0, body); + return; + } + if (want > asp.QuantumSize) want = asp.QuantumSize; + + const tid = this.allocTid(); + const pw: PendingWrite = { + reply: req.reply, + sess, + cmdBlk: block.slice(), + hdrLen, + want, + seq, + data: new Uint8Array(), + attempts: 0, + timer: null, + }; + this.pendingWrites.set(tid, pw); + await this.sendWriteContinue(sess, seq, tid, want); + pw.timer = setInterval(() => { + void this.retryWriteContinue(tid); + }, 2000); + } + + private async sendWriteContinue(sess: Session, seq: number, tid: number, want: number): Promise { + const nPackets = Math.min(Math.max(Math.ceil(want / atp.MaxATPData), 1), atp.MaxResponsePackets); + const bitmap = (1 << nPackets) - 1; + // TREQ|XO with TRel timeout indicator 0 (30s) in low bits. + const pkt = atp.encodePacket( + { + control: atp.TREQ | atp.XO, + bitmap, + transId: tid, + userData: asp.packWriteContinue(sess.id, seq), + }, + asp.writeContinuePayload(want), + ); + await sess.atp.sendTo(sess.peerNet, sess.peerNode, sess.wss, pkt); + } + + private async retryWriteContinue(tid: number): Promise { + const pw = this.pendingWrites.get(tid); + if (!pw) return; + pw.attempts++; + if (pw.attempts >= 8) { + this.clearPendingWrite(tid); + await pw.reply(asp.SPErrorParamErr >>> 0, new Uint8Array()); + return; + } + await this.sendWriteContinue(pw.sess, pw.seq, tid, pw.want); + } + + private clearPendingWrite(tid: number): void { + const pw = this.pendingWrites.get(tid); + if (!pw) return; + if (pw.timer) clearInterval(pw.timer); + this.pendingWrites.delete(tid); + } + + private async onWriteDataResponse(resp: AtpResponse): Promise { + const tid = resp.header.transId; + const pw = this.pendingWrites.get(tid); + if (!pw) return; + + const merged = new Uint8Array(pw.data.length + resp.data.length); + merged.set(pw.data); + merged.set(resp.data, pw.data.length); + pw.data = merged.length > pw.want ? merged.subarray(0, pw.want) : merged; + + if (!resp.eom && pw.data.length < pw.want) return; + + this.clearPendingWrite(tid); + // Release the XO WriteContinue transaction. + const trel = atp.encodePacket({ control: atp.TREL, bitmap: 0, transId: tid, userData: 0 }); + await pw.sess.atp.sendTo(pw.sess.peerNet, pw.sess.peerNode, pw.sess.wss, trel); + + const block = appendWriteData(pw.cmdBlk, pw.hdrLen, pw.data); + const [body, result] = await this.dispatch(pw.sess, block, true); + await pw.reply(result >>> 0, body); + } + + private async dispatch(sess: Session, block: Uint8Array, isWrite: boolean): Promise<[Uint8Array, number]> { + const cmd = block[0]!; + const detail = this.traceDetail(sess, cmd, block); + try { + let reply: [Uint8Array, number]; + switch (cmd) { + case C.CmdLogin: + reply = this.login(sess, block); + break; + case C.CmdLogout: + sess.loggedIn = false; + reply = [new Uint8Array(), C.NoErr]; + break; + case C.CmdGetSrvrParms: + reply = this.getSrvrParms(); + break; + case C.CmdGetSrvrMsg: + reply = this.getSrvrMsg(sess, block); + break; + case C.CmdOpenVol: + reply = this.openVol(sess, block); + break; + case C.CmdCloseVol: + sess.volId = 0; + reply = [new Uint8Array(), C.NoErr]; + break; + case C.CmdEnumerate: + reply = await this.enumerate(block); + break; + case C.CmdCreateDir: + reply = await this.createDir(block); + break; + case C.CmdCreateFile: + reply = await this.createFile(block); + break; + case C.CmdDelete: + reply = await this.deletePath(block); + break; + case C.CmdRename: + reply = await this.rename(block); + break; + case C.CmdOpenFork: + reply = await this.openFork(sess, block); + break; + case C.CmdRead: + reply = await this.readFork(sess, block); + break; + case C.CmdWrite: + reply = await this.writeFork(sess, block, isWrite); + break; + case C.CmdCloseFork: + reply = this.closeFork(sess, block); + break; + case C.CmdSetForkParms: + reply = await this.setForkParms(sess, block); + break; + case C.CmdGetForkParms: + reply = await this.getForkParms(sess, block); + break; + case C.CmdFlush: + case C.CmdFlushFork: + reply = [new Uint8Array(), C.NoErr]; + break; + case C.CmdSetFileDirParms: + case C.CmdSetFileParms: + case C.CmdSetDirParms: + reply = await this.setParms(block); + break; + case C.CmdGetFileDirParms: + reply = await this.getParms(block); + break; + case C.CmdGetVolParms: + reply = this.getVolParms(sess, block); + break; + case C.CmdOpenDT: { + const vol = block.length >= 4 ? be16(block, 2) : 1; + sess.dtRef = vol || 1; + { + const out = new Uint8Array(2); + writeBe16(out, 0, sess.dtRef); + reply = [out, C.NoErr]; + } + break; + } + case C.CmdCloseDT: + sess.dtRef = 0; + reply = [new Uint8Array(), C.NoErr]; + break; + case C.CmdAddIcon: + reply = await this.addIcon(block, isWrite); + break; + case C.CmdGetIcon: + reply = await this.getIcon(block); + break; + case C.CmdGetIconInfo: + reply = await this.getIconInfo(block); + break; + case C.CmdAddAPPL: + case C.CmdRemoveAPPL: + case C.CmdGetAPPL: + reply = [new Uint8Array(), C.ErrObjectNotFnd]; + break; + default: + reply = [new Uint8Array(), C.ErrCallNotSuppt]; + break; + } + const [body, result] = reply; + log.info( + `${afpCmdName(cmd)}${detail} → ${afpResultName(result)} (${body.length}b)`, + 'afp', + ); + return reply; + } catch (e) { + log.error(`${afpCmdName(cmd)}${detail} threw: ${e instanceof Error ? e.message : String(e)}`, 'afp'); + console.error('AFP dispatch', cmd, e); + return [new Uint8Array(), C.ErrParamErr]; + } + } + + private forkFile(sess: Session, ref: number): string { + const fork = sess.forks.get(ref); + if (!fork) return ''; + return ` “${fork.name}”${fork.resource ? ' rsrc' : ' data'}`; + } + + private traceDetail(sess: Session, cmd: number, block: Uint8Array): string { + try { + if (cmd === C.CmdOpenVol && block.length >= 5) { + const nameLen = block[4]!; + const name = decodeMacRoman(block.subarray(5, 5 + nameLen)); + return ` "${name}" bm=0x${be16(block, 2).toString(16)}`; + } + if (cmd === C.CmdCreateFile && block.length >= 9) { + return ` did=${be32(block, 4)} name=${JSON.stringify(this.readPathName(block, 8))}`; + } + if (cmd === C.CmdOpenFork && block.length >= 13) { + return ` did=${be32(block, 4)} name=${JSON.stringify(this.readPathName(block, 12))} bm=0x${be16(block, 8).toString(16)}`; + } + if (cmd === C.CmdRead && block.length >= 12) { + const ref = be16(block, 2); + return ` ref=${ref} off=${be32(block, 4)} count=${be32(block, 8)}${this.forkFile(sess, ref)}`; + } + if (cmd === C.CmdWrite && block.length >= 12) { + const ref = be16(block, 2); + return ` ref=${ref} off=${be32(block, 4)} count=${be32(block, 8)} data=${Math.max(0, block.length - 12)}b${this.forkFile(sess, ref)}`; + } + if (cmd === C.CmdCloseFork && block.length >= 4) { + const ref = be16(block, 2); + return ` ref=${ref}${this.forkFile(sess, ref)}`; + } + if (cmd === C.CmdGetFileDirParms && block.length >= 12) { + return ` vol=${be16(block, 2)} did=${be32(block, 4)} fbm=0x${be16(block, 8).toString(16)} dbm=0x${be16(block, 10).toString(16)}`; + } + if (cmd === C.CmdGetVolParms && block.length >= 6) { + return ` vol=${be16(block, 2)} bm=0x${be16(block, 4).toString(16)}`; + } + if (cmd === C.CmdEnumerate && block.length >= 16) { + return ` did=${be32(block, 4)} idx=${be16(block, 14)}`; + } + } catch { + /* ignore */ + } + return ''; + } + + private login(sess: Session, block: Uint8Array): [Uint8Array, number] { + // FPLogin: cmd + AFPVersion pstring + UAM pstring + optional userName pstring + sess.userName = parseLoginUserName(block); + sess.loggedIn = true; + return [new Uint8Array(), C.NoErr]; + } + + private getSrvrMsg(sess: Session, block: Uint8Array): [Uint8Array, number] { + const msgType = block.length >= 4 ? be16(block, 2) : 0; + let text = ''; + if (msgType === C.SrvrMsgTypeLogin) text = ''; + else if (msgType === C.SrvrMsgTypeServer) text = sess.serverMsg; + let bytes = encodeMacRoman(text); + if (bytes.length > C.MaxSrvrMsgLen) bytes = bytes.subarray(0, C.MaxSrvrMsgLen); + const out: number[] = []; + appendBe16(out, msgType); + appendBe16(out, C.SrvrMsgBitmapText); + out.push(bytes.length, ...bytes); + return [new Uint8Array(out), C.NoErr]; + } + + private getSrvrParms(): [Uint8Array, number] { + const name = encodeMacRoman(this.volumeName); + const out: number[] = []; + appendBe32(out, C.macTime()); + out.push(1); // vol count + out.push(0); // flags + out.push(name.length, ...name); + return [new Uint8Array(out), C.NoErr]; + } + + private openVol(sess: Session, block: Uint8Array): [Uint8Array, number] { + const reqBitmap = be16(block, 2); + sess.volId = 1; + // Always include VolumeID so the client has a usable handle (ClassicStack). + const bitmap = reqBitmap | C.VolBitmapID; + return [this.packVolReply(bitmap), C.NoErr]; + } + + /** FPGetVolParms — Finder's first post-mount call; missing → mount stalls. */ + private getVolParms(sess: Session, block: Uint8Array): [Uint8Array, number] { + if (block.length < 6) return [new Uint8Array(), C.ErrParamErr]; + const volId = be16(block, 2); + const reqBitmap = be16(block, 4); + if (volId !== sess.volId || sess.volId === 0) return [new Uint8Array(), C.ErrParamErr]; + // Echo exactly the requested bitmap (do not force VolumeID — unlike OpenVol). + return [this.packVolReply(reqBitmap), C.NoErr]; + } + + private packVolReply(bitmap: number): Uint8Array { + const out: number[] = []; + appendBe16(out, bitmap); + if (bitmap & C.VolBitmapAttributes) appendBe16(out, 0); + if (bitmap & C.VolBitmapSignature) appendBe16(out, 2); // Fixed Directory ID + if (bitmap & C.VolBitmapCreateDate) appendBe32(out, C.macTime()); + if (bitmap & C.VolBitmapModDate) appendBe32(out, C.macTime()); + if (bitmap & (1 << 4)) appendBe32(out, C.NoBackupDate); + if (bitmap & C.VolBitmapID) appendBe16(out, 1); + if (bitmap & C.VolBitmapBytesFree) appendBe32(out, 64 * 1024 * 1024); + if (bitmap & C.VolBitmapBytesTotal) appendBe32(out, 128 * 1024 * 1024); + if (bitmap & C.VolBitmapName) { + const name = encodeMacRoman(this.volumeName); + const fixedAfterPtr = 2; // name field is a 2-byte offset into params + // Offset is from start of params (after reply bitmap). Compute fixed size. + let fixed = 0; + if (bitmap & C.VolBitmapAttributes) fixed += 2; + if (bitmap & C.VolBitmapSignature) fixed += 2; + if (bitmap & C.VolBitmapCreateDate) fixed += 4; + if (bitmap & C.VolBitmapModDate) fixed += 4; + if (bitmap & (1 << 4)) fixed += 4; + if (bitmap & C.VolBitmapID) fixed += 2; + if (bitmap & C.VolBitmapBytesFree) fixed += 4; + if (bitmap & C.VolBitmapBytesTotal) fixed += 4; + fixed += fixedAfterPtr; + appendBe16(out, fixed); + out.push(name.length, ...name); + } + return new Uint8Array(out); + } + + private async enumerate(block: Uint8Array): Promise<[Uint8Array, number]> { + // cmd pad volID(2) dirID(4) fileBitmap(2) dirBitmap(2) reqCount(2) startIndex(2) maxReply(2) … + const dirId = be32(block, 4); + const fileBitmap = be16(block, 8); + const dirBitmap = be16(block, 10); + const reqCount = be16(block, 12); + const startIndex = be16(block, 14); + const kids = await this.fs.children(dirId || C.CNIDRoot); + const slice = kids.slice(Math.max(0, startIndex - 1), Math.max(0, startIndex - 1) + reqCount); + if (slice.length === 0) return [new Uint8Array(), C.ErrObjectNotFnd]; + + const records: Uint8Array[] = []; + for (const n of slice) { + const bm = n.isDir ? dirBitmap : fileBitmap; + const offspring = n.isDir ? (await this.fs.children(cnidOf(n))).length : 0; + records.push(this.buildEnumRecord(n, bm, n.name, offspring)); + } + const body: number[] = []; + appendBe16(body, fileBitmap); + appendBe16(body, dirBitmap); + appendBe16(body, records.length); + const recBytes = concat(...records); + const out = new Uint8Array(body.length + recBytes.length); + out.set(new Uint8Array(body)); + out.set(recBytes, body.length); + return [out, C.NoErr]; + } + + /** + * ClassicStack enumEntry: [len:1][type:1][params…] padded to even length. + * Params start at entry offset 2 — name offsets are relative to params[0], NOT + * including a pad byte between type and params. + */ + private buildEnumRecord(n: VNode, bitmap: number, displayName: string, offspring = 0): Uint8Array { + const params = this.packParms(n, bitmap, displayName, offspring); + const type = n.isDir ? 0x80 : 0x00; + let len = 2 + params.length; + if (len % 2) len++; + const out = new Uint8Array(len); + out[0] = len; + out[1] = type; + out.set(params, 2); + return out; + } + + /** + * Pack file/dir params in ascending bitmap-bit order (ClassicStack parms.go). + * Long/Short name fields are 2-byte offsets into a trailing Pascal-string area; + * offsets are measured from the start of this parameter block. + */ + private packParms(n: VNode, bitmap: number, displayName: string, offspring = 0): Uint8Array { + const longName = encodeMacRoman(displayName.slice(0, 31)); + const shortName = encodeMacRoman(shortenMacName(displayName)); + + const fixedSize = this.parmsFixedSize(n.isDir, bitmap); + const fixed: number[] = []; + const variable: number[] = []; + + const appendName = (bytes: Uint8Array) => { + appendBe16(fixed, fixedSize + variable.length); + variable.push(bytes.length, ...bytes); + }; + + if (bitmap & C.FDBitmapAttributes) appendBe16(fixed, 0); + if (bitmap & C.FDBitmapParentDID) appendBe32(fixed, requireCnid(n).parentId); + if (bitmap & C.FDBitmapCreateDate) appendBe32(fixed, fromUnixMs(n.createDate)); + if (bitmap & C.FDBitmapModDate) appendBe32(fixed, fromUnixMs(n.modDate)); + if (bitmap & C.FDBitmapBackupDate) appendBe32(fixed, C.NoBackupDate); + if (bitmap & C.FDBitmapFinderInfo) fixed.push(...n.finderInfo.subarray(0, 32)); + if (bitmap & C.FDBitmapLongName) appendName(longName); + if (bitmap & C.FDBitmapShortName) appendName(shortName); + if (!n.isDir) { + if (bitmap & C.FileBitmapFileNum) appendBe32(fixed, cnidOf(n)); + if (bitmap & C.FileBitmapDataForkLen) appendBe32(fixed, n.data.length); + if (bitmap & C.FileBitmapRsrcForkLen) appendBe32(fixed, n.resource.length); + } else { + if (bitmap & C.DirBitmapDirID) appendBe32(fixed, cnidOf(n)); + if (bitmap & C.DirBitmapOffspring) appendBe16(fixed, offspring); + if (bitmap & C.DirBitmapOwnerID) appendBe32(fixed, 0); + if (bitmap & C.DirBitmapGroupID) appendBe32(fixed, 0); + if (bitmap & C.DirBitmapAccessRights) appendBe32(fixed, C.DirAccessRights); + } + + if (fixed.length !== fixedSize) { + // Defensive: keep wire self-consistent even if bit sizing drifts. + while (fixed.length < fixedSize) fixed.push(0); + } + return new Uint8Array([...fixed, ...variable]); + } + + private parmsFixedSize(isDir: boolean, bitmap: number): number { + let size = 0; + if (bitmap & C.FDBitmapAttributes) size += 2; + if (bitmap & C.FDBitmapParentDID) size += 4; + if (bitmap & C.FDBitmapCreateDate) size += 4; + if (bitmap & C.FDBitmapModDate) size += 4; + if (bitmap & C.FDBitmapBackupDate) size += 4; + if (bitmap & C.FDBitmapFinderInfo) size += 32; + if (bitmap & C.FDBitmapLongName) size += 2; + if (bitmap & C.FDBitmapShortName) size += 2; + if (!isDir) { + if (bitmap & C.FileBitmapFileNum) size += 4; + if (bitmap & C.FileBitmapDataForkLen) size += 4; + if (bitmap & C.FileBitmapRsrcForkLen) size += 4; + } else { + if (bitmap & C.DirBitmapDirID) size += 4; + if (bitmap & C.DirBitmapOffspring) size += 2; + if (bitmap & C.DirBitmapOwnerID) size += 4; + if (bitmap & C.DirBitmapGroupID) size += 4; + if (bitmap & C.DirBitmapAccessRights) size += 4; + } + return size; + } + + /** Catalog display name: root uses the share/volume name (Finder window title). */ + private displayNameFor(n: VNode): string { + if (cnidOf(n) === C.CNIDRoot || requireCnid(n).parentId === 1) return this.volumeName; + return n.name; + } + + private async createDir(block: Uint8Array): Promise<[Uint8Array, number]> { + const dirId = be32(block, 4); + const name = this.readPathName(block, 8); + const n = await this.fs.mkdir(dirId || C.CNIDRoot, name); + const out = new Uint8Array(4); + writeBe32(out, 0, cnidOf(n)); + return [out, C.NoErr]; + } + + private async createFile(block: Uint8Array): Promise<[Uint8Array, number]> { + const dirId = be32(block, 4); + const name = this.readPathName(block, 8); + await this.fs.createFile(dirId || C.CNIDRoot, name, new Uint8Array()); + return [new Uint8Array(), C.NoErr]; + } + + private async deletePath(block: Uint8Array): Promise<[Uint8Array, number]> { + const dirId = be32(block, 4); + const name = this.readPathName(block, 8); + const n = await this.fs.lookup(dirId || C.CNIDRoot, name); + if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; + await this.fs.remove(cnidOf(n)); + return [new Uint8Array(), C.NoErr]; + } + + private async rename(block: Uint8Array): Promise<[Uint8Array, number]> { + const dirId = be32(block, 4); + // path then new name — simplified: find first path, then second + let o = 8; + if (block[o] === C.PathTypeLongNames) o++; + const oldName = this.readPathAt(block, o); + o = oldName.next; + if (o % 2) o++; + if (block[o] === C.PathTypeLongNames) o++; + const newName = this.readPathAt(block, o); + const n = await this.fs.lookup(dirId || C.CNIDRoot, oldName.name); + if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; + await this.fs.rename(cnidOf(n), newName.name); + return [new Uint8Array(), C.NoErr]; + } + + private async openFork(sess: Session, block: Uint8Array): Promise<[Uint8Array, number]> { + // cmd(1) flag(1) volID(2) dirID(4) bitmap(2) accessMode(2) pathType(1) pathname… + if (block.length < 13) return [new Uint8Array(), C.ErrParamErr]; + const forkFlag = block[1]!; + const dirId = be32(block, 4); + const bitmap = be16(block, 8); + const name = this.readPathName(block, 12); + const n = await this.fs.lookup(dirId || C.CNIDRoot, name); + if (!n || n.isDir) { + log.info(`FPOpenFork lookup miss did=${dirId || C.CNIDRoot} name=${JSON.stringify(name)}`, 'afp'); + return [new Uint8Array(), C.ErrObjectNotFnd]; + } + const ref = sess.nextFork++ & 0xffff; + if (ref === 0) sess.nextFork = 1; + const resource = (forkFlag & C.ForkFlagResource) !== 0; + sess.forks.set(ref, { + nodeId: cnidOf(n), + resource, + offset: 0, + name: n.name, + bytes: resource ? n.resource : n.data, + }); + const params = this.packParms(n, bitmap, n.name); + const out = new Uint8Array(4 + params.length); + writeBe16(out, 0, bitmap); + writeBe16(out, 2, ref); + out.set(params, 4); + return [out, C.NoErr]; + } + + private async readFork(sess: Session, block: Uint8Array): Promise<[Uint8Array, number]> { + const ref = be16(block, 2); + const offset = be32(block, 4); + const count = be32(block, 8); + const fork = sess.forks.get(ref); + if (!fork) return [new Uint8Array(), C.ErrParamErr]; + const src = fork.bytes; + if (offset >= src.length) return [new Uint8Array(), C.ErrEOFErr]; + const want = Math.min(count, asp.QuantumSize); + const end = Math.min(offset + want, src.length); + const slice = src.subarray(offset, end); + const result = end >= src.length && slice.length < count ? C.ErrEOFErr : C.NoErr; + return [slice.slice(), result]; + } + + private async writeFork(sess: Session, block: Uint8Array, _isWrite: boolean): Promise<[Uint8Array, number]> { + // cmd(1) flag(1) forkRef(2) offset(4) reqCount(4) data… (ClassicStack afpWrite) + // Reply: lastWritten(4) — fork offset one past the last byte written. + if (block.length < 12) return [new Uint8Array(), C.ErrParamErr]; + const fromEnd = (block[1]! & 0x80) !== 0; + const ref = be16(block, 2); + let offset = be32(block, 4); + const reqCount = be32(block, 8); + const fork = sess.forks.get(ref); + if (!fork) return [new Uint8Array(), C.ErrParamErr]; + const n = await this.fs.get(fork.nodeId); + if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; + let data = block.length > 12 ? block.subarray(12) : new Uint8Array(); + if (data.length > reqCount) data = data.subarray(0, reqCount); + const cur = fork.bytes; + if (fromEnd) offset = cur.length + offset; + const need = offset + data.length; + const next = new Uint8Array(Math.max(cur.length, need)); + next.set(cur); + next.set(data, offset); + fork.bytes = next; + if (fork.resource) n.resource = next; + else n.data = next; + n.modDate = C.macTime(); + await this.fs.put(n); + const out = new Uint8Array(4); + writeBe32(out, 0, offset + data.length); // lastWritten + return [out, C.NoErr]; + } + + private closeFork(sess: Session, block: Uint8Array): [Uint8Array, number] { + const ref = be16(block, 2); + sess.forks.delete(ref); + return [new Uint8Array(), C.NoErr]; + } + + /** FPSetForkParms: cmd pad forkRef(2) bitmap(2) forkLen(4). Reply empty. */ + private async setForkParms(sess: Session, block: Uint8Array): Promise<[Uint8Array, number]> { + if (block.length < 10) return [new Uint8Array(), C.ErrParamErr]; + const ref = be16(block, 2); + const bitmap = be16(block, 4); + const forkLen = be32(block, 6); + const fork = sess.forks.get(ref); + if (!fork) return [new Uint8Array(), C.ErrParamErr]; + if (!(bitmap & (C.FileBitmapDataForkLen | C.FileBitmapRsrcForkLen))) { + return [new Uint8Array(), C.ErrBitmapErr]; + } + const n = await this.fs.get(fork.nodeId); + if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; + // Truncate the fork that was opened; bitmap only names which length field is set. + const cur = fork.bytes; + const next = new Uint8Array(forkLen); + next.set(cur.subarray(0, Math.min(cur.length, forkLen))); + fork.bytes = next; + if (fork.resource) n.resource = next; + else n.data = next; + n.modDate = C.macTime(); + await this.fs.put(n); + log.info(`FPSetForkParms ref=${ref} len=${forkLen} ${fork.resource ? 'rsrc' : 'data'}`, 'afp'); + return [new Uint8Array(), C.NoErr]; + } + + /** FPGetForkParms: cmd pad forkRef(2) bitmap(2). Reply: bitmap(2) . */ + private async getForkParms(sess: Session, block: Uint8Array): Promise<[Uint8Array, number]> { + if (block.length < 6) return [new Uint8Array(), C.ErrParamErr]; + const ref = be16(block, 2); + const bitmap = be16(block, 4); + const fork = sess.forks.get(ref); + if (!fork) return [new Uint8Array(), C.ErrParamErr]; + const n = await this.fs.get(fork.nodeId); + if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; + const params = this.packParms(n, bitmap, n.name); + const out = new Uint8Array(2 + params.length); + writeBe16(out, 0, bitmap); + out.set(params, 2); + return [out, C.NoErr]; + } + + private async setParms(block: Uint8Array): Promise<[Uint8Array, number]> { + // cmd pad volID(2) dirID(4) bitmap(2) pathType(1) pathname [pad even] + // ClassicStack afpSetFileDirParms / setParamsFinderInfo — FinderInfo is NOT always first. + if (block.length < 11) return [new Uint8Array(), C.ErrParamErr]; + const dirId = be32(block, 4); + const bitmap = be16(block, 8); + const pathType = block[10]!; + const nameOff = pathType >= 1 && pathType <= 3 ? 11 : 10; + const path = this.readPathAt(block, nameOff); + let o = path.next; + if (o % 2) o++; // word-align params to even offset from start of command block + + let n: VNode | undefined; + if (path.name) { + n = await this.fs.lookup(dirId || C.CNIDRoot, path.name); + } else { + // Empty path → the dirID object itself (often a folder stamp during mount). + n = await this.fs.get(dirId || C.CNIDRoot); + } + if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; + + // Skip fixed fields that precede FinderInfo (bit 5) in ascending bit order. + if (bitmap & C.FDBitmapAttributes) o += 2; + if (bitmap & C.FDBitmapParentDID) o += 4; + if (bitmap & C.FDBitmapCreateDate) { + if (o + 4 <= block.length) n.createDate = be32(block, o); + o += 4; + } + if (bitmap & C.FDBitmapModDate) { + if (o + 4 <= block.length) n.modDate = be32(block, o); + o += 4; + } + if (bitmap & C.FDBitmapBackupDate) o += 4; + if (bitmap & C.FDBitmapFinderInfo) { + if (o + 32 > block.length) return [new Uint8Array(), C.ErrParamErr]; + n.finderInfo = block.subarray(o, o + 32).slice(); + const tc = decodeMacRoman(n.finderInfo.subarray(0, 8)); + log.info( + `FPSet*Parms did=${dirId} name=${JSON.stringify(path.name)} type/creator=${JSON.stringify(tc)} bm=0x${bitmap.toString(16)}`, + 'afp', + ); + } + n.modDate = C.macTime(); + await this.fs.put(n); + return [new Uint8Array(), C.NoErr]; + } + + private async getParms(block: Uint8Array): Promise<[Uint8Array, number]> { + // Request: cmd pad volID(2) dirID(4) fileBitmap(2) dirBitmap(2) pathType(1) pathname… + // Reply (ClassicStack FPGetFileDirParmsRes): fileBitmap(2) dirBitmap(2) type(1) pad(1) params + const dirId = be32(block, 4); + const fileBitmap = be16(block, 8); + const dirBitmap = be16(block, 10); + const name = this.readPathName(block, 12); + + let n: VNode | undefined; + // Parent-of-root (DID 1): Finder names the volume; only the share root is valid. + if (dirId === 1) { + const want = name.trim() || this.volumeName; + if (want.toLowerCase() === this.volumeName.toLowerCase() || name === '') { + n = await this.fs.get(C.CNIDRoot); + } + } else if (name) { + n = await this.fs.lookup(dirId || C.CNIDRoot, name); + } else { + n = await this.fs.get(dirId || C.CNIDRoot); + } + if (!n) return [new Uint8Array(), C.ErrObjectNotFnd]; + + const bm = n.isDir ? dirBitmap : fileBitmap; + const offspring = n.isDir ? (await this.fs.children(cnidOf(n))).length : 0; + const params = this.packParms(n, bm, this.displayNameFor(n), offspring); + const out = new Uint8Array(6 + params.length); + writeBe16(out, 0, fileBitmap); + writeBe16(out, 2, dirBitmap); + out[4] = n.isDir ? 0x80 : 0; + out[5] = 0; + out.set(params, 6); + return [out, C.NoErr]; + } + + private async addIcon(block: Uint8Array, isWrite: boolean): Promise<[Uint8Array, number]> { + // cmd(1) pad(1) DTRef(2) creator(4) type(4) iconType(1) pad(1) tag(4) size(4) + data + if (block.length < 20) return [new Uint8Array(), C.ErrParamErr]; + const creator = decodeMacRoman(block.subarray(4, 8)); + const type = decodeMacRoman(block.subarray(8, 12)); + const iconType = block[12]!; + const tag = be32(block, 14); + const size = be32(block, 18); + let data = new Uint8Array(); + if (isWrite && block.length > 22) { + data = block.subarray(22, 22 + size).slice(); + } + const key = `icon:${creator}:${type}:${iconType}:${tag}`; + await this.fs.desktopSet(key, data.length ? data : new Uint8Array(size)); + return [new Uint8Array(), C.NoErr]; + } + + private async getIcon(block: Uint8Array): Promise<[Uint8Array, number]> { + if (block.length < 16) return [new Uint8Array(), C.ErrParamErr]; + const creator = decodeMacRoman(block.subarray(4, 8)); + const type = decodeMacRoman(block.subarray(8, 12)); + const iconType = block[12]!; + const length = be16(block, 14); + const key = `icon:${creator}:${type}:${iconType}:`; + // try exact tag 0 first — scan keys is hard; store canonical without tag lookup + const data = (await this.fs.desktopGet(`${key}0`)) ?? (await this.fs.desktopGet(key + '0')); + if (!data) return [new Uint8Array(), C.ErrItemNotFound]; + return [data.subarray(0, length || data.length), C.NoErr]; + } + + private async getIconInfo(block: Uint8Array): Promise<[Uint8Array, number]> { + void block; + return [new Uint8Array(), C.ErrItemNotFound]; + } + + private readPathName(block: Uint8Array, start: number): string { + // ClassicStack: pathType(1) at `start`, then a Pascal string (length byte + bytes). + if (start >= block.length) return ''; + const pathType = block[start]!; + // pathType 1/2/3 are short/long/UTF8; anything else treat `start` as the Pascal length. + const o = pathType >= 1 && pathType <= 3 ? start + 1 : start; + return this.readPascalName(block, o); + } + + /** Pascal pathname at `o`: length byte then that many MacRoman bytes (ClassicStack pascalPathAt). */ + private readPascalName(block: Uint8Array, o: number): string { + if (o >= block.length) return ''; + const n = block[o]!; + if (o + 1 + n > block.length) return ''; + if (n === 0) return ''; + return this.normalizeWirePathName(block.subarray(o + 1, o + 1 + n)); + } + + /** + * AFP long-name path bodies are often leading-NUL + elements joined by NUL + * (ClassicStack afpWirePath). Strip edge NULs; for multi-level under a dirID that + * already names the parent, use the leaf element. + */ + private normalizeWirePathName(raw: Uint8Array): string { + let start = 0; + let end = raw.length; + while (start < end && raw[start] === 0) start++; + while (end > start && raw[end - 1] === 0) end--; + if (start >= end) return ''; + let leafStart = start; + for (let i = start; i < end; i++) { + if (raw[i] === 0) leafStart = i + 1; + } + return decodeMacRoman(raw.subarray(leafStart, end)); + } + + private readPathAt(block: Uint8Array, o: number): { name: string; next: number } { + if (o >= block.length) return { name: '', next: o }; + const n = block[o]!; + if (o + 1 + n > block.length) return { name: '', next: o }; + const name = n === 0 ? '' : this.normalizeWirePathName(block.subarray(o + 1, o + 1 + n)); + return { name, next: o + 1 + n }; + } + + private serverInfoBlock(): Uint8Array { + const serverName = encodeMacRoman(this.serverName); + const machine = encodeMacRoman('ClassicStackWeb'); + const versions = [encodeMacRoman('AFPVersion 2.1'), encodeMacRoman('AFPVersion 2.0')]; + const uams = [encodeMacRoman('No User Authent'), encodeMacRoman('Cleartxt Passwrd')]; + + const headerLen = 10; + let base = headerLen + 1 + serverName.length; + if (base % 2) base++; + const machineOff = base; + const versionsOff = machineOff + 1 + machine.length; + let versionsLen = 1; + for (const v of versions) versionsLen += 1 + v.length; + const uamOff = versionsOff + versionsLen; + let uamLen = 1; + for (const u of uams) uamLen += 1 + u.length; + const total = uamOff + uamLen; + const out = new Uint8Array(total); + writeBe16(out, 0, machineOff); + writeBe16(out, 2, versionsOff); + writeBe16(out, 4, uamOff); + writeBe16(out, 6, 0); + writeBe16(out, 8, C.SrvrInfoSupportsSrvrMsg); + out[headerLen] = serverName.length; + out.set(serverName, headerLen + 1); + out[machineOff] = machine.length; + out.set(machine, machineOff + 1); + out[versionsOff] = versions.length; + let o = versionsOff + 1; + for (const v of versions) { + out[o++] = v.length; + out.set(v, o); + o += v.length; + } + out[uamOff] = uams.length; + o = uamOff + 1; + for (const u of uams) { + out[o++] = u.length; + out.set(u, o); + o += u.length; + } + return out; + } +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const len = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(len); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; +} + +/** Rough Mac Roman short name (≤12 chars); enough for AFP ShortName field. */ +function shortenMacName(name: string): string { + if (name.length <= 12) return name; + const dot = name.lastIndexOf('.'); + if (dot > 0 && name.length - dot <= 4) { + const ext = name.slice(dot, dot + 4); + return name.slice(0, Math.max(1, 12 - ext.length)) + ext; + } + return name.slice(0, 12); +} + +/** Bytes to pull in ASP WriteContinue + fixed header length (ClassicStack writeDataCount). */ +function writeDataCount(block: Uint8Array): { want: number; hdrLen: number } { + if (block.length >= 12 && block[0] === C.CmdWrite) { + const n = be32(block, 8) | 0; + if (n < 0) return { want: 0, hdrLen: 0 }; + return { want: n, hdrLen: 12 }; + } + if (block.length >= 20 && block[0] === C.CmdAddIcon) { + return { want: be16(block, 18), hdrLen: 20 }; + } + return { want: 0, hdrLen: 0 }; +} + +/** AFPVersion + UAM Pascal strings; leftover is typically the user name. */ +function parseLoginUserName(block: Uint8Array): string { + let o = 1; + for (let i = 0; i < 2; i++) { + if (o >= block.length) return ''; + const n = block[o]!; + o += 1 + n; + } + if (o >= block.length) return ''; + const n = block[o]!; + if (!n || o + 1 + n > block.length) return ''; + return decodeMacRoman(block.subarray(o + 1, o + 1 + n)); +} + +function appendWriteData(header: Uint8Array, headerLen: number, data: Uint8Array): Uint8Array { + const h = header.length > headerLen ? header.subarray(0, headerLen) : header; + const out = new Uint8Array(h.length + data.length); + out.set(h); + out.set(data, h.length); + return out; +} diff --git a/src/ui/about-dialog.ts b/src/ui/about-dialog.ts index 3d8649a..8dc2e04 100644 --- a/src/ui/about-dialog.ts +++ b/src/ui/about-dialog.ts @@ -18,6 +18,7 @@ const HARALD_KUHR = 'https://github.com/haraldk'; const TWELVE_MONKEYS = 'https://github.com/haraldk/TwelveMonkeys'; const TWELVE_MONKEYS_PICT = 'https://github.com/haraldk/TwelveMonkeys/tree/master/imageio/imageio-pict'; +const ICONS8 = 'https://icons8.com/'; const GPL = 'https://www.gnu.org/licenses/gpl-3.0.html'; function extLink(href: string, label: string): string { @@ -106,6 +107,9 @@ export class AboutDialog extends HTMLElement { ${extLink(TWELVE_MONKEYS_PICT, 'imageio-pict')} (PICT bitmap packing; BSD-3-Clause) +
  • + Icons by ${extLink(ICONS8, 'Icons8')} +