From bc6aaf0774131f0dc385d8324a704bd7ebdd45c5 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Mon, 17 Aug 2026 09:11:17 +1000 Subject: [PATCH 01/12] Extract a protocol-neutral FinderHost so ClassicStack can reuse the Finder UI. FinderWindow now talks RemoteEndpoint/Catalog instead of NBP and AFP client types, package.json exports the shared surface, and the TashTalk app implements the new host. Co-authored-by: Cursor --- package.json | 12 ++ src/fs/empty-catalog.ts | 106 ++++++++++++++++ src/main.ts | 61 +++++----- src/ui/finder-host.ts | 72 +++++++++++ src/ui/finder-window.ts | 263 +++++++++++++++++++++------------------- src/ui/login-dialog.ts | 13 +- 6 files changed, 357 insertions(+), 170 deletions(-) create mode 100644 src/fs/empty-catalog.ts create mode 100644 src/ui/finder-host.ts diff --git a/package.json b/package.json index 96bb2eb..4ec7d9d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,18 @@ "version": "0.1.0", "license": "GPL-3.0", "type": "module", + "exports": { + "./ui/finder": "./src/ui/finder-window.ts", + "./ui/finder-host": "./src/ui/finder-host.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", + "./fs/catalog": "./src/fs/virtual-fs.ts", + "./fs/empty-catalog": "./src/fs/empty-catalog.ts" + }, "scripts": { "dev": "vite", "build": "tsc && vite build", diff --git a/src/fs/empty-catalog.ts b/src/fs/empty-catalog.ts new file mode 100644 index 0000000..d390f78 --- /dev/null +++ b/src/fs/empty-catalog.ts @@ -0,0 +1,106 @@ +/** No-op Catalog used when the Finder has no local share (ClassicStack Go SPA). */ + +import type { ByteRangeReader } from './byte-range'; +import type { ResourceFork, ResourceForkLoadOpts } from './resource-fork'; +import type { + Catalog, + ChildrenBatchListener, + VfsChangeListener, + VNode, +} from './virtual-fs'; + +const ROOT_ID = 2; + +function emptyRoot(): VNode { + return { + 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 { + rootId(): number { + return ROOT_ID; + } + + subscribe(_fn: VfsChangeListener): () => void { + return () => undefined; + } + + beginBatch(): void {} + endBatch(): void {} + + async get(id: number): Promise { + return id === ROOT_ID ? emptyRoot() : undefined; + } + + async ensureContent(id: number): Promise { + return this.get(id); + } + + async children( + _parentId: number, + onBatch?: ChildrenBatchListener, + _signal?: AbortSignal, + ): Promise { + onBatch?.([]); + return []; + } + + async lookup(): Promise { + return undefined; + } + + 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/main.ts b/src/main.ts index c56693e..f8709a9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,12 +4,12 @@ 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 { AfpClient, 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 { FinderWindow, type FinderHost, type RemoteEndpoint } from './ui/finder-window'; import { AppMenuBar } from './ui/app-menubar'; import { LogPanel } from './ui/log-panel'; import { ActivityWindow } from './ui/activity-window'; @@ -194,6 +194,15 @@ async function main(): Promise { void applyNetboot(state); }); + function toEndpoint(s: LookupResult): RemoteEndpoint { + return { + id: s.object, + kind: 'afp', + title: s.object, + subtitle: s.zone && s.zone !== '*' ? s.zone : `${s.network}.${s.node}`, + }; + } + function afpServerKey(s: LookupResult): string { return `${s.object}\0${s.network}.${s.node}:${s.socket}`; } @@ -228,7 +237,7 @@ async function main(): Promise { const key = afpServerListKey(list); const changed = key !== prevKey; lastAfpScanKey = key; - finder.setServers(list); + finder.setServers(list.map(toEndpoint)); if (kind === 'manual' || !prevKey || changed) { log.info(`NBP found ${list.length} AFPServer(s)`, 'nbp'); } @@ -267,19 +276,9 @@ async function main(): Promise { stack && stack.node ? `node ${stack.node.toString(16).padStart(2, '0').toUpperCase()} net ${stack.network}` : '', + localTitle: () => 'Browser Share', - 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() { + async connectTransport() { if (!WebSerialPort.supported()) { alertDialog.show('Web Serial is not supported', WEB_SERIAL_HELP); throw new Error('Web Serial is not supported'); @@ -300,7 +299,7 @@ async function main(): Promise { await stack.startClaim(); }, - async disconnectSerial() { + async disconnectTransport() { stopAfpServerScan(); await remote?.close().catch(() => undefined); remote = null; @@ -318,11 +317,17 @@ async function main(): Promise { }, async refreshNetwork() { - return scanAfpServers('manual'); + const list = await scanAfpServers('manual'); + return list.map(toEndpoint); }, - async beginRemote(h: LookupResult) { + async beginRemote(ep) { if (!atp) throw new Error('not connected'); + const list = nbp ? await 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 remote?.close().catch(() => undefined); remote = await AfpClient.openSession(atp, h.network, h.node, h.socket || asp.DefaultSLS); @@ -330,18 +335,19 @@ async function main(): Promise { attachRemoteNotices(remote); return { serverName: remote.serverName, - versions: remote.versions, + volumes: [], + allowGuest: remote.uams.some((u) => /no user authent/i.test(u)), uams: remote.uams, }; }, - async loginRemote(creds: AfpCredentials) { + async loginRemote(creds) { if (!remote) throw new Error('no AFP session'); await remote.login(creds); return remote.volumes.map((v) => v.name); }, - async openRemoteVolume(name: string) { + async openVolume(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'); @@ -376,17 +382,6 @@ async function main(): Promise { 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; @@ -413,7 +408,7 @@ async function main(): Promise { async resetEnvironment(eraseShare) { log.info(eraseShare ? 'Resetting environment (including Browser Share)' : 'Resetting environment', 'app'); try { - await host.disconnectSerial(); + await host.disconnectTransport?.(); } catch { /* already disconnected */ } diff --git a/src/ui/finder-host.ts b/src/ui/finder-host.ts new file mode 100644 index 0000000..dd2a29a --- /dev/null +++ b/src/ui/finder-host.ts @@ -0,0 +1,72 @@ +/** Protocol-neutral Finder host contract shared with ClassicStack’s Go SPA. */ + +import type { Catalog } from '../fs/virtual-fs'; +import type { NameConflictChoice } from '../fs/name-conflict'; +import type { WelcomePackProgress } from '../fs/welcome-pack'; + +/** File-sharing scheme a sidebar endpoint was discovered on (or this host’s own volumes). */ +export type ShareKind = 'local' | 'afp' | 'smb' | 'ncp' | 'etherdfs'; + +/** One discoverable server or local volume the Finder can open. */ +export interface RemoteEndpoint { + /** Opaque id (NBP name, SMB server, `local:afp:Mac HD`, …). */ + id: string; + kind: ShareKind; + title: string; + subtitle?: string; +} + +/** Result of contacting a remote (or local) endpoint before / after login. */ +export interface SessionInfo { + serverName: string; + volumes: string[]; + allowGuest: boolean; + uams?: string[]; +} + +export type Credentials = + | { kind: 'guest' } + | { kind: 'password'; username: string; password: string }; + +export interface CredentialPromptOptions { + serverName: string; + uams: string[]; + error?: string; + allowGuest: boolean; +} + +/** + * Composition root the Finder talks to for discover / login / mount. + * ClassicStack-web implements this over TashTalk + AFP; ClassicStack’s SPA + * implements it over HTTP to the Go server (no in-browser protocol stack). + */ +export interface FinderHost { + isConnected(): boolean; + nodeLabel(): string; + refreshNetwork(): Promise; + beginRemote(ep: RemoteEndpoint): Promise; + loginRemote(creds: Credentials): Promise; + openVolume(name: string): Promise; + closeRemote(): Promise; + /** IndexedDB Browser Share on the web PWA; null in the Go SPA. */ + localCatalog(): Catalog | null; + promptCredentials(opts: CredentialPromptOptions): Promise; + showAlert(title: string, text: string): void; + promptNameConflict(opts: { + name: string; + isDir: boolean; + suggestedName: string; + }): Promise; + + /** Display name for the local catalog (default “Browser Share”). */ + localTitle?(): string; + dismissLogin?(): void; + /** TashTalk / Web Serial — omitted on the Go SPA. */ + connectTransport?(): Promise; + disconnectTransport?(): Promise; + /** Copy bundled public/welcome files into the local catalog. */ + installWelcomePack?(opts?: WelcomePackProgress): Promise<{ imported: number; skipped: number }>; + seedWelcomePack?( + opts?: WelcomePackProgress, + ): Promise<{ imported: number; skipped: number } | null>; +} diff --git a/src/ui/finder-window.ts b/src/ui/finder-window.ts index 46f6127..cb29877 100644 --- a/src/ui/finder-window.ts +++ b/src/ui/finder-window.ts @@ -1,6 +1,11 @@ import type { Catalog, VNode } from '../fs/virtual-fs'; -import type { LookupResult } from '../services/nbp'; -import type { AfpCredentials, AfpServerInfo } from '../services/afp-client/client'; +import { EmptyCatalog } from '../fs/empty-catalog'; +import type { + Credentials, + FinderHost, + RemoteEndpoint, + SessionInfo, +} from './finder-host'; import { fromMacTime } from '../protocol/afp/constants'; import { decodeMacRoman } from '../protocol/macroman'; import { buildAppleDouble, zipSidecarPath, zipStore, type ZipExportStyle } from '../fs/appledouble'; @@ -53,7 +58,6 @@ import { planItemPlacement, uniqueCopyName, TransferCancelled, - type NameConflictChoice, type PlacementPlan, } from '../fs/name-conflict'; import { decodePict, pictToSvg } from '../fs/pict/pict'; @@ -65,43 +69,7 @@ export type SortKey = 'name' | 'modified' | 'size'; /** Finder file types that open in the Quick Look overlay. */ const PREVIEW_TEXT_MAX_BYTES = 512 * 1024; -export interface FinderHost { - connectSerial(): Promise; - disconnectSerial(): Promise; - refreshNetwork(): Promise; - beginRemote(host: LookupResult): Promise; - loginRemote(creds: AfpCredentials): Promise; - openRemoteVolume(name: string): Promise; - findServer(nbpName: string): Promise; - promptCredentials(opts: { - serverName: string; - uams: string[]; - error?: string; - allowGuest: boolean; - }): Promise; - dismissLogin(): void; - closeRemote(): Promise; - localCatalog(): Catalog; - remoteMeta(): { - nbpName: string; - serverName: string; - volumeName: string; - volumes: string[]; - loggedIn: boolean; - } | null; - isConnected(): boolean; - nodeLabel(): string; - showAlert(title: string, text: string): void; - promptNameConflict(opts: { - name: string; - isDir: boolean; - suggestedName: string; - }): Promise; - /** Copy bundled public/welcome files into Browser Share (skips existing names). */ - installWelcomePack(opts?: WelcomePackProgress): Promise<{ imported: number; skipped: number }>; - /** Import new bundled files once per pack list; returns null when already up to date. */ - seedWelcomePack(opts?: WelcomePackProgress): Promise<{ imported: number; skipped: number } | null>; -} +export type { Credentials, FinderHost, RemoteEndpoint, SessionInfo } from './finder-host'; interface ListItem { key: string; @@ -150,7 +118,7 @@ export class FinderWindow extends HTMLElement { private columnChildren: VNode[][] = []; private selectedId: number | null = null; private nodes: VNode[] = []; - private servers: LookupResult[] = []; + private servers: RemoteEndpoint[] = []; private source: 'local' | 'remote' = 'local'; private status = 'Connect a TashTalk adaptor to begin.'; private statusBusy = false; @@ -162,7 +130,7 @@ export class FinderWindow extends HTMLElement { private remoteVolumes: string[] = []; private remoteBusy = false; private remoteNbpName = ''; - private remoteLookup: LookupResult | null = null; + private remoteEndpoint: RemoteEndpoint | null = null; private eventsBound = false; private dragDepth = 0; /** Folder ids expanded in list-view outline. */ @@ -340,18 +308,38 @@ export class FinderWindow extends HTMLElement { this.invalidateIcons(); } - bind(vfs: Catalog, host: FinderHost): void { - this.localVfs = vfs; - this.catalogs.set('local', vfs); - this.attachCatalog(vfs); + bind(vfs: Catalog | null, host: FinderHost): void { this.host = host; + this.localVfs = vfs ?? host.localCatalog(); + if (this.localVfs) { + this.catalogs.set('local', this.localVfs); + this.attachCatalog(this.localVfs); + } else { + this.attachCatalog(new EmptyCatalog()); + } this.ensureShellEvents(); void this.bootstrapFromLocation().then(() => { this.applyCompactView(); - void this.runWelcomePack({ seed: true }); + if (this.hasLocalShare()) void this.runWelcomePack({ seed: true }); + if (!this.hasTransport()) { + this.setStatus('Select a volume in the sidebar to browse.'); + void this.onRefresh(); + } }); } + private hasLocalShare(): boolean { + return this.localVfs != null; + } + + private localShareTitle(): string { + return this.host?.localTitle?.() || 'Browser Share'; + } + + private hasTransport(): boolean { + return typeof this.host?.connectTransport === 'function'; + } + private attachCatalog(next: Catalog): void { if (this.vfs === next) return; this.vfsUnsub?.(); @@ -397,12 +385,13 @@ export class FinderWindow extends HTMLElement { /** Drop a remote mount (server CloseSession / disconnect attention). */ unmountRemote(status?: string): void { const local = this.localVfs ?? this.host?.localCatalog(); - if (local) this.mountCatalog(local, 'local', 'Browser Share'); + if (local) this.mountCatalog(local, 'local', this.localShareTitle()); + else this.attachCatalog(new EmptyCatalog()); this.remoteOpen = false; this.remoteLoggedIn = false; this.remoteVolumes = []; this.remoteNbpName = ''; - this.remoteLookup = null; + this.remoteEndpoint = null; this.dropRemoteCatalogs(); if (status) this.setStatus(status); void this.reload().then(() => { @@ -660,14 +649,14 @@ export class FinderWindow extends HTMLElement { btn.setAttribute('aria-busy', String(busy)); } - setServers(list: LookupResult[]): void { + setServers(list: RemoteEndpoint[]): void { this.servers = list; if ( this.remoteLoggedIn && - this.remoteLookup && - !list.some((s) => s.object === this.remoteLookup!.object && s.node === this.remoteLookup!.node) + this.remoteEndpoint && + !list.some((s) => s.id === this.remoteEndpoint!.id) ) { - this.servers = [this.remoteLookup, ...list]; + this.servers = [this.remoteEndpoint, ...list]; } this.renderSidebar(); } @@ -760,12 +749,12 @@ export class FinderWindow extends HTMLElement { vol: string; path: string[]; } { - const meta = this.host.remoteMeta(); + const metaId = this.remoteEndpoint?.id ?? ''; return { view: this.view, source: this.source, - share: this.source === 'remote' ? this.remoteNbpName || meta?.nbpName || '' : '', - vol: this.source === 'remote' ? this.pathStack[0]?.name || meta?.volumeName || '' : '', + share: this.source === 'remote' ? this.remoteNbpName || metaId : '', + vol: this.source === 'remote' ? this.pathStack[0]?.name || '' : '', path: this.pathNamesForUrl(), }; } @@ -884,7 +873,7 @@ export class FinderWindow extends HTMLElement { return; } this.showLocalShare(); - this.pathStack = await this.resolvePathNames(state.path, 'Browser Share'); + this.pathStack = await this.resolvePathNames(state.path, this.localShareTitle()); this.cwd = this.pathStack[this.pathStack.length - 1]!.id; await this.reload(); } finally { @@ -896,9 +885,8 @@ export class FinderWindow extends HTMLElement { /** True when this Finder session is already logged in to the named AFP server. */ private remoteServerConnected(share: string): boolean { if (!this.remoteLoggedIn || !share) return false; - const meta = this.host.remoteMeta(); - const nbp = this.remoteNbpName || meta?.nbpName || ''; - return nbp.toLowerCase() === share.toLowerCase() && !!meta?.loggedIn; + const id = this.remoteNbpName || this.remoteEndpoint?.id || ''; + return id.toLowerCase() === share.toLowerCase(); } private canonicalVolumeName(vol: string): string | null { @@ -915,7 +903,8 @@ export class FinderWindow extends HTMLElement { private showLocalShare(): void { const local = this.localVfs ?? this.host.localCatalog(); - this.mountCatalog(local, 'local', 'Browser Share'); + if (!local) return; + this.mountCatalog(local, 'local', this.localShareTitle()); this.remoteOpen = false; } @@ -923,14 +912,14 @@ export class FinderWindow extends HTMLElement { this.showLocalShare(); this.remoteLoggedIn = false; this.remoteVolumes = []; - this.remoteLookup = null; + this.remoteEndpoint = null; this.remoteNbpName = ''; } private async resolvePathNames(names: string[], rootName?: string): Promise<{ id: number; name: string }[]> { const rootId = this.vfs.rootId(); const stack: { id: number; name: string }[] = [ - { id: rootId, name: rootName ?? this.pathStack[0]?.name ?? 'Browser Share' }, + { id: rootId, name: rootName ?? this.pathStack[0]?.name ?? this.localShareTitle() }, ]; let parent = rootId; for (const name of names) { @@ -1231,10 +1220,14 @@ export class FinderWindow extends HTMLElement {
- + ` + : '' + } @@ -1404,18 +1397,14 @@ export class FinderWindow extends HTMLElement { private renderSidebar(): void { const side = this.querySelector('.sidebar'); if (!side) return; - const meta = this.host?.remoteMeta?.() ?? null; - const connectedName = this.remoteNbpName || meta?.nbpName || ''; - const volumes = this.remoteVolumes.length ? this.remoteVolumes : (meta?.volumes ?? []); - const viewingLocal = this.source === 'local'; + const connectedId = this.remoteNbpName || this.remoteEndpoint?.id || ''; + const volumes = this.remoteVolumes; + const viewingLocal = this.source === 'local' && this.hasLocalShare(); const openVol = this.source === 'remote' ? this.pathStack[0]?.name || '' : ''; const viewingServer = this.source === 'remote' && !this.remoteOpen; const servers = this.servers .map((s, i) => { - const connected = - this.remoteLoggedIn && - (s.object === connectedName || - (this.remoteLookup != null && s.node === this.remoteLookup.node && s.socket === this.remoteLookup.socket)); + const connected = this.remoteLoggedIn && s.id === connectedId; const serverSel = viewingServer && connected ? 'selected' : ''; const kids = connected && volumes.length @@ -1432,26 +1421,33 @@ export class FinderWindow extends HTMLElement { const eject = connected ? `` : ''; + const subtitle = s.subtitle ? ` title="${this.escape(s.subtitle)}"` : ''; return ` -
+
- ${this.escape(s.object)} + ${this.escape(s.title)} ${eject}
${kids}`; }) .join(''); - side.innerHTML = ` -
Local
+ const localBlock = this.hasLocalShare() + ? `
Local
- Browser Share + ${this.escape(this.localShareTitle())} -
+
` + : ''; + const netLabel = this.hasTransport() ? 'LocalTalk' : 'Network'; + const emptyNet = this.hasTransport() ? 'No AFP servers' : 'No servers'; + const refreshEnabled = this.host?.isConnected() || !this.hasTransport(); + side.innerHTML = ` + ${localBlock}
- LocalTalk - + ${netLabel} +
- ${servers || '
No AFP servers
'} + ${servers || `
${emptyNet}
`} `; } @@ -1463,7 +1459,7 @@ export class FinderWindow extends HTMLElement { name: i === 0 && this.source === 'remote' && this.remoteNbpName ? `${this.remoteNbpName}:${p.name}` - : p.name || 'Browser Share', + : p.name || this.localShareTitle(), id: p.id, index: i, })); @@ -1535,7 +1531,7 @@ export class FinderWindow extends HTMLElement { const items = this.currentItems(); iconItems = items; if (items.length === 0) { - content.innerHTML = `
Drop files or folders here, or browse the LocalTalk network.
`; + content.innerHTML = `
${this.hasTransport() ? 'Drop files or folders here, or browse the LocalTalk network.' : 'Select a volume in the sidebar to browse.'}
`; } else if (this.view === 'icon') { content.innerHTML = `
${items.map((it) => this.iconHtml(it)).join('')}
`; } else if (this.view === 'list') { @@ -2524,6 +2520,7 @@ export class FinderWindow extends HTMLElement { return; } if (t.closest('[data-local]')) { + if (!this.hasLocalShare()) return; this.showLocalShare(); this.closeSidebar(); await this.reload(); @@ -2562,7 +2559,7 @@ export class FinderWindow extends HTMLElement { const s = this.servers[i]; if (!s) return; if (this.remoteBusy) return; - if (this.remoteLoggedIn && this.remoteNbpName === s.object) { + if (this.remoteLoggedIn && this.remoteNbpName === s.id) { this.renderSidebar(); return; } @@ -3022,7 +3019,7 @@ export class FinderWindow extends HTMLElement { const side = this.querySelector('.sidebar'); if (!side) return null; if (t.closest('[data-local]') && side.contains(t.closest('[data-local]')!)) { - return { key: 'local', name: 'Browser Share' }; + return { key: 'local', name: this.localShareTitle() }; } const volEl = t.closest('[data-vol]') as HTMLElement | null; if (!volEl || !side.contains(volEl) || !this.remoteLoggedIn) return null; @@ -3040,7 +3037,7 @@ export class FinderWindow extends HTMLElement { if (!this.remoteLoggedIn || !key.startsWith(prefix)) return null; const name = key.slice(prefix.length); if (!name) return null; - const cat = await this.host.openRemoteVolume(name); + const cat = await this.host.openVolume(name); this.catalogs.set(key, cat); return cat; } @@ -3850,13 +3847,14 @@ export class FinderWindow extends HTMLElement { } private async onConnect(): Promise { + if (!this.hasTransport()) return; if (this.host.isConnected()) { - await this.host.disconnectSerial(); + await this.host.disconnectTransport?.(); this.unmountRemote('Disconnected'); return; } try { - await this.host.connectSerial(); + await this.host.connectTransport?.(); this.setStatus('Serial connected — claiming LocalTalk node…'); this.render(); } catch (e) { @@ -3864,68 +3862,72 @@ export class FinderWindow extends HTMLElement { } } - private async connectServerWithLogin(s: LookupResult): Promise { + private async connectServerWithLogin(s: RemoteEndpoint): Promise { if (this.remoteBusy) return false; this.remoteBusy = true; try { - this.setStatus(`Contacting ${s.object}…`, { busy: true }); - log.info( - `Connecting to AFP “${s.object}” at ${s.network}.${s.node}:${s.socket}`, - 'afp', - ); + this.setStatus(`Contacting ${s.title}…`, { busy: true }); + log.info(`Connecting to ${s.kind} “${s.title}” (${s.id})`, s.kind); this.remoteLoggedIn = false; this.remoteVolumes = []; this.remoteOpen = false; if (this.source === 'remote') this.showLocalShare(); - const info = await this.host.beginRemote(s); + const info: SessionInfo = await this.host.beginRemote(s); this.dropRemoteCatalogs(); - const allowGuest = info.uams.some((u) => /no user authent/i.test(u)); - this.setStatus(`Connected to ${info.serverName || s.object} — sign in`); + const uams = info.uams ?? []; + const skipPrompt = info.allowGuest && uams.length === 0; + this.setStatus(`Connected to ${info.serverName || s.title} — sign in`); let error: string | undefined; for (;;) { - const creds = await this.host.promptCredentials({ - serverName: info.serverName || s.object, - uams: info.uams, - error, - allowGuest, - }); + const creds: Credentials | null = skipPrompt + ? { kind: 'guest' } + : await this.host.promptCredentials({ + serverName: info.serverName || s.title, + uams, + error, + allowGuest: info.allowGuest, + }); if (!creds) { await this.host.closeRemote().catch(() => undefined); this.remoteLoggedIn = false; this.remoteVolumes = []; - this.remoteLookup = null; + this.remoteEndpoint = null; this.setStatus('Login cancelled'); return false; } try { const vols = await this.host.loginRemote(creds); this.remoteLoggedIn = true; - this.remoteVolumes = vols; - this.remoteNbpName = s.object; - this.remoteLookup = s; + this.remoteVolumes = vols.length ? vols : info.volumes; + this.remoteNbpName = s.id; + this.remoteEndpoint = s; this.remoteOpen = false; this.setStatus( - `Signed in to ${info.serverName || s.object} — ${vols.length} volume(s)`, + `Signed in to ${info.serverName || s.title} — ${this.remoteVolumes.length} volume(s)`, ); log.info( - `Authenticated to “${info.serverName || s.object}”; volumes: ${vols.join(', ') || '(none)'}`, - 'afp', + `Authenticated to “${info.serverName || s.title}”; volumes: ${this.remoteVolumes.join(', ') || '(none)'}`, + s.kind, ); - this.host.dismissLogin(); + this.host.dismissLogin?.(); + if (skipPrompt && this.remoteVolumes.length === 1) { + await this.mountRemoteVolume(this.remoteVolumes[0]!); + } return true; } catch (err) { error = err instanceof Error ? err.message : String(err); - log.error(`AFP login failed: ${error}`, 'afp'); + log.error(`Login failed: ${error}`, s.kind); + if (skipPrompt) throw err; } } } catch (err) { const msg = err instanceof Error ? err.message : String(err); - log.error(`AFP connect failed: ${msg}`, 'afp'); + log.error(`Connect failed: ${msg}`, s.kind); this.setStatus(`Connect failed: ${msg}`); await this.host.closeRemote().catch(() => undefined); this.remoteLoggedIn = false; this.remoteVolumes = []; - this.remoteLookup = null; + this.remoteEndpoint = null; return false; } finally { this.remoteBusy = false; @@ -3948,16 +3950,21 @@ export class FinderWindow extends HTMLElement { await this.host.closeRemote().catch(() => undefined); this.dropRemoteCatalogs(nbp); this.resetToLocalShare(); - this.setStatus('Disconnected from AFP server'); + this.setStatus('Disconnected from server'); await this.reload(); this.syncHistory(); this.render(); } private async onRefresh(): Promise { - this.setStatus('Looking up AFPServer…'); - const list = await this.host.refreshNetwork(); - this.setStatus(`Found ${list.length} AFP server(s)`); + this.setStatus(this.hasTransport() ? 'Looking up AFPServer…' : 'Looking up servers…'); + try { + const list = await this.host.refreshNetwork(); + this.setServers(list); + this.setStatus(`Found ${list.length} server(s)`); + } catch (e) { + this.setStatus(`Lookup failed: ${(e as Error).message}`); + } } /** @@ -4071,7 +4078,7 @@ export class FinderWindow extends HTMLElement { if (n.id === this.vfs.rootId()) break; id = n.parentId; } - const root = this.pathStack[0] ?? { id: this.vfs.rootId(), name: 'Browser Share' }; + const root = this.pathStack[0] ?? { id: this.vfs.rootId(), name: this.localShareTitle() }; const rest = suffix.reverse(); if (rest[0]?.id === root.id) return rest; return [root, ...rest.filter((s) => s.id !== root.id)]; @@ -4902,6 +4909,8 @@ export class FinderWindow extends HTMLElement { } private async runWelcomePack(opts: { seed: boolean }): Promise { + if (opts.seed && !this.host.seedWelcomePack) return; + if (!opts.seed && !this.host.installWelcomePack) return; if (this.welcomePackBusy) { if (!opts.seed) this.setStatus('Welcome pack is already adding items'); return; @@ -4919,8 +4928,8 @@ export class FinderWindow extends HTMLElement { }; try { const result = opts.seed - ? await this.host.seedWelcomePack(progress) - : await this.host.installWelcomePack(progress); + ? await this.host.seedWelcomePack!(progress) + : await this.host.installWelcomePack!(progress); if (!result) return; if (result.imported === 0 && result.skipped === 0) { this.setStatus('Welcome pack is empty'); @@ -4945,16 +4954,16 @@ export class FinderWindow extends HTMLElement { const root = cat.rootId(); const kids = await cat.children(root); if (!kids.length) { - this.setStatus('Browser Share is empty'); + this.setStatus(`${this.localShareTitle()} is empty`); return; } - if (!confirm('Erase all items in Browser Share? This cannot be undone.')) { + if (!confirm(`Erase all items in ${this.localShareTitle()}? This cannot be undone.`)) { return; } - this.setStatus('Erasing Browser Share…', { busy: true }); + this.setStatus(`Erasing ${this.localShareTitle()}…`, { busy: true }); if (this.source === 'local') { this.cwd = root; - this.pathStack = [{ id: root, name: 'Browser Share' }]; + this.pathStack = [{ id: root, name: this.localShareTitle() }]; this.selectedId = null; this.expandedIds.clear(); this.loadingIds.clear(); @@ -4987,7 +4996,7 @@ export class FinderWindow extends HTMLElement { this.syncHistory(); this.render(); } - if (!failed) this.setStatus('Erased Browser Share'); + if (!failed) this.setStatus(`Erased ${this.localShareTitle()}`); } private async pasteDestFromTarget(targetId: number | null): Promise { diff --git a/src/ui/login-dialog.ts b/src/ui/login-dialog.ts index 24e6d95..196f0e7 100644 --- a/src/ui/login-dialog.ts +++ b/src/ui/login-dialog.ts @@ -1,15 +1,8 @@ import { log } from '../util/logger'; +import type { CredentialPromptOptions, Credentials } from './finder-host'; -export type LoginCredentials = - | { kind: 'guest' } - | { kind: 'password'; username: string; password: string }; - -export interface LoginPromptOptions { - serverName: string; - uams: string[]; - error?: string; - allowGuest: boolean; -} +export type LoginCredentials = Credentials; +export type LoginPromptOptions = CredentialPromptOptions; /** Modal AFP login (guest or username/password). */ export class LoginDialog extends HTMLElement { From 589efead1e2aecb85fbd6ebdf6c5edd5a936c548 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Mon, 17 Aug 2026 09:19:23 +1000 Subject: [PATCH 02/12] Drop an unused type import from EmptyCatalog so the Go SPA typecheck is clean. Co-authored-by: Cursor --- src/fs/empty-catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fs/empty-catalog.ts b/src/fs/empty-catalog.ts index d390f78..87dead9 100644 --- a/src/fs/empty-catalog.ts +++ b/src/fs/empty-catalog.ts @@ -1,7 +1,7 @@ /** No-op Catalog used when the Finder has no local share (ClassicStack Go SPA). */ import type { ByteRangeReader } from './byte-range'; -import type { ResourceFork, ResourceForkLoadOpts } from './resource-fork'; +import type { ResourceFork } from './resource-fork'; import type { Catalog, ChildrenBatchListener, From 8313f45799d6c4cf8ef5b3857ec4e6604443c6f2 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Mon, 17 Aug 2026 09:23:42 +1000 Subject: [PATCH 03/12] Expose Zip download next to Expand in the Finder context menu and Get Info. The Go SPA reuses this chrome; operators need the same on-the-fly archive and AppleDouble zip actions without the TashTalk Advanced menu. Co-authored-by: Cursor --- src/ui/finder-window.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ui/finder-window.ts b/src/ui/finder-window.ts index cb29877..ab1fa8e 100644 --- a/src/ui/finder-window.ts +++ b/src/ui/finder-window.ts @@ -2107,6 +2107,7 @@ export class FinderWindow extends HTMLElement {
${expandBtn} +
@@ -4837,6 +4838,7 @@ export class FinderWindow extends HTMLElement { this.isExpandableArchive(targetNode) ? `` : '', + ``, canPreview ? `` : '', ``, ``, @@ -4862,6 +4864,10 @@ export class FinderWindow extends HTMLElement { case 'expand': await this.expandArchive(targetId); break; + case 'download': + if (targetId != null) this.selectedId = targetId; + await this.onDownload(); + break; case 'preview': await this.openPreview(targetId); break; From 3df98d0a8ab882b3bcf0a42440f17301963327a3 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Mon, 17 Aug 2026 10:16:27 +1000 Subject: [PATCH 04/12] Split Macintosh codecs behind a registry so SIT and rez stay replaceable. Finder Expand and resource decompress now go through pluggable codecs, so a later package split can ship a third-party StuffIt expander or rez decoder without forking the UI. Co-authored-by: Cursor --- .cursor/rules/codec-packages.mdc | 25 ++++++ README.md | 4 + package.json | 11 ++- src/fs/codecs.test.ts | 46 +++++++++++ src/fs/codecs.ts | 136 ++++++++++++++++++++++++++++++ src/fs/expand-incoming.ts | 137 +++++++++++++++++++++---------- src/fs/resource-compress.ts | 24 ++++-- src/ui/resource-fork-explorer.ts | 10 +++ 8 files changed, 343 insertions(+), 50 deletions(-) create mode 100644 .cursor/rules/codec-packages.mdc create mode 100644 src/fs/codecs.test.ts create mode 100644 src/fs/codecs.ts 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..7261b3e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ 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, 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`, …) so a third party can ship a replacement SIT expander or rez decoder without forking the PWA. + +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: diff --git a/package.json b/package.json index 4ec7d9d..5e993b5 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,16 @@ "./ui/get-info": "./src/ui/get-info-window.ts", "./ui/resource-explorer": "./src/ui/resource-fork-explorer.ts", "./fs/catalog": "./src/fs/virtual-fs.ts", - "./fs/empty-catalog": "./src/fs/empty-catalog.ts" + "./fs/empty-catalog": "./src/fs/empty-catalog.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/resource-fork": "./src/fs/resource-fork.ts", + "./fs/resource-compress": "./src/fs/resource-compress.ts", + "./fs/appledouble": "./src/fs/appledouble.ts" }, "scripts": { "dev": "vite", 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/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/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/ui/resource-fork-explorer.ts b/src/ui/resource-fork-explorer.ts index bf79832..7135498 100644 --- a/src/ui/resource-fork-explorer.ts +++ b/src/ui/resource-fork-explorer.ts @@ -5,6 +5,7 @@ import type { Catalog, VNode } from '../fs/virtual-fs'; import { ResourceFork, resourceFetchCap, type ResourceEntry } from '../fs/resource-fork'; +import { decompileRez, decodeResourceType } from '../fs/codecs'; import { decodeFref, describeBndl, @@ -399,6 +400,15 @@ export class ResourceForkExplorer extends HTMLElement { } } + const decoded = decodeResourceType(sel.entry.type, sel.entry.id, bytes); + if (typeof decoded === 'string' && decoded) { + parts.push(`
${escapeHtml(decoded)}
`); + } + const rez = decompileRez(sel.entry.type, sel.entry.id, bytes); + if (rez) { + parts.push(`
${escapeHtml(rez)}
`); + } + const dump = hexDump(bytes); const unread = sel.entry.length > bytes.length ? sel.entry.length - bytes.length : dump.truncated ? bytes.length - HEX_PREVIEW_BYTES : 0; parts.push( From 4aa8482ef58104aef04571b1e87ac7d754efa3ea Mon Sep 17 00:00:00 2001 From: pgodwin Date: Mon, 17 Aug 2026 10:23:00 +1000 Subject: [PATCH 05/12] Let the Finder host own sidebar groups and badges. Callers can section locations and stamp protocol pills, so ClassicStack can label shares AFP/SMB/NCP/EDFS and group LAN clients by AppleTalk, SMB, NetWare, and EtherDFS. Co-authored-by: Cursor --- README.md | 2 + package.json | 1 + src/main.ts | 2 + src/ui/finder-host.ts | 35 +++++++++ src/ui/finder-sidebar.test.ts | 60 +++++++++++++++ src/ui/finder-sidebar.ts | 70 +++++++++++++++++ src/ui/finder-window.ts | 139 +++++++++++++++++++++++++--------- src/ui/styles/tokens.css | 18 +++++ 8 files changed, 293 insertions(+), 34 deletions(-) create mode 100644 src/ui/finder-sidebar.test.ts create mode 100644 src/ui/finder-sidebar.ts diff --git a/README.md b/README.md index 7261b3e..90c0295 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ Protocol codecs mirror [ClassicStack](https://github.com/ObsoleteMadness/Classic 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, 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`, …) 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. + Register with `registerArchiveCodec`, `registerResourceDecompressor`, `registerResourceTypeDecoder`, or `registerRezCodec`. Re-registering the bundled ids (`sit`, `binhex`, `macbinary`, `zip`, `applesingle`, `dcmp`) replaces the default implementation. ## Credits diff --git a/package.json b/package.json index 5e993b5..b42508e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "exports": { "./ui/finder": "./src/ui/finder-window.ts", "./ui/finder-host": "./src/ui/finder-host.ts", + "./ui/finder-sidebar": "./src/ui/finder-sidebar.ts", "./ui/styles": "./src/ui/styles/tokens.css", "./ui/login": "./src/ui/login-dialog.ts", "./ui/alert": "./src/ui/alert-dialog.ts", diff --git a/src/main.ts b/src/main.ts index f8709a9..b084f4e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -200,6 +200,8 @@ async function main(): Promise { kind: 'afp', title: s.object, subtitle: s.zone && s.zone !== '*' ? s.zone : `${s.network}.${s.node}`, + badge: 'NBP', + transport: 'nbp', }; } diff --git a/src/ui/finder-host.ts b/src/ui/finder-host.ts index dd2a29a..7b336e6 100644 --- a/src/ui/finder-host.ts +++ b/src/ui/finder-host.ts @@ -7,6 +7,28 @@ import type { WelcomePackProgress } from '../fs/welcome-pack'; /** File-sharing scheme a sidebar endpoint was discovered on (or this host’s own volumes). */ export type ShareKind = 'local' | 'afp' | 'smb' | 'ncp' | 'etherdfs'; +/** Short pill on a sidebar row (AFP, TCP, NBP, …). */ +export type SidebarBadge = { + text: string; + title?: string; +}; + +/** + * One heading in the Finder sidebar. The host owns titles and order; + * FinderWindow only renders. Unknown `RemoteEndpoint.group` values fall through + * to the `network` group, or the first group with `refresh`. + */ +export type SidebarGroup = { + id: string; + title: string; + /** Show the network-refresh control on this heading. */ + refresh?: boolean; + /** Placeholder when the group has no endpoints. */ + empty?: string; + /** Omit the heading when this group has no endpoints. */ + hideWhenEmpty?: boolean; +}; + /** One discoverable server or local volume the Finder can open. */ export interface RemoteEndpoint { /** Opaque id (NBP name, SMB server, `local:afp:Mac HD`, …). */ @@ -14,6 +36,14 @@ export interface RemoteEndpoint { kind: ShareKind; title: string; subtitle?: string; + /** Sidebar section id from `FinderHost.sidebarGroups`. */ + group?: string; + /** Share-type or transport pill (AFP, SMB, TCP, DDP, …). */ + badge?: string | SidebarBadge; + /** File protocol for local shares (`afp`, `smb`, `ncp`, `etherdfs`). */ + protocol?: string; + /** How this client was reached (`tcp`, `ddp`, `ipx`, `nbp`, `etherdfs`). */ + transport?: string; } /** Result of contacting a remote (or local) endpoint before / after login. */ @@ -58,6 +88,11 @@ export interface FinderHost { suggestedName: string; }): Promise; + /** + * Sidebar headings in display order. Endpoints set `group` to one of these ids. + * Omitted: a single LocalTalk/Network section (plus the IndexedDB local share). + */ + sidebarGroups?(): SidebarGroup[]; /** Display name for the local catalog (default “Browser Share”). */ localTitle?(): string; dismissLogin?(): void; diff --git a/src/ui/finder-sidebar.test.ts b/src/ui/finder-sidebar.test.ts new file mode 100644 index 0000000..ec9f017 --- /dev/null +++ b/src/ui/finder-sidebar.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import type { RemoteEndpoint, SidebarGroup } from './finder-host'; +import { + SIDEBAR_GROUP_NETWORK, + assignSidebarGroup, + endpointsByGroup, + visibleSidebarGroups, +} from './finder-sidebar'; + +function ep(partial: Partial & Pick): RemoteEndpoint { + return { kind: 'afp', ...partial }; +} + +const classic: SidebarGroup[] = [ + { id: 'shares', title: 'Shares', hideWhenEmpty: true }, + { id: 'appletalk', title: 'AppleTalk', refresh: true, empty: 'None' }, + { id: 'smb', title: 'SMB', empty: 'None' }, + { id: 'netware', title: 'NetWare', empty: 'None' }, + { id: 'etherdfs', title: 'EtherDFS', empty: 'None' }, +]; + +describe('assignSidebarGroup', () => { + it('keeps a host group when it is in the layout', () => { + expect(assignSidebarGroup(ep({ id: '1', title: 'SYS', group: 'netware' }), classic)).toBe('netware'); + }); + + it('falls back to the network / refresh group for unknown ids', () => { + expect(assignSidebarGroup(ep({ id: '1', title: 'X', group: 'other' }), classic)).toBe('appletalk'); + expect( + assignSidebarGroup(ep({ id: '1', title: 'X' }), [ + { id: SIDEBAR_GROUP_NETWORK, title: 'Network', refresh: true }, + ]), + ).toBe(SIDEBAR_GROUP_NETWORK); + }); +}); + +describe('endpointsByGroup', () => { + it('groups shares and clients separately and preserves server indices', () => { + const servers = [ + ep({ id: 'local:afp:HD', title: 'HD', kind: 'local', group: 'shares', badge: 'AFP' }), + ep({ id: 'Mac', title: 'Mac', group: 'appletalk', badge: 'NBP' }), + ep({ id: 'FILE', title: 'FILE', kind: 'smb', group: 'smb', badge: 'TCP' }), + ]; + const by = endpointsByGroup(servers, classic); + expect(by.get('shares')?.map((r) => r.index)).toEqual([0]); + expect(by.get('appletalk')?.map((r) => r.ep.badge)).toEqual(['NBP']); + expect(by.get('smb')?.[0]?.index).toBe(2); + expect(by.get('netware')).toEqual([]); + }); +}); + +describe('visibleSidebarGroups', () => { + it('hides empty hideWhenEmpty groups but keeps a refresh group', () => { + const by = endpointsByGroup( + [ep({ id: 'FILE', title: 'FILE', kind: 'smb', group: 'smb', badge: 'TCP' })], + classic, + ); + expect(visibleSidebarGroups(classic, by).map((g) => g.id)).toEqual(['appletalk', 'smb', 'netware', 'etherdfs']); + }); +}); diff --git a/src/ui/finder-sidebar.ts b/src/ui/finder-sidebar.ts new file mode 100644 index 0000000..20f2407 --- /dev/null +++ b/src/ui/finder-sidebar.ts @@ -0,0 +1,70 @@ +/** Sidebar grouping helpers. FinderWindow renders; the host owns labels and badges. */ + +import type { RemoteEndpoint, SidebarGroup } from './finder-host'; + +/** Default catch-all group when the host does not set `endpoint.group`. */ +export const SIDEBAR_GROUP_NETWORK = 'network'; + +export type SidebarRow = { + ep: RemoteEndpoint; + /** Index in the Finder’s `servers` array (`data-server`). */ + index: number; +}; + +export function badgeText(badge: RemoteEndpoint['badge']): string { + if (!badge) return ''; + return typeof badge === 'string' ? badge : badge.text; +} + +export function badgeTitle(badge: RemoteEndpoint['badge']): string | undefined { + if (!badge || typeof badge === 'string') return undefined; + return badge.title; +} + +/** Group id for one endpoint: host `group` if known, else the network/refresh fallback. */ +export function assignSidebarGroup(ep: RemoteEndpoint, groups: readonly SidebarGroup[]): string { + const known = new Set(groups.map((g) => g.id)); + if (ep.group && known.has(ep.group)) return ep.group; + const fallback = + groups.find((g) => g.id === SIDEBAR_GROUP_NETWORK) ?? + groups.find((g) => g.refresh) ?? + groups[groups.length - 1]; + return fallback?.id ?? SIDEBAR_GROUP_NETWORK; +} + +/** Partition endpoints into host-defined groups, preserving original indices. */ +export function endpointsByGroup( + servers: readonly RemoteEndpoint[], + groups: readonly SidebarGroup[], +): Map { + const map = new Map(); + for (const g of groups) map.set(g.id, []); + servers.forEach((ep, index) => { + const id = assignSidebarGroup(ep, groups); + const list = map.get(id) ?? []; + list.push({ ep, index }); + map.set(id, list); + }); + return map; +} + +export function visibleSidebarGroups( + groups: readonly SidebarGroup[], + byGroup: Map, +): SidebarGroup[] { + const out: SidebarGroup[] = []; + let keptRefresh = false; + for (const g of groups) { + const rows = byGroup.get(g.id) ?? []; + if (g.hideWhenEmpty && rows.length === 0) { + if (g.refresh && !keptRefresh) { + out.push(g); + keptRefresh = true; + } + continue; + } + out.push(g); + if (g.refresh) keptRefresh = true; + } + return out; +} diff --git a/src/ui/finder-window.ts b/src/ui/finder-window.ts index ab1fa8e..ed5ca3d 100644 --- a/src/ui/finder-window.ts +++ b/src/ui/finder-window.ts @@ -5,6 +5,8 @@ import type { FinderHost, RemoteEndpoint, SessionInfo, + SidebarBadge, + SidebarGroup, } from './finder-host'; import { fromMacTime } from '../protocol/afp/constants'; import { decodeMacRoman } from '../protocol/macroman'; @@ -62,6 +64,13 @@ import { } from '../fs/name-conflict'; import { decodePict, pictToSvg } from '../fs/pict/pict'; import { previewKindFor, previewMime, type FilePreviewKind } from './file-preview'; +import { + SIDEBAR_GROUP_NETWORK, + badgeText, + badgeTitle, + endpointsByGroup, + visibleSidebarGroups, +} from './finder-sidebar'; export type ViewMode = 'icon' | 'list' | 'column'; export type SortKey = 'name' | 'modified' | 'size'; @@ -69,7 +78,14 @@ export type SortKey = 'name' | 'modified' | 'size'; /** Finder file types that open in the Quick Look overlay. */ const PREVIEW_TEXT_MAX_BYTES = 512 * 1024; -export type { Credentials, FinderHost, RemoteEndpoint, SessionInfo } from './finder-host'; +export type { + Credentials, + FinderHost, + RemoteEndpoint, + SessionInfo, + SidebarBadge, + SidebarGroup, +} from './finder-host'; interface ListItem { key: string; @@ -1402,32 +1418,34 @@ export class FinderWindow extends HTMLElement { const viewingLocal = this.source === 'local' && this.hasLocalShare(); const openVol = this.source === 'remote' ? this.pathStack[0]?.name || '' : ''; const viewingServer = this.source === 'remote' && !this.remoteOpen; - const servers = this.servers - .map((s, i) => { - const connected = this.remoteLoggedIn && s.id === connectedId; - const serverSel = viewingServer && connected ? 'selected' : ''; - const kids = - connected && volumes.length - ? volumes - .map( - (v, vi) => ` -
- - ${this.escape(v)} -
`, - ) - .join('') - : ''; - const eject = connected - ? `` + const groups = this.sidebarGroups(); + const byGroup = endpointsByGroup(this.servers, groups); + const refreshEnabled = this.host?.isConnected() || !this.hasTransport(); + const groupBlocks = visibleSidebarGroups(groups, byGroup) + .map((g) => { + const rows = byGroup.get(g.id) ?? []; + const items = + rows + .map(({ ep: s, index: i }) => + this.sidebarEndpointHtml(s, i, { + connectedId, + volumes, + viewingLocal, + openVol, + viewingServer, + }), + ) + .join('') || + `
${this.escape(g.empty || 'None')}
`; + const refresh = g.refresh + ? `` : ''; - const subtitle = s.subtitle ? ` title="${this.escape(s.subtitle)}"` : ''; return ` -
- - ${this.escape(s.title)} - ${eject} -
${kids}`; +
+ ${this.escape(g.title)} + ${refresh} +
+ ${items}`; }) .join(''); const localBlock = this.hasLocalShare() @@ -1438,19 +1456,71 @@ export class FinderWindow extends HTMLElement {
` : ''; - const netLabel = this.hasTransport() ? 'LocalTalk' : 'Network'; - const emptyNet = this.hasTransport() ? 'No AFP servers' : 'No servers'; - const refreshEnabled = this.host?.isConnected() || !this.hasTransport(); side.innerHTML = ` ${localBlock} -
- ${netLabel} - -
- ${servers || `
${emptyNet}
`} + ${groupBlocks} `; } + private sidebarGroups(): SidebarGroup[] { + const custom = this.host?.sidebarGroups?.(); + if (custom?.length) return custom; + return [ + { + id: SIDEBAR_GROUP_NETWORK, + title: this.hasTransport() ? 'LocalTalk' : 'Network', + refresh: true, + empty: this.hasTransport() ? 'No AFP servers' : 'No servers', + }, + ]; + } + + private sidebarBadgeHtml(badge: string | SidebarBadge | undefined): string { + const text = badgeText(badge); + if (!text) return ''; + const title = badgeTitle(badge); + const tip = title ? ` title="${this.escape(title)}"` : ''; + return `${this.escape(text)}`; + } + + private sidebarEndpointHtml( + s: RemoteEndpoint, + i: number, + opts: { + connectedId: string; + volumes: string[]; + viewingLocal: boolean; + openVol: string; + viewingServer: boolean; + }, + ): string { + const connected = this.remoteLoggedIn && s.id === opts.connectedId; + const serverSel = opts.viewingServer && connected ? 'selected' : ''; + const kids = + connected && opts.volumes.length + ? opts.volumes + .map( + (v, vi) => ` +
+ + ${this.escape(v)} +
`, + ) + .join('') + : ''; + const eject = connected + ? `` + : ''; + const subtitle = s.subtitle ? ` title="${this.escape(s.subtitle)}"` : ''; + return ` +
+ + ${this.escape(s.title)} + ${this.sidebarBadgeHtml(s.badge)} + ${eject} +
${kids}`; + } + private renderPath(): void { const bar = this.querySelector('.pathbar'); if (!bar) return; @@ -3962,7 +4032,8 @@ export class FinderWindow extends HTMLElement { try { const list = await this.host.refreshNetwork(); this.setServers(list); - this.setStatus(`Found ${list.length} server(s)`); + const n = list.filter((s) => s.kind !== 'local').length; + this.setStatus(n ? `Found ${n} server(s)` : 'No servers'); } catch (e) { this.setStatus(`Lookup failed: ${(e as Error).message}`); } diff --git a/src/ui/styles/tokens.css b/src/ui/styles/tokens.css index 8f98870..ca42b47 100644 --- a/src/ui/styles/tokens.css +++ b/src/ui/styles/tokens.css @@ -395,6 +395,24 @@ finder-window.is-dragging .titlebar { background: var(--text-muted); } +.side-badge { + flex-shrink: 0; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 1px 5px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); + color: var(--text-muted); + line-height: 1.4; +} + +.side-item.selected .side-badge { + background: rgba(255, 255, 255, 0.14); + color: var(--text); +} + .main { display: flex; flex-direction: column; From f6ab643e4abb219ad0699e4c15b7fbdb7d6645be Mon Sep 17 00:00:00 2001 From: pgodwin Date: Mon, 17 Aug 2026 10:40:38 +1000 Subject: [PATCH 06/12] Back the shared extension editor with a pluggable store. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same dialog can persist mappings in browser storage or through a host API, so ClassicStack can edit the server’s Netatalk extmap without forking the UI. Co-authored-by: Cursor --- README.md | 2 + package.json | 3 + src/fs/extension-map-netatalk.test.ts | 33 ++++++++ src/fs/extension-map-netatalk.ts | 81 ++++++++++++++++++ src/fs/extension-map.test.ts | 27 ++++++ src/fs/extension-map.ts | 114 +++++++++++++++++++++----- src/ui/extension-editor-dialog.ts | 78 +++++++++++++----- src/ui/styles/tokens.css | 6 ++ 8 files changed, 301 insertions(+), 43 deletions(-) create mode 100644 src/fs/extension-map-netatalk.test.ts create mode 100644 src/fs/extension-map-netatalk.ts diff --git a/README.md b/README.md index 90c0295..c1d8b33 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Finder UI (`src/ui/finder-window.ts`) must stay independent of archive and resou 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 diff --git a/package.json b/package.json index b42508e..eaeacec 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "./ui/finder": "./src/ui/finder-window.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", @@ -16,6 +17,8 @@ "./ui/resource-explorer": "./src/ui/resource-fork-explorer.ts", "./fs/catalog": "./src/fs/virtual-fs.ts", "./fs/empty-catalog": "./src/fs/empty-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", 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..a6541c4 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. @@ -87,7 +104,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 +150,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/ui/extension-editor-dialog.ts b/src/ui/extension-editor-dialog.ts index f516d31..2618a46 100644 --- a/src/ui/extension-editor-dialog.ts +++ b/src/ui/extension-editor-dialog.ts @@ -1,8 +1,8 @@ import { log } from '../util/logger'; import { cloneDefaultExtensionMap, - loadExtensionMap, - saveExtensionMap, + hydrateExtensionMap, + persistExtensionMap, type ExtensionMapping, } from '../fs/extension-map'; import { uiIcons } from './lucide-icon'; @@ -10,29 +10,49 @@ import { uiIcons } from './lucide-icon'; /** Advanced-menu editor for filename extension → creator/type mappings. */ export class ExtensionEditorDialog extends HTMLElement { private rows: ExtensionMapping[] = []; + private busy = false; + private error = ''; connectedCallback(): void { this.classList.add('extension-editor-dialog'); this.hidden = true; - this.addEventListener('click', (e) => this.onClick(e)); + this.addEventListener('click', (e) => void this.onClick(e)); this.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !this.hidden) this.close(); }); } open(): void { - this.rows = loadExtensionMap(); this.hidden = false; + this.error = ''; + this.busy = true; + this.rows = []; this.render(); log.info('Opened extension editor', 'finder'); - queueMicrotask(() => { - this.querySelector('[data-field="extension"]')?.focus(); - }); + void this.reload(); } close(): void { this.hidden = true; this.innerHTML = ''; + this.error = ''; + this.busy = false; + } + + private async reload(): Promise { + try { + this.rows = await hydrateExtensionMap(); + this.error = ''; + } catch (err) { + this.error = err instanceof Error ? err.message : String(err); + this.rows = []; + } + this.busy = false; + if (this.hidden) return; + this.render(); + queueMicrotask(() => { + this.querySelector('[data-field="extension"]')?.focus(); + }); } private harvest(): ExtensionMapping[] { @@ -53,14 +73,14 @@ export class ExtensionEditorDialog extends HTMLElement { (row, i) => `
+ value="${escapeAttr(row.extension)}" placeholder="txt" aria-label="Extension" ${this.busy ? 'disabled' : ''} /> + value="${escapeAttr(row.creator)}" placeholder="ttxt" aria-label="Creator" ${this.busy ? 'disabled' : ''} /> - -
`, @@ -75,6 +95,7 @@ export class ExtensionEditorDialog extends HTMLElement {

Map filename extensions to Macintosh creator and type codes. Used when importing files that have no AppleDouble metadata.

+ ${this.error ? `

${escapeAttr(this.error)}

` : ''}
Extension
@@ -82,22 +103,23 @@ export class ExtensionEditorDialog extends HTMLElement {
Type
Comment
- ${bodyRows} + ${this.busy ? '' : bodyRows}
- ${bodyRows ? '' : `

No mappings. Add a row or reset to defaults.

`} + ${this.busy ? `

Loading…

` : ''} + ${!this.busy && bodyRows ? '' : !this.busy ? `

No mappings. Add a row or reset to defaults.

` : ''}
- - + + - +
`; } - private onClick(e: MouseEvent): void { + private async onClick(e: MouseEvent): Promise { const t = (e.target as HTMLElement).closest('[data-act]') as HTMLElement | null; if (!t) return; const act = t.dataset.act; @@ -105,6 +127,7 @@ export class ExtensionEditorDialog extends HTMLElement { this.close(); return; } + if (this.busy) return; if (act === 'add') { this.rows = this.harvest(); this.rows.push({ extension: '', creator: '', type: '', comment: '' }); @@ -122,13 +145,24 @@ export class ExtensionEditorDialog extends HTMLElement { } if (act === 'reset') { this.rows = cloneDefaultExtensionMap(); + this.error = ''; this.render(); return; } if (act === 'save') { - this.rows = saveExtensionMap(this.harvest()); - log.info(`Saved ${this.rows.length} filename extension mapping${this.rows.length === 1 ? '' : 's'}`, 'finder'); - this.close(); + const rows = this.harvest(); + this.busy = true; + this.error = ''; + this.render(); + try { + this.rows = await persistExtensionMap(rows); + log.info(`Saved ${this.rows.length} filename extension mapping${this.rows.length === 1 ? '' : 's'}`, 'finder'); + this.close(); + } catch (err) { + this.error = err instanceof Error ? err.message : String(err); + this.busy = false; + if (!this.hidden) this.render(); + } } } } diff --git a/src/ui/styles/tokens.css b/src/ui/styles/tokens.css index ca42b47..9a33d9a 100644 --- a/src/ui/styles/tokens.css +++ b/src/ui/styles/tokens.css @@ -2942,6 +2942,12 @@ extension-editor-dialog[hidden], color: var(--text-muted); } +.extension-editor__error { + margin: 0 20px 8px; + font-size: 13px; + color: var(--danger); +} + .extension-editor__footer { align-items: center; } From 947755e4bf0daa98bbce250616be07276798dec2 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Mon, 17 Aug 2026 11:39:18 +1000 Subject: [PATCH 07/12] Let hosts own sidebar menus and per-group network scans. Local shares no longer show a nested volume or eject, and a heading refresh can rediscover just that service. Co-authored-by: Cursor --- src/ui/finder-host.ts | 15 +++- src/ui/finder-window.ts | 159 ++++++++++++++++++++++++++++------------ 2 files changed, 128 insertions(+), 46 deletions(-) diff --git a/src/ui/finder-host.ts b/src/ui/finder-host.ts index 7b336e6..38b1c57 100644 --- a/src/ui/finder-host.ts +++ b/src/ui/finder-host.ts @@ -29,6 +29,12 @@ export type SidebarGroup = { hideWhenEmpty?: boolean; }; +/** One item in a sidebar-row context menu (Configure, Mount, …). */ +export type SidebarAction = { + id: string; + label: string; +}; + /** One discoverable server or local volume the Finder can open. */ export interface RemoteEndpoint { /** Opaque id (NBP name, SMB server, `local:afp:Mac HD`, …). */ @@ -73,7 +79,11 @@ export interface CredentialPromptOptions { export interface FinderHost { isConnected(): boolean; nodeLabel(): string; - refreshNetwork(): Promise; + /** + * Rediscover endpoints. `scope` is a `SidebarGroup.id` so a heading’s scan + * button can refresh only that service; omitted means all groups. + */ + refreshNetwork(scope?: string): Promise; beginRemote(ep: RemoteEndpoint): Promise; loginRemote(creds: Credentials): Promise; openVolume(name: string): Promise; @@ -93,6 +103,9 @@ export interface FinderHost { * Omitted: a single LocalTalk/Network section (plus the IndexedDB local share). */ sidebarGroups?(): SidebarGroup[]; + /** Context-menu items for a sidebar server (and optional volume child). */ + sidebarContextMenu?(ep: RemoteEndpoint, volume?: string): SidebarAction[]; + onSidebarAction?(ep: RemoteEndpoint, action: string, volume?: string): void | Promise; /** Display name for the local catalog (default “Browser Share”). */ localTitle?(): string; dismissLogin?(): void; diff --git a/src/ui/finder-window.ts b/src/ui/finder-window.ts index ed5ca3d..dc513b8 100644 --- a/src/ui/finder-window.ts +++ b/src/ui/finder-window.ts @@ -5,6 +5,7 @@ import type { FinderHost, RemoteEndpoint, SessionInfo, + SidebarAction, SidebarBadge, SidebarGroup, } from './finder-host'; @@ -66,6 +67,7 @@ import { decodePict, pictToSvg } from '../fs/pict/pict'; import { previewKindFor, previewMime, type FilePreviewKind } from './file-preview'; import { SIDEBAR_GROUP_NETWORK, + assignSidebarGroup, badgeText, badgeTitle, endpointsByGroup, @@ -185,7 +187,13 @@ export class FinderWindow extends HTMLElement { sourceIds: number[]; } | null = null; private catalogs = new Map(); - private contextMenu: { x: number; y: number; targetId: number | null; local?: boolean } | null = null; + private contextMenu: { + x: number; + y: number; + targetId: number | null; + local?: boolean; + sidebar?: { index: number; volume?: string; actions: SidebarAction[] }; + } | null = null; /** Show Finder-invisible / Icon\\r items (persisted via prefs). */ private showHiddenFiles = loadPrefs().showHiddenFiles; /** Decode dropped BinHex / MacBinary (persisted via prefs). */ @@ -229,7 +237,8 @@ export class FinderWindow extends HTMLElement { private navListAbort: AbortController | null = null; /** Per-folder list-view disclose listings; aborted on collapse or navigation. */ private expandListAbort = new Map(); - private networkScanning = false; + /** Sidebar group id being scanned, or `'*'` for a full refresh. */ + private networkScanning: string | null = null; private preview: { id: number; name: string; @@ -658,11 +667,8 @@ export class FinderWindow extends HTMLElement { } setNetworkScanning(busy: boolean): void { - this.networkScanning = busy; - const btn = this.querySelector('.side-refresh'); - if (!btn) return; - btn.classList.toggle('spinning', busy); - btn.setAttribute('aria-busy', String(busy)); + this.networkScanning = busy ? '*' : null; + this.renderSidebar(); } setServers(list: RemoteEndpoint[]): void { @@ -1437,8 +1443,9 @@ export class FinderWindow extends HTMLElement { ) .join('') || `
${this.escape(g.empty || 'None')}
`; + const scanning = this.networkScanning === '*' || this.networkScanning === g.id; const refresh = g.refresh - ? `` + ? `` : ''; return `
@@ -1495,9 +1502,10 @@ export class FinderWindow extends HTMLElement { }, ): string { const connected = this.remoteLoggedIn && s.id === opts.connectedId; - const serverSel = opts.viewingServer && connected ? 'selected' : ''; + const localShare = s.kind === 'local'; + const serverSel = connected && (localShare ? this.source === 'remote' : opts.viewingServer) ? 'selected' : ''; const kids = - connected && opts.volumes.length + connected && !localShare && opts.volumes.length ? opts.volumes .map( (v, vi) => ` @@ -1508,9 +1516,10 @@ export class FinderWindow extends HTMLElement { ) .join('') : ''; - const eject = connected - ? `` - : ''; + const eject = + connected && !localShare + ? `` + : ''; const subtitle = s.subtitle ? ` title="${this.escape(s.subtitle)}"` : ''; return `
@@ -2532,8 +2541,14 @@ export class FinderWindow extends HTMLElement { e.preventDefault(); const action = ctxItem.getAttribute('data-ctx')!; const ctxTarget = this.contextMenu?.targetId ?? null; + const sidebar = this.contextMenu?.sidebar; this.contextMenu = null; this.renderContextMenu(); + if (sidebar) { + const ep = this.servers[sidebar.index]; + if (ep) await this.host.onSidebarAction?.(ep, action, sidebar.volume); + return; + } await this.handleContextAction(action, ctxTarget); return; } @@ -2580,6 +2595,10 @@ export class FinderWindow extends HTMLElement { if (jobId) transferActivity.cancel(jobId); return; } + if (act === 'refresh') { + await this.onRefresh(actEl?.getAttribute('data-refresh') || undefined); + return; + } if (act) { await this.handleAction(act); return; @@ -4027,15 +4046,39 @@ export class FinderWindow extends HTMLElement { this.render(); } - private async onRefresh(): Promise { - this.setStatus(this.hasTransport() ? 'Looking up AFPServer…' : 'Looking up servers…'); + private async onRefresh(groupId?: string): Promise { + const groups = this.sidebarGroups(); + const title = groupId ? groups.find((g) => g.id === groupId)?.title : undefined; + this.networkScanning = groupId || '*'; + this.renderSidebar(); + this.setStatus( + title + ? `Scanning ${title}…` + : this.hasTransport() + ? 'Looking up AFPServer…' + : 'Looking up servers…', + ); try { - const list = await this.host.refreshNetwork(); + const list = await this.host.refreshNetwork(groupId); this.setServers(list); - const n = list.filter((s) => s.kind !== 'local').length; - this.setStatus(n ? `Found ${n} server(s)` : 'No servers'); + const scoped = groupId + ? list.filter((s) => assignSidebarGroup(s, groups) === groupId && s.kind !== 'local') + : list.filter((s) => s.kind !== 'local'); + const n = scoped.length; + this.setStatus( + title + ? n + ? `${title}: found ${n} server(s)` + : `${title}: none` + : n + ? `Found ${n} server(s)` + : 'No servers', + ); } catch (e) { this.setStatus(`Lookup failed: ${(e as Error).message}`); + } finally { + this.networkScanning = null; + this.renderSidebar(); } } @@ -4870,6 +4913,30 @@ export class FinderWindow extends HTMLElement { this.renderContextMenu(); return; } + const volEl = t.closest('[data-vol]'); + const serverEl = t.closest('[data-server]'); + if (volEl || serverEl) { + let index = -1; + let volume: string | undefined; + if (volEl) { + index = this.servers.findIndex((s) => s.id === (this.remoteEndpoint?.id || this.remoteNbpName)); + volume = this.remoteVolumes[Number(volEl.getAttribute('data-vol'))]; + } else if (serverEl) { + index = Number(serverEl.getAttribute('data-server')); + } + const ep = this.servers[index]; + const actions = ep ? (this.host.sidebarContextMenu?.(ep, volume) ?? []) : []; + if (!ep || !actions.length) return; + e.preventDefault(); + this.contextMenu = { + x: e.clientX, + y: e.clientY, + targetId: null, + sidebar: { index, volume, actions }, + }; + this.renderContextMenu(); + return; + } const content = this.querySelector('.content'); if (!content?.contains(e.target as Node)) return; e.preventDefault(); @@ -4895,38 +4962,40 @@ export class FinderWindow extends HTMLElement { root.innerHTML = ''; return; } - const { x, y, targetId, local } = this.contextMenu; + const { x, y, targetId, local, sidebar } = this.contextMenu; const targetNode = targetId != null ? this.findNodeAnywhere(targetId) : null; const canPreview = this.isPreviewable(targetNode); - const items = local - ? [ - ``, - `
`, - ``, - ] - : targetId != null + const items = sidebar + ? sidebar.actions.map((a) => ``) + : local ? [ - this.isExpandableArchive(targetNode) - ? `` - : '', - ``, - canPreview ? `` : '', - ``, - ``, - ``, - targetNode && !targetNode.isDir - ? `` - : '', + ``, `
`, - ``, - ``, - this.clipboard ? `` : '', + ``, ] - : [ - ``, - this.clipboard ? `` : '', - ``, - ]; + : targetId != null + ? [ + this.isExpandableArchive(targetNode) + ? `` + : '', + ``, + canPreview ? `` : '', + ``, + ``, + ``, + targetNode && !targetNode.isDir + ? `` + : '', + `
`, + ``, + ``, + this.clipboard ? `` : '', + ] + : [ + ``, + this.clipboard ? `` : '', + ``, + ]; root.innerHTML = `
${items.filter(Boolean).join('')}
`; } From 6a4d9f12b3d54ce635ded6aa334d32cd94468a24 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Mon, 17 Aug 2026 11:39:53 +1000 Subject: [PATCH 08/12] Resolve a cancelled login when the dialog is closed. Leaving Finder for another screen dismisses the prompt instead of leaving connect hanging. Co-authored-by: Cursor --- src/ui/login-dialog.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/login-dialog.ts b/src/ui/login-dialog.ts index 196f0e7..852b811 100644 --- a/src/ui/login-dialog.ts +++ b/src/ui/login-dialog.ts @@ -42,11 +42,13 @@ export class LoginDialog extends HTMLElement { } close(): void { + const done = this.pending; this.busy = false; this.pending = null; this.hidden = true; this.opts = null; this.password = ''; + done?.(null); } private finish(value: LoginCredentials | null): void { From a34e899ae8f23a8a36c125e5137fe15832dae602 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Mon, 17 Aug 2026 11:50:15 +1000 Subject: [PATCH 09/12] List a share after opening it and drop the duplicate local path crumb. Auto-mounted volumes never called reload, so the window stayed empty, and local shares showed id:name in the path bar. Co-authored-by: Cursor --- src/ui/finder-window.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/ui/finder-window.ts b/src/ui/finder-window.ts index dc513b8..3ea392a 100644 --- a/src/ui/finder-window.ts +++ b/src/ui/finder-window.ts @@ -1536,7 +1536,7 @@ export class FinderWindow extends HTMLElement { type Crumb = { name: string; id?: number; index: number }; const crumbs: Crumb[] = this.pathStack.map((p, i) => ({ name: - i === 0 && this.source === 'remote' && this.remoteNbpName + i === 0 && this.source === 'remote' && this.remoteNbpName && this.remoteEndpoint?.kind !== 'local' ? `${this.remoteNbpName}:${p.name}` : p.name || this.localShareTitle(), id: p.id, @@ -2650,11 +2650,21 @@ export class FinderWindow extends HTMLElement { if (!s) return; if (this.remoteBusy) return; if (this.remoteLoggedIn && this.remoteNbpName === s.id) { - this.renderSidebar(); + if (this.remoteOpen) { + this.closeSidebar(); + await this.reload(); + this.render(); + } else { + this.renderSidebar(); + } return; } await this.connectServerWithLogin(s); this.closeSidebar(); + if (this.remoteOpen) { + await this.reload(); + this.syncHistory(); + } this.render(); return; } @@ -4031,7 +4041,9 @@ export class FinderWindow extends HTMLElement { if (!cat) throw new Error(`Couldn’t open volume “${name}”`); this.mountCatalog(cat, 'remote', name); this.remoteOpen = true; - this.setStatus(`Mounted ${this.remoteNbpName}:${name}`); + this.setStatus( + this.remoteEndpoint?.kind === 'local' ? `Opened ${name}` : `Mounted ${this.remoteNbpName}:${name}`, + ); } private async ejectRemote(): Promise { From 3c2207f92d9a3757c9de23cf910d8657ffb153a9 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Tue, 18 Aug 2026 23:10:12 +1000 Subject: [PATCH 10/12] Extract a shared Finder host, support multiple open servers, and add a Settings window. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the AFP-over-WebSerial wiring out of main.ts into a new src/finder/ module (api.ts, api-catalog.ts, afp-finder-api.ts, afp-finder-host.ts, bind-catalog.ts, catalog-copy.ts, progress.ts, types.ts) behind a protocol-neutral FinderAPI/CatalogWithBackend interface, and publish it plus the protocol/services/transport layers as package.json subpath exports. ClassicStack's Go control-panel SPA implements the same FinderAPI over HTTP, so the two apps now share one Finder UI and one copy/move/expand engine instead of forking it. Let the sidebar hold more than one live connection at once: endpoints are now either catalogs themselves (a ClassicStack share, a FUSE/WinFsp mount) or servers that list volumes as children, tracked per-endpoint (loggedInEndpoints/knownVolumes/openedVolumeKeys) instead of assuming a single remote session. Disconnect (log out of a server) and Eject (unmount one volume) are now separate actions, drag-and-drop targets key off data-share-key instead of positional volume indexes, and browser-history navigation can reconnect to whichever share a restored URL names. The login dialog is now protocol-aware (AFP/SMB/NCP) instead of hardcoding AFP UAM copy and the 8-char AFP password limit. Add a Settings window (settings-window.ts, settings-panel.ts) that gathers the prefs that used to live loose in the Advanced menu — hidden files, auto-expand, Finder icon reads, zip export style, extension editor, import/export preferences, reset environment, and Netboot — into one sectioned dialog, and factor menu open/close/escape/click-outside handling out of app-menubar.ts into a shared menu-bar-track.ts. Give the AFP client real multi-volume sessions: FPCopyFile support for server-side copies, and closeVolume() to release one volume (DT ref + desktop-info caches) without logging out, replacing the old assumption that only one volume is ever open. finder-window detects when source and destination share a backend (isCatalogWithBackend) and routes copies through the native copyFrom/expandNode paths instead of a client-side byte shuffle. Also: gate folder Icon\r lookups on the HAS_CUSTOM_ICON Finder flag instead of just the presence of a findChild callback, so folders using the default glyph are never probed; make the default Finder view (icon/list/column) a persisted preference; and rebrand the README from "ClassicStackWeb" to "ClassicStack-Web" with a pointer to the full ClassicStack project. Co-Authored-By: Claude Sonnet 5 --- README.md | 7 +- package.json | 36 ++ src/finder/afp-finder-api.ts | 144 +++++ src/finder/afp-finder-host.ts | 298 ++++++++++ src/finder/api-catalog.ts | 417 ++++++++++++++ src/finder/api.ts | 56 ++ src/finder/bind-catalog.ts | 57 ++ src/finder/catalog-copy.test.ts | 148 +++++ src/finder/catalog-copy.ts | 199 +++++++ src/finder/index.ts | 16 + src/finder/progress.ts | 53 ++ src/finder/types.ts | 56 ++ src/fs/icon-cache.test.ts | 18 +- src/fs/icon-cache.ts | 13 +- src/fs/remote-vfs.ts | 6 +- src/main.ts | 690 ++++++++--------------- src/protocol/afp/constants.ts | 2 + src/services/afp-client/client.ts | 68 ++- src/services/afp-client/commands.test.ts | 21 + src/services/afp-client/commands.ts | 26 + src/ui/app-menubar.ts | 188 +----- src/ui/finder-host.ts | 26 + src/ui/finder-sidebar.test.ts | 27 + src/ui/finder-sidebar.ts | 53 ++ src/ui/finder-window.ts | 465 ++++++++++++--- src/ui/login-dialog.ts | 56 +- src/ui/lucide-icon.ts | 8 + src/ui/menu-bar-track.ts | 111 ++++ src/ui/netboot-dialog.ts | 6 + src/ui/settings-panel.test.ts | 57 ++ src/ui/settings-panel.ts | 235 ++++++++ src/ui/settings-window.ts | 419 ++++++++++++++ src/ui/styles/tokens.css | 333 ++++++++++- src/util/prefs.ts | 9 + 34 files changed, 3622 insertions(+), 702 deletions(-) create mode 100644 src/finder/afp-finder-api.ts create mode 100644 src/finder/afp-finder-host.ts create mode 100644 src/finder/api-catalog.ts create mode 100644 src/finder/api.ts create mode 100644 src/finder/bind-catalog.ts create mode 100644 src/finder/catalog-copy.test.ts create mode 100644 src/finder/catalog-copy.ts create mode 100644 src/finder/index.ts create mode 100644 src/finder/progress.ts create mode 100644 src/finder/types.ts create mode 100644 src/ui/menu-bar-track.ts create mode 100644 src/ui/settings-panel.test.ts create mode 100644 src/ui/settings-panel.ts create mode 100644 src/ui/settings-window.ts diff --git a/README.md b/README.md index c1d8b33..de7befe 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 diff --git a/package.json b/package.json index eaeacec..c22f209 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "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", @@ -15,7 +16,39 @@ "./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", + "./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/empty-catalog": "./src/fs/empty-catalog.ts", "./fs/extension-map": "./src/fs/extension-map.ts", "./fs/extension-map-netatalk": "./src/fs/extension-map-netatalk.ts", @@ -25,6 +58,9 @@ "./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/resource-fork": "./src/fs/resource-fork.ts", "./fs/resource-compress": "./src/fs/resource-compress.ts", "./fs/appledouble": "./src/fs/appledouble.ts" diff --git a/src/finder/afp-finder-api.ts b/src/finder/afp-finder-api.ts new file mode 100644 index 0000000..3b09b9b --- /dev/null +++ b/src/finder/afp-finder-api.ts @@ -0,0 +1,144 @@ +import type { CatalogWithBackend, FinderAPI } from './api'; +import type { FinderNodeDto, FinderSessionDto, OpProgress, CrossTransferRequest } from './types'; +import { bindCatalog } from './bind-catalog'; +import { copyBetweenCatalogs, expandOnCatalog, moveBetweenCatalogs } from './catalog-copy'; +import type { Catalog } from '../fs/virtual-fs'; + +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, id: number): Promise { + const node = await this.catalogFor(sessionId).get(id); + if (!node) throw new Error('not found'); + return this.toNode(node); + } + async children(sessionId: string, parentId: number): Promise { + return (await this.catalogFor(sessionId).children(parentId)).map((n) => this.toNode(n)); + } + async lookup(sessionId: string, parentId: number, name: string): Promise { + const node = await this.catalogFor(sessionId).lookup(parentId, name); + return node ? this.toNode(node) : null; + } + async mkdir(sessionId: string, parentId: number, name: string): Promise { + return this.toNode(await this.catalogFor(sessionId).mkdir(parentId, name)); + } + async create( + sessionId: string, + parentId: number, + name: string, + body?: { data?: Uint8Array; resource?: Uint8Array; finderInfo?: Uint8Array }, + ): Promise { + return this.toNode( + await this.catalogFor(sessionId).createFile( + parentId, + name, + body?.data ?? new Uint8Array(), + body?.resource ?? new Uint8Array(), + body?.finderInfo, + ), + ); + } + async rename(sessionId: string, id: number, name: string): Promise { + await this.catalogFor(sessionId).rename(id, name); + } + async move(sessionId: string, id: number, parentId: number): Promise { + await this.catalogFor(sessionId).move(id, parentId); + } + async remove(sessionId: string, id: number): Promise { + await this.catalogFor(sessionId).remove(id); + } + async readFork(sessionId: string, id: number, resource: boolean): Promise { + const node = await this.catalogFor(sessionId).ensureContent(id); + if (!node) throw new Error('not found'); + return resource ? node.resource : node.data; + } + async writeFork(sessionId: string, id: number, resource: boolean, _off: number, data: Uint8Array): Promise { + const cat = this.catalogFor(sessionId); + const node = await cat.ensureContent(id); + 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, id: number, finderInfo: Uint8Array): Promise { + const cat = this.catalogFor(sessionId); + const node = await cat.get(id); + if (!node) throw new Error('not found'); + node.finderInfo = finderInfo; + await cat.put(node); + } + + 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, id: number, signal?: AbortSignal): AsyncIterable { + return expandOnCatalog(this.catalogFor(sessionId), id, 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: import('../fs/virtual-fs').VNode): FinderNodeDto { + return { + 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, + }; + } +} diff --git a/src/finder/afp-finder-host.ts b/src/finder/afp-finder-host.ts new file mode 100644 index 0000000..4bfc891 --- /dev/null +++ b/src/finder/afp-finder-host.ts @@ -0,0 +1,298 @@ +/** 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'); + 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 { + serverName: this.remote.serverName, + volumes: [], + allowGuest: this.remote.uams.some((u) => /no user authent/i.test(u)), + uams: this.remote.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..7d20dc1 --- /dev/null +++ b/src/finder/api-catalog.ts @@ -0,0 +1,417 @@ +import { CNIDRoot } from '../protocol/afp/constants'; +import type { VNode, VfsChangeListener, ChildrenBatchListener } from '../fs/virtual-fs'; +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 root: number; + 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.root = session.rootId || CNIDRoot; + } + + rootId(): number { 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]; + this.batchParents.clear(); + this.emit(parentIds); + } + } + + async get(id: number): Promise { + const cached = this.nodes.get(id); + if (cached) return cached; + try { + return this.adopt(await this.api.getNode(this.sessionId, id)); + } catch { + return id === this.root ? this.ensureRoot() : undefined; + } + } + + async ensureContent(id: number, onBytes?: (n: number) => void, signal?: AbortSignal): Promise { + const node = await this.get(id); + if (!node || node.isDir) return node; + if (!this.forksLoaded.has(id)) await this.hydrateForks(node, onBytes, signal); + return node; + } + + async children(parentId: number, onBatch?: ChildrenBatchListener, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const raw = await this.api.children(this.sessionId, parentId); + const kids = raw.map((n) => this.adopt(n)); + await onBatch?.(kids); + return kids; + } + + async lookup(parentId: number, name: string, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const lower = name.toLowerCase(); + for (const n of this.nodes.values()) { + if (n.parentId === parentId && n.name.toLowerCase() === lower) return n; + } + const raw = await this.api.lookup(this.sessionId, parentId, name); + return raw ? this.adopt(raw) : undefined; + } + + 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(node.id)) { + const bytes = opts?.resource ? node.resource : node.data; + return fn(bufferRangeReader(bytes)); + } + const read: ByteRangeReader = (offset, count) => + this.api.readFork(this.sessionId, node.id, !!opts?.resource, offset, count); + return fn(read); + } + + async mkdir(parentId: number, name: string): Promise { + const existing = await this.lookup(parentId, name); + if (existing) throw new Error('exists'); + const node = this.adopt(await this.api.mkdir(this.sessionId, parentId, name)); + this.notify(parentId); + return node; + } + + async ensureDir(parentId: number, name: string): Promise { + const existing = await this.lookup(parentId, name); + if (existing?.isDir) return existing; + if (existing) throw new Error('exists'); + return this.mkdir(parentId, name); + } + + async createFile( + parentId: number, + 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, parentId, name, { finderInfo }); + const node = this.adopt(raw); + if (data.length) await this.writeFork(node.id, false, data, onBytes, signal); + throwIfAborted(signal); + if (resource.length) await this.writeFork(node.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(node.id); + this.nodes.set(node.id, node); + this.notify(parentId); + return node; + } + + async put(node: VNode): Promise { + this.nodes.set(node.id, node); + await this.api.writeFinderInfo(this.sessionId, node.id, node.finderInfo); + this.notify(node.parentId); + } + + async rename(id: number, newName: string): Promise { + const n = this.nodes.get(id) ?? (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.api.rename(this.sessionId, id, newName); + n.name = newName; + 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)); + 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.api.move(this.sessionId, id, newParent); + const oldParent = n.parentId; + n.parentId = newParent; + this.nodes.set(id, n); + this.notify(oldParent, newParent); + } + + async remove(id: number): Promise { + const n = this.nodes.get(id) ?? (await this.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); + } + await this.api.remove(this.sessionId, id); + this.nodes.delete(id); + this.forksLoaded.delete(id); + this.notify(n.parentId); + } + + async importDataTransfer(parentId: number, dt: DataTransfer, opts?: ImportProgress): Promise { + return importDataTransferInto( + this, + parentId, + dt, + (p, file, onBytes, resource, signal) => this.importBlob(p, file, onBytes, resource, signal), + opts, + ); + } + + async copyFrom(src: CatalogWithBackend, srcId: number, destParent: number, 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: number, destParent: number, 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(id: number, opts?: Pick): Promise { + await consumeProgress(this.api.expand(this.sessionId, id, opts?.signal), opts?.onProgress, opts?.signal); + const node = await this.get(id); + if (node) this.notify(node.parentId); + } + + private async importBlob( + parentId: number, + file: File, + onBytes?: (n: number) => void, + resource?: Uint8Array, + signal?: AbortSignal, + ): Promise { + 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(existing.id) + ? existing.data + : await this.readWholeFork(existing.id, 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(node.id, false, onBytes, signal); + } catch (err) { + if (isAbortError(err)) throw err; + node.data = EMPTY; + } + try { + node.resource = await this.readWholeFork(node.id, 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(node.id); + this.nodes.set(node.id, node); + } + + private async writeFork( + id: number, + 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: number, + 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 prev = this.nodes.get(raw.id); + const loaded = this.forksLoaded.has(raw.id); + const finderInfo = b64ToBytes(raw.finderInfo); + const node: VNode = { + id: raw.id, + parentId: raw.parentId, + 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: raw.createDate ?? 0, + modDate: raw.modDate ?? 0, + dataBytes: raw.isDir ? 0 : (raw.dataBytes ?? 0), + resourceBytes: raw.isDir ? 0 : (raw.resourceBytes ?? 0), + }; + if (!raw.isDir && loaded && prev) { + node.dataBytes = prev.data.length; + node.resourceBytes = prev.resource.length; + } + this.nodes.set(node.id, node); + return node; + } + + private ensureRoot(): VNode { + const existing = this.nodes.get(this.root); + if (existing) return existing; + const root: VNode = { + id: this.root, + parentId: 1, + name: '', + isDir: true, + data: EMPTY, + resource: EMPTY, + finderInfo: new Uint8Array(32), + createDate: 0, + modDate: 0, + }; + this.nodes.set(this.root, root); + return root; + } + + private notify(...parentIds: number[]): void { + if (this.batchDepth > 0) { + for (const id of parentIds) this.batchParents.add(id); + return; + } + this.emit(parentIds); + } + + private emit(parentIds: number[]): 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..c380c4b --- /dev/null +++ b/src/finder/api.ts @@ -0,0 +1,56 @@ +/** Protocol-neutral Finder backend (ClassicStack-web AFP or ClassicStack-go HTTP). */ + +import type { Catalog } from '../fs/virtual-fs'; +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, id: number): Promise; + children(sessionId: string, parentId: number): Promise; + lookup(sessionId: string, parentId: number, name: string): Promise; + mkdir(sessionId: string, parentId: number, name: string): Promise; + create( + sessionId: string, + parentId: number, + name: string, + body?: { data?: Uint8Array; resource?: Uint8Array; finderInfo?: Uint8Array }, + ): Promise; + rename(sessionId: string, id: number, name: string): Promise; + move(sessionId: string, id: number, parentId: number): Promise; + remove(sessionId: string, id: number): Promise; + readFork(sessionId: string, id: number, resource: boolean, off?: number, len?: number): Promise; + writeFork(sessionId: string, id: number, resource: boolean, off: number, data: Uint8Array): Promise; + writeFinderInfo(sessionId: string, id: number, finderInfo: Uint8Array): Promise; + + copy(req: CrossTransferRequest, signal?: AbortSignal): AsyncIterable; + moveAcross(req: CrossTransferRequest, signal?: AbortSignal): AsyncIterable; + expand(sessionId: string, id: number, 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: number, destParent: number, opts: TransferOptions): Promise; + moveFrom(src: CatalogWithBackend, srcId: number, destParent: number, opts: TransferOptions): Promise; + expandNode(id: number, 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..343659f --- /dev/null +++ b/src/finder/catalog-copy.ts @@ -0,0 +1,199 @@ +/** In-browser copy/move/expand between Finder catalogs (AFP PWA). */ + +import type { Catalog, VNode } 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 { CrossTransferRequest, OpProgress } from './types'; + +type CopyCtx = { + destName: string; + destParentId: number; + 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: number, + 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(node.id, undefined, ctx.signal)) { + yield* copyNode(src, dest, child, dir.id, child.name, ctx); + } + return; + } + if (sameAfpClient(src, dest) && src instanceof RemoteVfs && dest instanceof RemoteVfs) { + await src.client.copyFile(node.parentId, node.name, dest.volId, 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(node.id, 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(existing.id); + } + 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 && existing.id !== req.srcId) await dest.remove(existing.id); + } + 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, + id: number, + signal?: AbortSignal, +): AsyncGenerator { + const node = await cat.get(id); + 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(id, track.onBytes, signal)) ?? node; + throwIfAborted(signal); + const tree = expandArchiveFile(full.name, full.data); + await importExpandedTree(cat, node.parentId, 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..82276d6 --- /dev/null +++ b/src/finder/index.ts @@ -0,0 +1,16 @@ +/** 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'; 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..f74a317 --- /dev/null +++ b/src/finder/types.ts @@ -0,0 +1,56 @@ +/** Shared Finder VFS job progress (Go SSE and in-browser AFP). */ + +export type OpPhase = 'copying' | 'moving' | 'expanding' | 'listing'; + +export type OpProgress = { + phase?: OpPhase; + path?: string; + bytesDone?: number; + bytesTotal?: number; + destName?: string; + destParentId?: number; + done?: boolean; + error?: string; +}; + +export type FinderNodeDto = { + id: number; + parentId: number; + name: string; + isDir: boolean; + dataBytes?: number; + resourceBytes?: number; + finderInfo?: string; + createDate?: number; + modDate?: number; +}; + +export type FinderSessionDto = { + sessionId: string; + serverName: string; + kind: string; + volumes: string[]; + allowGuest: boolean; + uams?: string[]; + rootId?: number; + volume?: string; + target?: string; + transport?: string; +}; + +export type TransferOptions = { + destName: string; + replace?: boolean; + replaceId?: number | null; + signal?: AbortSignal; + onProgress?: (p: OpProgress) => void; +}; + +export type CrossTransferRequest = { + srcSession: string; + destSession: string; + srcId: number; + destParentId: number; + destName: string; + replace?: boolean; +}; diff --git a/src/fs/icon-cache.test.ts b/src/fs/icon-cache.test.ts index 5778196..7c73dc3 100644 --- a/src/fs/icon-cache.test.ts +++ b/src/fs/icon-cache.test.ts @@ -228,7 +228,7 @@ describe('iconForkLoadOptions', () => { }); describe('IconCache.getForNode', () => { - function folderNode(): VNode { + function folderNode(flags = 0): VNode { return { id: 4, parentId: 2, @@ -236,7 +236,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 +245,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; }); diff --git a/src/fs/icon-cache.ts b/src/fs/icon-cache.ts index 8a9f9ef..65d2558 100644 --- a/src/fs/icon-cache.ts +++ b/src/fs/icon-cache.ts @@ -606,10 +606,15 @@ export class IconCache { 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); diff --git a/src/fs/remote-vfs.ts b/src/fs/remote-vfs.ts index 2da4001..2b4a19e 100644 --- a/src/fs/remote-vfs.ts +++ b/src/fs/remote-vfs.ts @@ -18,9 +18,9 @@ const EMPTY = new Uint8Array(); export class RemoteVfs implements Catalog { readonly reportsChunkedBytes = true; - private client: AfpClient; - private volumeName: string; - private volId: number; + readonly client: AfpClient; + readonly volumeName: string; + readonly volId: number; private nodes = new Map(); private forksLoaded = new Set(); private listeners = new Set(); diff --git a/src/main.ts b/src/main.ts index b084f4e..600e6a4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,449 +1,241 @@ -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 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, type RemoteEndpoint } 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 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', - }; - } - - 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.map(toEndpoint)); - 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}` - : '', - localTitle: () => 'Browser Share', - - async connectTransport() { - 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 disconnectTransport() { - 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() { - const list = await scanAfpServers('manual'); - return list.map(toEndpoint); - }, - - async beginRemote(ep) { - if (!atp) throw new Error('not connected'); - const list = nbp ? await 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 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, - volumes: [], - allowGuest: remote.uams.some((u) => /no user authent/i.test(u)), - uams: remote.uams, - }; - }, - - async loginRemote(creds) { - if (!remote) throw new Error('no AFP session'); - await remote.login(creds); - return remote.volumes.map((v) => v.name); - }, - - async openVolume(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 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.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(); - }, - }); - - 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 { 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 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, 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, + getInfoWindow, + about, + alertDialog, + settings, + finder, + 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(); + + 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(); 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/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/ui/app-menubar.ts b/src/ui/app-menubar.ts index 786e5ac..58aa49d 100644 --- a/src/ui/app-menubar.ts +++ b/src/ui/app-menubar.ts @@ -1,55 +1,51 @@ import { downloadBytes, type PcapCapture } from '../util/pcap'; import { log } from '../util/logger'; -import { loadPrefs, savePrefs } from '../util/prefs'; import { applyPrefsBundle, parsePrefsBundle, stringifyPrefsBundle } from '../util/prefs-bundle'; import type { LogPanel } from './log-panel'; import type { ActivityWindow } from './activity-window'; -import type { NetbootDialog } from './netboot-dialog'; import type { AfpSessionsDialog } from './afp-sessions-dialog'; -import type { ExtensionEditorDialog } from './extension-editor-dialog'; import type { ResourceForkExplorer } from './resource-fork-explorer'; import type { GetInfoWindow } from './get-info-window'; import type { FinderWindow } from './finder-window'; import type { AboutDialog } from './about-dialog'; import type { AlertDialog } from './alert-dialog'; +import type { SettingsWindow } from './settings-window'; import { iconCache } from '../fs/icon-cache'; import { persistWindow } from './window-layout'; import { isCompactUi } from './layout-mode'; - -type OpenMenu = 'app' | 'advanced' | null; +import { bindMenuBarTracking, MENUBAR_CHANGE, menubarOpenKey, setMenubarOpen } from './menu-bar-track'; export interface AdvancedMenuHost { pcap: PcapCapture; logPanel: LogPanel; activityWindow: ActivityWindow; - netboot: NetbootDialog; afpSessions: AfpSessionsDialog; - extensionEditor: ExtensionEditorDialog; resourceExplorer: ResourceForkExplorer; getInfoWindow: GetInfoWindow; about: AboutDialog; alertDialog?: AlertDialog; + settings?: SettingsWindow; finder?: FinderWindow; - resetEnvironment?(eraseShare: boolean): Promise; onCaptureChanged?(capturing: boolean): void; } /** Screen-top menu bar with ClassicStack / Advanced menus. */ export class AppMenuBar extends HTMLElement { private host: AdvancedMenuHost | null = null; - private openMenu: OpenMenu = null; + private unbindTracking: (() => void) | null = null; connectedCallback(): void { this.classList.add('app-menubar'); this.render(); this.addEventListener('click', (e) => this.onClick(e)); - window.addEventListener('click', this.onWindowClick); - window.addEventListener('keydown', this.onKey); + this.addEventListener(MENUBAR_CHANGE, this.onMenubarChange); + this.unbindTracking = bindMenuBarTracking(this); } disconnectedCallback(): void { - window.removeEventListener('click', this.onWindowClick); - window.removeEventListener('keydown', this.onKey); + this.removeEventListener(MENUBAR_CHANGE, this.onMenubarChange); + this.unbindTracking?.(); + this.unbindTracking = null; } bind(host: AdvancedMenuHost): void { @@ -84,16 +80,12 @@ export class AppMenuBar extends HTMLElement { const count = this.host?.pcap.packetCount ?? 0; const logOpen = this.host ? !this.host.logPanel.hidden : false; const activityOpen = this.host ? !this.host.activityWindow.hidden : false; - const showHidden = this.host?.finder?.getShowHiddenFiles?.() ?? false; - const autoExpand = this.host?.finder?.getAutoExpandFiles?.() ?? false; - const readFinderIcons = this.host?.finder?.getReadFinderIcons?.() ?? true; - const zipStyle = loadPrefs().zipExportStyle; - const appOpen = this.openMenu === 'app'; - const advancedOpen = this.openMenu === 'advanced'; + const appOpen = menubarOpenKey(this) === 'app'; + const advancedOpen = menubarOpenKey(this) === 'advanced'; this.innerHTML = `
-
+
@@ -101,9 +93,12 @@ export class AppMenuBar extends HTMLElement { +
-
+
@@ -121,10 +116,6 @@ export class AppMenuBar extends HTMLElement { Message Macintosh clients… -
- - - -
- - -
- -
- - -
-
@@ -192,23 +143,12 @@ export class AppMenuBar extends HTMLElement { `; } - private onWindowClick = (e: MouseEvent): void => { - if (!this.openMenu) return; - if (this.contains(e.target as Node)) return; - this.openMenu = null; + private onMenubarChange = (): void => { this.render(); }; - private onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape' && this.openMenu) { - this.openMenu = null; - this.render(); - } - }; - private closeMenus(): void { - this.openMenu = null; - this.render(); + setMenubarOpen(this, null); } private onClick(e: MouseEvent): void { @@ -216,22 +156,17 @@ export class AppMenuBar extends HTMLElement { if (!t || !this.host) return; e.stopPropagation(); const act = t.dataset.act; + if (act === 'toggle-app' || act === 'toggle-advanced') return; - if (act === 'toggle-app') { - this.openMenu = this.openMenu === 'app' ? null : 'app'; - this.render(); - return; - } - - if (act === 'toggle-advanced') { - this.openMenu = this.openMenu === 'advanced' ? null : 'advanced'; - this.render(); + if (act === 'about') { + this.closeMenus(); + this.host.about.open(); return; } - if (act === 'about') { + if (act === 'settings') { this.closeMenus(); - this.host.about.open(); + this.host.settings?.open('general'); return; } @@ -263,12 +198,6 @@ export class AppMenuBar extends HTMLElement { return; } - if (act === 'netboot') { - this.closeMenus(); - this.host.netboot.open(); - return; - } - if (act === 'show-log') { this.closeMenus(); this.host.logPanel.toggle(); @@ -281,39 +210,6 @@ export class AppMenuBar extends HTMLElement { return; } - if (act === 'toggle-show-hidden') { - const next = !(this.host.finder?.getShowHiddenFiles?.() ?? false); - this.host.finder?.setShowHiddenFiles?.(next); - this.closeMenus(); - return; - } - - if (act === 'toggle-auto-expand') { - const next = !(this.host.finder?.getAutoExpandFiles?.() ?? false); - this.host.finder?.setAutoExpandFiles?.(next); - this.closeMenus(); - return; - } - - if (act === 'toggle-read-finder-icons') { - const next = !(this.host.finder?.getReadFinderIcons?.() ?? true); - this.host.finder?.setReadFinderIcons?.(next); - this.closeMenus(); - return; - } - - if (act === 'zip-appledouble' || act === 'zip-macosx') { - savePrefs({ zipExportStyle: act === 'zip-macosx' ? 'macosx' : 'appledouble' }); - this.render(); - return; - } - - if (act === 'extension-editor') { - this.closeMenus(); - this.host.extensionEditor.open(); - return; - } - if (act === 'resource-fork') { this.closeMenus(); this.host.finder?.openResourceExplorer(); @@ -329,24 +225,6 @@ export class AppMenuBar extends HTMLElement { })(); return; } - - if (act === 'export-prefs') { - this.closeMenus(); - this.exportPreferences(); - return; - } - - if (act === 'import-prefs') { - this.closeMenus(); - void this.importPreferences(); - return; - } - - if (act === 'reset-environment') { - this.closeMenus(); - void this.resetEnvironment(); - return; - } } private snapshotWindows(): void { @@ -359,7 +237,7 @@ export class AppMenuBar extends HTMLElement { persistWindow('info', host.getInfoWindow); } - private exportPreferences(): void { + exportPreferences(): void { this.snapshotWindows(); const json = stringifyPrefsBundle(); const stamp = new Date().toISOString().slice(0, 10); @@ -367,7 +245,7 @@ export class AppMenuBar extends HTMLElement { log.info('Exported preferences', 'app'); } - private async importPreferences(): Promise { + async importPreferences(): Promise { const host = this.host; if (!host) return; const file = await pickJsonFile(); @@ -392,20 +270,6 @@ export class AppMenuBar extends HTMLElement { log.info(`Imported preferences from “${file.name}”`, 'app'); location.reload(); } - - private async resetEnvironment(): Promise { - const host = this.host; - if (!host?.alertDialog || !host.resetEnvironment) return; - const result = await host.alertDialog.confirm({ - title: 'Reset environment', - text: 'This restores default window positions and preferences, then reloads ClassicStack.', - checkboxLabel: 'Erase all Browser Share items', - confirmLabel: 'Reset', - danger: true, - }); - if (!result.confirmed) return; - await host.resetEnvironment(result.checked); - } } function pickJsonFile(): Promise { diff --git a/src/ui/finder-host.ts b/src/ui/finder-host.ts index 38b1c57..173bd1e 100644 --- a/src/ui/finder-host.ts +++ b/src/ui/finder-host.ts @@ -50,6 +50,11 @@ export interface RemoteEndpoint { protocol?: string; /** How this client was reached (`tcp`, `ddp`, `ipx`, `nbp`, `etherdfs`). */ transport?: string; + /** + * `volume` is a mounted share (eject on this row). Default `server` lists + * volumes as children after login and shows Disconnect on this row. + */ + role?: 'server' | 'volume'; } /** Result of contacting a remote (or local) endpoint before / after login. */ @@ -69,6 +74,8 @@ export interface CredentialPromptOptions { uams: string[]; error?: string; allowGuest: boolean; + /** File-sharing scheme; omitted on the in-browser AFP host (UAMs). */ + kind?: ShareKind; } /** @@ -84,10 +91,29 @@ export interface FinderHost { * button can refresh only that service; omitted means all groups. */ refreshNetwork(scope?: string): Promise; + /** + * Last successful scan from the host (no network wait). FinderWindow paints + * this immediately on load/reload, then awaits `refreshNetwork` for new servers. + */ + cachedNetwork?(scope?: string): Promise; + /** + * Resolves once currently-open volumes (FUSE/WinFsp mounts and live sessions) + * are known. FinderWindow waits on this before restoring a URL path so it does + * not bounce to “server isn’t connected” while `/finder/mounted` is in flight. + */ + readyMounted?(): Promise; beginRemote(ep: RemoteEndpoint): Promise; loginRemote(creds: Credentials): Promise; openVolume(name: string): Promise; closeRemote(): Promise; + /** Close one opened volume (FPCloseVol / host unmount); stay logged in. */ + closeVolume?(name: string): Promise; + /** + * Open a catalog for a sidebar endpoint without changing the Finder’s + * current viewed session. Used when dropping onto a ClassicStack share or a + * FUSE-mounted volume while another catalog is on screen. + */ + openEndpointCatalog?(ep: RemoteEndpoint): Promise; /** IndexedDB Browser Share on the web PWA; null in the Go SPA. */ localCatalog(): Catalog | null; promptCredentials(opts: CredentialPromptOptions): Promise; diff --git a/src/ui/finder-sidebar.test.ts b/src/ui/finder-sidebar.test.ts index ec9f017..bd6b6a6 100644 --- a/src/ui/finder-sidebar.test.ts +++ b/src/ui/finder-sidebar.test.ts @@ -4,6 +4,9 @@ import { SIDEBAR_GROUP_NETWORK, assignSidebarGroup, endpointsByGroup, + isCatalogEndpoint, + shareKeyForEndpoint, + viewingCatalogEndpoint, visibleSidebarGroups, } from './finder-sidebar'; @@ -58,3 +61,27 @@ describe('visibleSidebarGroups', () => { expect(visibleSidebarGroups(classic, by).map((g) => g.id)).toEqual(['appletalk', 'smb', 'netware', 'etherdfs']); }); }); + +describe('share keys and drop targets', () => { + it('treats ClassicStack shares and FUSE mounts as catalog rows', () => { + const share = ep({ id: 'local:afp:HD', title: 'HD', kind: 'local' }); + const mounted = ep({ id: 'mounted:abc', title: 'SYS', kind: 'ncp', role: 'volume' }); + const server = ep({ id: 'Mac', title: 'Mac' }); + expect(isCatalogEndpoint(share)).toBe(true); + expect(isCatalogEndpoint(mounted)).toBe(true); + expect(isCatalogEndpoint(server)).toBe(false); + expect(shareKeyForEndpoint(share)).toBe('endpoint:local:afp:HD'); + expect(shareKeyForEndpoint(mounted)).toBe('endpoint:mounted:abc'); + expect(shareKeyForEndpoint(server, 'Mac HD')).toBe('Mac:Mac HD'); + }); + + it('switches to a ClassicStack share when another remote catalog is on screen', () => { + const share = ep({ id: 'local:afp:HD', title: 'HD', kind: 'local' }); + const remote = ep({ id: 'mounted:abc', title: 'SYS', kind: 'ncp', role: 'volume' }); + const server = ep({ id: 'Mac', title: 'Mac' }); + expect(viewingCatalogEndpoint(share, remote.id, 'remote', true)).toBe(false); + expect(viewingCatalogEndpoint(share, share.id, 'remote', true)).toBe(true); + expect(viewingCatalogEndpoint(share, share.id, 'remote', false)).toBe(false); + expect(viewingCatalogEndpoint(server, share.id, 'remote', true)).toBe(false); + }); +}); diff --git a/src/ui/finder-sidebar.ts b/src/ui/finder-sidebar.ts index 20f2407..563f4dc 100644 --- a/src/ui/finder-sidebar.ts +++ b/src/ui/finder-sidebar.ts @@ -68,3 +68,56 @@ export function visibleSidebarGroups( } return out; } + +/** PWA IndexedDB Browser Share. */ +export const LOCAL_SHARE_KEY = 'local'; + +/** + * True when the sidebar row is itself a catalog (ClassicStack live share or a + * FUSE/WinFsp mounted volume), not a server that lists volumes as children. + */ +export function isCatalogEndpoint(ep: RemoteEndpoint): boolean { + return ep.kind === 'local' || ep.role === 'volume'; +} + +/** Stable Finder catalog key for an endpoint and optional volume child. */ +export function shareKeyForEndpoint(ep: RemoteEndpoint, volume?: string): string { + if (isCatalogEndpoint(ep)) return `endpoint:${ep.id}`; + if (volume) return `${ep.id}:${volume}`; + return `endpoint:${ep.id}`; +} + +/** + * True when the on-screen Finder catalog is already this ClassicStack share + * or FUSE/WinFsp mount. Clicking the row must still switch catalogs when + * another remote volume is open — id/name matches are not enough. + */ +export function viewingCatalogEndpoint( + ep: RemoteEndpoint, + currentId: string | undefined, + source: 'local' | 'remote', + remoteOpen: boolean, +): boolean { + return isCatalogEndpoint(ep) && source === 'remote' && remoteOpen && currentId === ep.id; +} + +export type ShareDrop = { key: string; name: string }; + +/** + * Sidebar drop target under the pointer. ClassicStack shares and mounted + * volumes use `data-share-key`; Browser Share uses `data-local`. + */ +export function shareDropFromElement(target: EventTarget | null, sidebar: Element | null): ShareDrop | null { + const t = target instanceof Element ? target : null; + if (!t || !sidebar) return null; + if (t.closest('[data-eject], [data-eject-endpoint], [data-disconnect]')) return null; + const el = t.closest('[data-share-key], [data-local]') as HTMLElement | null; + if (!el || !sidebar.contains(el)) return null; + const key = el.getAttribute('data-share-key') || (el.hasAttribute('data-local') ? LOCAL_SHARE_KEY : ''); + if (!key) return null; + const name = + el.getAttribute('data-share-name') || + el.querySelector('.side-item-label')?.getAttribute('aria-label') || + ''; + return { key, name }; +} diff --git a/src/ui/finder-window.ts b/src/ui/finder-window.ts index 3ea392a..0720e55 100644 --- a/src/ui/finder-window.ts +++ b/src/ui/finder-window.ts @@ -65,12 +65,18 @@ import { } from '../fs/name-conflict'; import { decodePict, pictToSvg } from '../fs/pict/pict'; import { previewKindFor, previewMime, type FilePreviewKind } from './file-preview'; +import { isCatalogWithBackend } from '../finder/api'; import { SIDEBAR_GROUP_NETWORK, assignSidebarGroup, badgeText, badgeTitle, endpointsByGroup, + isCatalogEndpoint, + LOCAL_SHARE_KEY, + shareDropFromElement, + shareKeyForEndpoint, + viewingCatalogEndpoint, visibleSidebarGroups, } from './finder-sidebar'; @@ -143,9 +149,14 @@ export class FinderWindow extends HTMLElement { private welcomePackBusy = false; private showProps = false; private remoteOpen = false; - /** True after AFP login; volumes listed under the server until eject. */ + /** True after AFP login; volumes listed under the server until disconnect. */ private remoteLoggedIn = false; private remoteVolumes: string[] = []; + /** Volumes enumerated for a server stay in the sidebar after switching rows. */ + private knownVolumes = new Map(); + private loggedInEndpoints = new Set(); + /** Share keys of volumes the user has opened (eject is hidden until then). */ + private openedVolumeKeys = new Set(); private remoteBusy = false; private remoteNbpName = ''; private remoteEndpoint: RemoteEndpoint | null = null; @@ -325,6 +336,16 @@ export class FinderWindow extends HTMLElement { return this.readFinderIcons; } + getDefaultView(): ViewMode { + return loadPrefs().defaultView; + } + + /** Persist default Finder view for new sessions (does not change the current view). */ + setDefaultView(view: ViewMode): void { + if (view !== 'icon' && view !== 'list' && view !== 'column') return; + savePrefs({ defaultView: view }); + } + /** Toggle Icon\\r / resource-fork icon reads; persists and refreshes glyphs. */ setReadFinderIcons(read: boolean): void { if (this.readFinderIcons === read) return; @@ -337,7 +358,7 @@ export class FinderWindow extends HTMLElement { this.host = host; this.localVfs = vfs ?? host.localCatalog(); if (this.localVfs) { - this.catalogs.set('local', this.localVfs); + this.catalogs.set(LOCAL_SHARE_KEY, this.localVfs); this.attachCatalog(this.localVfs); } else { this.attachCatalog(new EmptyCatalog()); @@ -386,17 +407,27 @@ export class FinderWindow extends HTMLElement { private dropRemoteCatalogs(nbp?: string): void { const prefix = nbp ? `${nbp}:` : null; + const endpointKey = nbp ? `endpoint:${nbp}` : null; for (const key of [...this.catalogs.keys()]) { - if (key === 'local') continue; - if (prefix && !key.startsWith(prefix)) continue; + if (key === LOCAL_SHARE_KEY) continue; + if (prefix) { + if (key !== endpointKey && !key.startsWith(prefix)) continue; + } const cat = this.catalogs.get(key); if (this.clipboard && this.clipboard.source === cat) this.clipboard.source = null; this.catalogs.delete(key); } } + private catalogKeyForVolume(name: string): string { + if (this.remoteEndpoint && isCatalogEndpoint(this.remoteEndpoint)) { + return shareKeyForEndpoint(this.remoteEndpoint); + } + return `${this.remoteNbpName}:${name}`; + } + private mountCatalog(cat: Catalog, source: 'local' | 'remote', rootName: string): void { - const key = source === 'local' ? 'local' : `${this.remoteNbpName}:${rootName}`; + const key = source === 'local' ? LOCAL_SHARE_KEY : this.catalogKeyForVolume(rootName); this.catalogs.set(key, cat); this.attachCatalog(cat); this.source = source; @@ -418,6 +449,9 @@ export class FinderWindow extends HTMLElement { this.remoteNbpName = ''; this.remoteEndpoint = null; this.dropRemoteCatalogs(); + this.knownVolumes.clear(); + this.loggedInEndpoints.clear(); + this.openedVolumeKeys.clear(); if (status) this.setStatus(status); void this.reload().then(() => { this.syncHistory(); @@ -513,6 +547,37 @@ export class FinderWindow extends HTMLElement { this.render(); } + /** Sidebar endpoint named by a restored `?share=` / `?vol=` URL. */ + private findNavEndpoint(share: string, vol: string): RemoteEndpoint | undefined { + const shareKey = share.toLowerCase(); + const volKey = vol.toLowerCase(); + return this.servers.find((s) => { + if (s.id.toLowerCase() === shareKey) return true; + if ((s.title || '').toLowerCase() === shareKey) return true; + if (s.role === 'volume' && (s.title || '').toLowerCase() === volKey) { + const sub = (s.subtitle || '').toLowerCase(); + return !shareKey || sub === shareKey || s.id.toLowerCase() === shareKey; + } + return false; + }); + } + + /** + * Wait for open mounts (and the cached sidebar), then connect so a URL path + * can resolve against a live catalog. + */ + private async ensureRemoteForHistory( + state: ReturnType, + ): Promise { + if (this.remoteServerConnected(state.share)) return true; + if (this.host.readyMounted) await this.host.readyMounted(); + if (this.host.cachedNetwork) await this.refreshSidebarEndpoints(); + if (this.remoteServerConnected(state.share)) return true; + const ep = this.findNavEndpoint(state.share, state.vol); + if (!ep) return false; + return this.connectServerWithLogin(ep); + } + setStatus(msg: string, opts?: { busy?: boolean }): void { this.status = msg; this.statusBusy = opts?.busy ?? false; @@ -748,7 +813,9 @@ export class FinderWindow extends HTMLElement { const params = new URLSearchParams(location.search); const viewParam = params.get('view'); const view: ViewMode = - viewParam === 'list' || viewParam === 'column' || viewParam === 'icon' ? viewParam : 'icon'; + viewParam === 'list' || viewParam === 'column' || viewParam === 'icon' + ? viewParam + : loadPrefs().defaultView; const share = params.get('share') ?? ''; const vol = params.get('vol') ?? ''; const pathRaw = params.get('path') ?? ''; @@ -864,7 +931,7 @@ export class FinderWindow extends HTMLElement { await this.reload(); return; } - if (!this.host.isConnected() || !this.remoteServerConnected(state.share)) { + if (!this.host.isConnected() || !(await this.ensureRemoteForHistory(state))) { bounceToLocal = true; this.bounceRemoteNavigation( `Cannot navigate to “${target}” — that server isn’t connected.`, @@ -1457,7 +1524,7 @@ export class FinderWindow extends HTMLElement { .join(''); const localBlock = this.hasLocalShare() ? `
Local
-
+
${this.escape(this.localShareTitle())} @@ -1490,6 +1557,46 @@ export class FinderWindow extends HTMLElement { return `${this.escape(text)}`; } + private volumesFor(s: RemoteEndpoint): string[] { + const cached = this.knownVolumes.get(s.id); + if (cached?.length) return cached; + const current = this.remoteNbpName || this.remoteEndpoint?.id || ''; + if (this.remoteLoggedIn && s.id === current) return this.remoteVolumes; + return []; + } + + /** True when the on-screen catalog is already this sidebar endpoint. */ + private viewingEndpoint(s: RemoteEndpoint): boolean { + const currentId = this.remoteEndpoint?.id || this.remoteNbpName; + if (isCatalogEndpoint(s)) { + return viewingCatalogEndpoint(s, currentId, this.source, this.remoteOpen); + } + if (this.source !== 'remote' || !this.remoteOpen || currentId !== s.id) return false; + const openName = this.pathStack[0]?.name; + return !!openName && this.volumesFor(s).includes(openName); + } + + private async openCatalogVolume(s: RemoteEndpoint): Promise { + const name = this.volumesFor(s)[0] || this.remoteVolumes[0] || s.title; + if (!name) throw new Error(`Couldn’t open “${s.title}”`); + await this.mountRemoteVolume(name); + } + + private forgetEndpoint(id: string): void { + if (!id) return; + this.knownVolumes.delete(id); + this.loggedInEndpoints.delete(id); + for (const k of [...this.openedVolumeKeys]) { + if (k === `endpoint:${id}` || k.startsWith(`${id}:`)) this.openedVolumeKeys.delete(k); + } + } + + private volumeIsOpen(s: RemoteEndpoint, volume?: string): boolean { + if (s.role === 'volume') return true; + if (!volume) return false; + return this.openedVolumeKeys.has(shareKeyForEndpoint(s, volume)); + } + private sidebarEndpointHtml( s: RemoteEndpoint, i: number, @@ -1501,32 +1608,49 @@ export class FinderWindow extends HTMLElement { viewingServer: boolean; }, ): string { - const connected = this.remoteLoggedIn && s.id === opts.connectedId; + const connected = this.loggedInEndpoints.has(s.id) || (this.remoteLoggedIn && s.id === opts.connectedId); const localShare = s.kind === 'local'; - const serverSel = connected && (localShare ? this.source === 'remote' : opts.viewingServer) ? 'selected' : ''; + const volumeRow = s.role === 'volume'; + const isCurrent = s.id === opts.connectedId; + const serverSel = isCurrent && (localShare ? this.source === 'remote' : opts.viewingServer) ? 'selected' : ''; + const volumes = this.volumesFor(s); const kids = - connected && !localShare && opts.volumes.length - ? opts.volumes - .map( - (v, vi) => ` -
+ !localShare && !volumeRow && volumes.length + ? volumes + .map((v, vi) => { + const shareKey = shareKeyForEndpoint(s, v); + const selected = isCurrent && !opts.viewingLocal && opts.openVol === v ? 'selected' : ''; + const eject = this.volumeIsOpen(s, v) + ? `` + : ''; + return ` +
${this.escape(v)} -
`, - ) + ${eject} +
`; + }) .join('') : ''; - const eject = - connected && !localShare - ? `` + const ejectSelf = + volumeRow && !localShare + ? `` + : ''; + const disconnect = + connected && !localShare && !volumeRow + ? `` : ''; const subtitle = s.subtitle ? ` title="${this.escape(s.subtitle)}"` : ''; + const shareAttrs = isCatalogEndpoint(s) + ? ` data-share-key="${this.escape(shareKeyForEndpoint(s))}" data-share-name="${this.escape(s.title)}"` + : ''; return ` -
+
${this.escape(s.title)} ${this.sidebarBadgeHtml(s.badge)} - ${eject} + ${ejectSelf} + ${disconnect}
${kids}`; } @@ -2546,6 +2670,21 @@ export class FinderWindow extends HTMLElement { this.renderContextMenu(); if (sidebar) { const ep = this.servers[sidebar.index]; + if (action === 'disconnect') { + if (ep && ep.id !== this.remoteEndpoint?.id && ep.id !== this.remoteNbpName) { + await this.host.onSidebarAction?.(ep, 'disconnect'); + this.forgetEndpoint(ep.id); + this.renderSidebar(); + return; + } + await this.disconnectRemote(); + return; + } + if (action === 'eject' || action === 'unmount') { + if (sidebar.volume) await this.ejectVolume(sidebar.volume); + else if (ep) await this.ejectEndpoint(ep); + return; + } if (ep) await this.host.onSidebarAction?.(ep, action, sidebar.volume); return; } @@ -2622,15 +2761,49 @@ export class FinderWindow extends HTMLElement { if (ejectEl) { e.preventDefault(); e.stopPropagation(); - await this.ejectRemote(); + const name = + ejectEl.getAttribute('data-vol-name') || + this.remoteVolumes[Number(ejectEl.getAttribute('data-eject'))]; + if (name) await this.ejectVolume(name); + return; + } + const ejectEpEl = t.closest('[data-eject-endpoint]'); + if (ejectEpEl) { + e.preventDefault(); + e.stopPropagation(); + const i = Number(ejectEpEl.getAttribute('data-eject-endpoint')); + const ep = this.servers[i]; + if (ep) await this.ejectEndpoint(ep); + return; + } + const disconnectEl = t.closest('[data-disconnect]'); + if (disconnectEl) { + e.preventDefault(); + e.stopPropagation(); + const i = Number(disconnectEl.getAttribute('data-disconnect')); + const ep = this.servers[i]; + if (ep && ep.id !== this.remoteEndpoint?.id && ep.id !== this.remoteNbpName) { + await this.host.onSidebarAction?.(ep, 'disconnect'); + this.forgetEndpoint(ep.id); + this.renderSidebar(); + return; + } + await this.disconnectRemote(); return; } const volEl = t.closest('[data-vol]'); if (volEl) { - const vi = Number(volEl.getAttribute('data-vol')); - const name = this.remoteVolumes[vi]; + const parentI = Number(volEl.getAttribute('data-server-parent')); + const parent = Number.isFinite(parentI) ? this.servers[parentI] : this.remoteEndpoint; + const name = + volEl.getAttribute('data-vol-name') || + this.remoteVolumes[Number(volEl.getAttribute('data-vol'))]; if (!name) return; try { + if (parent && parent.id !== this.remoteEndpoint?.id) { + const ok = await this.connectServerWithLogin(parent); + if (!ok) return; + } await this.mountRemoteVolume(name); this.closeSidebar(); await this.reload(); @@ -2649,7 +2822,14 @@ export class FinderWindow extends HTMLElement { const s = this.servers[i]; if (!s) return; if (this.remoteBusy) return; - if (this.remoteLoggedIn && this.remoteNbpName === s.id) { + if (this.viewingEndpoint(s)) { + this.closeSidebar(); + await this.reload(); + this.syncHistory(); + this.render(); + return; + } + if (!isCatalogEndpoint(s) && this.remoteLoggedIn && this.remoteNbpName === s.id) { if (this.remoteOpen) { this.closeSidebar(); await this.reload(); @@ -3108,31 +3288,30 @@ export class FinderWindow extends HTMLElement { } private currentShareKey(): string { - return this.source === 'local' ? 'local' : `${this.remoteNbpName}:${this.pathStack[0]?.name ?? ''}`; + if (this.source === 'local') return LOCAL_SHARE_KEY; + if (this.remoteEndpoint && isCatalogEndpoint(this.remoteEndpoint)) { + return shareKeyForEndpoint(this.remoteEndpoint); + } + return `${this.remoteNbpName}:${this.pathStack[0]?.name ?? ''}`; } - /** Sidebar share under the pointer (Browser Share or a mounted/listed volume). */ + /** Sidebar share under the pointer (Browser Share, ClassicStack share, or mounted volume). */ private shareDropFromEvent(e: DragEvent): { key: string; name: string } | null { - const t = e.target as HTMLElement | null; - if (!t) return null; - if (t.closest('[data-eject]')) return null; - const side = this.querySelector('.sidebar'); - if (!side) return null; - if (t.closest('[data-local]') && side.contains(t.closest('[data-local]')!)) { - return { key: 'local', name: this.localShareTitle() }; - } - const volEl = t.closest('[data-vol]') as HTMLElement | null; - if (!volEl || !side.contains(volEl) || !this.remoteLoggedIn) return null; - const vi = Number(volEl.getAttribute('data-vol')); - const name = this.remoteVolumes[vi]; - if (!name) return null; - return { key: `${this.remoteNbpName}:${name}`, name }; + return shareDropFromElement(e.target, this.querySelector('.sidebar')); } private async ensureShareCatalog(key: string): Promise { - if (key === 'local') return this.localVfs ?? this.catalogs.get('local') ?? null; + if (key === LOCAL_SHARE_KEY) return this.localVfs ?? this.catalogs.get(LOCAL_SHARE_KEY) ?? null; const existing = this.catalogs.get(key); if (existing) return existing; + if (key.startsWith('endpoint:')) { + const id = key.slice('endpoint:'.length); + const ep = this.servers.find((s) => s.id === id); + if (!ep || !this.host.openEndpointCatalog) return null; + const cat = await this.host.openEndpointCatalog(ep); + this.catalogs.set(key, cat); + return cat; + } const prefix = `${this.remoteNbpName}:`; if (!this.remoteLoggedIn || !key.startsWith(prefix)) return null; const name = key.slice(prefix.length); @@ -3150,7 +3329,7 @@ export class FinderWindow extends HTMLElement { } | null { const share = this.shareDropFromEvent(e); if (share) { - const cat = this.catalogs.get(share.key) ?? (share.key === 'local' ? (this.localVfs ?? null) : null); + const cat = this.catalogs.get(share.key) ?? (share.key === LOCAL_SHARE_KEY ? (this.localVfs ?? null) : null); return { catalog: cat, parentId: cat?.rootId() ?? 0, @@ -3172,7 +3351,7 @@ export class FinderWindow extends HTMLElement { const share = this.shareDropFromEvent(e); if (share) { const destCat = - this.catalogs.get(share.key) ?? (share.key === 'local' ? (this.localVfs ?? null) : null); + this.catalogs.get(share.key) ?? (share.key === LOCAL_SHARE_KEY ? (this.localVfs ?? null) : null); if (this.isInternalDrag() && this.dragNodeId != null && destCat && destCat === this.dragCatalog) { if (!this.isValidMoveTarget(this.dragNodeId, destCat.rootId(), destCat)) { if (e.dataTransfer) e.dataTransfer.dropEffect = 'none'; @@ -3263,8 +3442,8 @@ export class FinderWindow extends HTMLElement { content?.classList.remove('drop-active'); if (shareKey != null) { - const sel = shareKey === 'local' ? '[data-local]' : this.shareVolumeSelector(shareKey); - if (sel) this.querySelector(`.sidebar ${sel}`)?.classList.add('drop-target'); + const sel = `[data-share-key="${CSS.escape(shareKey)}"]`; + this.querySelector(`.sidebar ${sel}`)?.classList.add('drop-target'); return; } @@ -3292,14 +3471,6 @@ export class FinderWindow extends HTMLElement { content?.classList.add('drop-active'); } - private shareVolumeSelector(shareKey: string): string | null { - const prefix = `${this.remoteNbpName}:`; - if (!shareKey.startsWith(prefix)) return null; - const name = shareKey.slice(prefix.length); - const vi = this.remoteVolumes.indexOf(name); - return vi >= 0 ? `[data-vol="${vi}"]` : null; - } - private clearDropUi(): void { this.dropHoverFolderId = null; this.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target')); @@ -3320,9 +3491,13 @@ export class FinderWindow extends HTMLElement { this.springTimer = null; if (this.springShareKey !== key) return; if (this.currentShareKey() === key) return; - this.parkDragSource(); try { - if (key === 'local') { + if (key.startsWith('endpoint:')) { + await this.ensureShareCatalog(key); + return; + } + this.parkDragSource(); + if (key === LOCAL_SHARE_KEY) { this.showLocalShare(); } else { const prefix = `${this.remoteNbpName}:`; @@ -3682,6 +3857,26 @@ export class FinderWindow extends HTMLElement { const jobId = this.startTransfer(plan.destName, node.isDir, expected, node.finderInfo); transferActivity.setDest(jobId, dest, destParent, plan.destName); const signal = transferActivity.signal(jobId); + if (isCatalogWithBackend(src) && isCatalogWithBackend(dest) && src.api.backendId === dest.api.backendId) { + try { + await dest.copyFrom(src, id, destParent, { + destName: plan.destName, + replace: plan.replaceId != null, + replaceId: plan.replaceId, + signal, + onProgress: (p) => { + const cur = transferActivity.list().find((j) => j.id === jobId)?.bytesDone || 0; + if (typeof p.bytesTotal === 'number') transferActivity.setTotal(jobId, p.bytesTotal); + if (typeof p.bytesDone === 'number' && p.bytesDone > cur) transferActivity.addBytes(jobId, p.bytesDone - cur); + }, + }); + await transferActivity.settle(jobId); + return; + } catch (err) { + await transferActivity.settle(jobId, err); + throw err; + } + } await this.withOwnVfsMutation(async () => { dest.beginBatch(); try { @@ -3964,6 +4159,32 @@ export class FinderWindow extends HTMLElement { private async connectServerWithLogin(s: RemoteEndpoint): Promise { if (this.remoteBusy) return false; + if (this.loggedInEndpoints.has(s.id)) { + const alreadyViewing = this.viewingEndpoint(s); + this.remoteEndpoint = s; + this.remoteNbpName = s.id; + this.remoteVolumes = this.volumesFor(s); + this.remoteLoggedIn = true; + try { + await this.host.beginRemote(s); + } catch { + /* keep cached volumes */ + } + if (!alreadyViewing && isCatalogEndpoint(s)) { + try { + await this.openCatalogVolume(s); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.error(`Open volume failed: ${msg}`, s.kind); + this.setStatus(`Open volume failed: ${msg}`); + return false; + } + } + this.remoteOpen = alreadyViewing; + this.renderSidebar(); + return true; + } this.remoteBusy = true; try { this.setStatus(`Contacting ${s.title}…`, { busy: true }); @@ -3973,7 +4194,7 @@ export class FinderWindow extends HTMLElement { this.remoteOpen = false; if (this.source === 'remote') this.showLocalShare(); const info: SessionInfo = await this.host.beginRemote(s); - this.dropRemoteCatalogs(); + if (this.remoteNbpName === s.id) this.dropRemoteCatalogs(s.id); const uams = info.uams ?? []; const skipPrompt = info.allowGuest && uams.length === 0; this.setStatus(`Connected to ${info.serverName || s.title} — sign in`); @@ -3986,6 +4207,7 @@ export class FinderWindow extends HTMLElement { uams, error, allowGuest: info.allowGuest, + kind: s.kind, }); if (!creds) { await this.host.closeRemote().catch(() => undefined); @@ -4002,6 +4224,8 @@ export class FinderWindow extends HTMLElement { this.remoteNbpName = s.id; this.remoteEndpoint = s; this.remoteOpen = false; + this.knownVolumes.set(s.id, [...this.remoteVolumes]); + this.loggedInEndpoints.add(s.id); this.setStatus( `Signed in to ${info.serverName || s.title} — ${this.remoteVolumes.length} volume(s)`, ); @@ -4036,28 +4260,91 @@ export class FinderWindow extends HTMLElement { private async mountRemoteVolume(name: string): Promise { log.info(`Opening volume “${name}”`, 'afp'); - const key = `${this.remoteNbpName}:${name}`; + const key = this.catalogKeyForVolume(name); const cat = await this.ensureShareCatalog(key); if (!cat) throw new Error(`Couldn’t open volume “${name}”`); this.mountCatalog(cat, 'remote', name); this.remoteOpen = true; + if (this.remoteEndpoint) this.openedVolumeKeys.add(shareKeyForEndpoint(this.remoteEndpoint, name)); this.setStatus( this.remoteEndpoint?.kind === 'local' ? `Opened ${name}` : `Mounted ${this.remoteNbpName}:${name}`, ); } - private async ejectRemote(): Promise { - log.info(`Eject “${this.remoteNbpName || 'remote'}”`, 'afp'); + private async disconnectRemote(): Promise { + log.info(`Disconnect “${this.remoteNbpName || 'remote'}”`, 'afp'); const nbp = this.remoteNbpName; await this.host.closeRemote().catch(() => undefined); this.dropRemoteCatalogs(nbp); + this.forgetEndpoint(nbp); this.resetToLocalShare(); this.setStatus('Disconnected from server'); + await this.refreshSidebarEndpoints(); + await this.reload(); + this.syncHistory(); + this.render(); + } + + private async ejectVolume(name: string): Promise { + log.info(`Eject volume “${name}”`, 'afp'); + const viewing = this.source === 'remote' && this.pathStack[0]?.name === name; + if (viewing) { + this.abortAllListings(); + this.bumpIconLoadGen(); + } + const key = this.catalogKeyForVolume(name); + const cat = this.catalogs.get(key); + if (this.clipboard && this.clipboard.source === cat) this.clipboard.source = null; + this.catalogs.delete(key); + if (this.remoteEndpoint) this.openedVolumeKeys.delete(shareKeyForEndpoint(this.remoteEndpoint, name)); + if (viewing) { + this.showLocalShare(); + this.remoteOpen = false; + } + await this.host.closeVolume?.(name).catch(() => undefined); + if (!this.host.closeVolume) { + const ep = this.remoteEndpoint; + if (ep) await this.host.onSidebarAction?.(ep, 'unmount', name); + } + this.setStatus(`Ejected ${name}`); + await this.refreshSidebarEndpoints(); await this.reload(); this.syncHistory(); this.render(); } + private async ejectEndpoint(ep: RemoteEndpoint): Promise { + log.info(`Eject “${ep.title}”`, ep.kind); + const current = this.remoteLoggedIn && this.remoteEndpoint?.id === ep.id; + if (current) { + const nbp = this.remoteNbpName; + await this.host.closeRemote().catch(() => undefined); + this.dropRemoteCatalogs(nbp); + this.resetToLocalShare(); + } else if (this.remoteLoggedIn && ep.role === 'volume' && this.remoteEndpoint?.role !== 'volume') { + await this.ejectVolume(ep.title); + return; + } else { + await this.host.onSidebarAction?.(ep, 'eject'); + } + this.setStatus(`Ejected ${ep.title}`); + await this.refreshSidebarEndpoints(); + await this.reload(); + this.syncHistory(); + this.render(); + } + + private async refreshSidebarEndpoints(): Promise { + try { + const list = this.host.cachedNetwork + ? await this.host.cachedNetwork() + : await this.host.refreshNetwork(); + this.setServers(list); + } catch { + this.renderSidebar(); + } + } + private async onRefresh(groupId?: string): Promise { const groups = this.sidebarGroups(); const title = groupId ? groups.find((g) => g.id === groupId)?.title : undefined; @@ -4071,6 +4358,13 @@ export class FinderWindow extends HTMLElement { : 'Looking up servers…', ); try { + if (this.host.cachedNetwork) { + try { + this.setServers(await this.host.cachedNetwork(groupId)); + } catch { + /* scan still runs */ + } + } const list = await this.host.refreshNetwork(groupId); this.setServers(list); const scoped = groupId @@ -4285,6 +4579,24 @@ export class FinderWindow extends HTMLElement { const track = this.trackImportItem({ name: node.name, isDir: false, bytesTotal }, this.vfs, node.parentId, false); this.setStatus(`Expanding “${node.name}”…`, { busy: true }); try { + if (isCatalogWithBackend(this.vfs)) { + let last = 0; + await this.vfs.expandNode(id, { + signal: track.signal, + onProgress: (p) => { + const next = p.bytesDone || 0; + if (next > last) track.onBytes?.(next - last); + last = next; + }, + }); + track.onDone?.(); + this.setStatus(`Expanded “${node.name}”`); + iconCache.clearDirectoryCache(); + this.iconUrls.clear(); + this.bumpIconLoadGen(); + await this.refreshAfterMutation(); + return; + } const inPlace = await expandSitInPlace(this.vfs, node, { fileSize: node.dataBytes ?? node.data.length, track, @@ -4931,13 +5243,38 @@ export class FinderWindow extends HTMLElement { let index = -1; let volume: string | undefined; if (volEl) { - index = this.servers.findIndex((s) => s.id === (this.remoteEndpoint?.id || this.remoteNbpName)); - volume = this.remoteVolumes[Number(volEl.getAttribute('data-vol'))]; + index = Number(volEl.getAttribute('data-server-parent')); + if (!Number.isFinite(index) || index < 0) { + index = this.servers.findIndex((s) => s.id === (this.remoteEndpoint?.id || this.remoteNbpName)); + } + const parent = this.servers[index]; + volume = + volEl.getAttribute('data-vol-name') || + (parent ? this.volumesFor(parent)[Number(volEl.getAttribute('data-vol'))] : undefined); } else if (serverEl) { index = Number(serverEl.getAttribute('data-server')); } const ep = this.servers[index]; - const actions = ep ? (this.host.sidebarContextMenu?.(ep, volume) ?? []) : []; + const hostActions = ep ? (this.host.sidebarContextMenu?.(ep, volume) ?? []) : []; + const actions: SidebarAction[] = []; + if (ep && (volume || ep.role === 'volume')) { + actions.push({ id: 'info', label: 'Get Info…' }); + if (this.volumeIsOpen(ep, volume) || ep.role === 'volume') { + actions.push({ id: 'eject', label: 'Eject' }); + } + } else if (ep) { + actions.push({ id: 'info', label: 'Get Info…' }); + if (ep.kind === 'afp' || ep.kind === 'smb') { + actions.push({ id: 'message', label: 'Send Message…' }); + } + if (this.loggedInEndpoints.has(ep.id) || (this.remoteLoggedIn && (ep.id === this.remoteNbpName || ep.id === this.remoteEndpoint?.id))) { + actions.push({ id: 'disconnect', label: 'Disconnect' }); + } + } + for (const a of hostActions) { + if (actions.some((x) => x.id === a.id)) continue; + actions.push(a); + } if (!ep || !actions.length) return; e.preventDefault(); this.contextMenu = { diff --git a/src/ui/login-dialog.ts b/src/ui/login-dialog.ts index 852b811..5b31b7c 100644 --- a/src/ui/login-dialog.ts +++ b/src/ui/login-dialog.ts @@ -1,10 +1,10 @@ import { log } from '../util/logger'; -import type { CredentialPromptOptions, Credentials } from './finder-host'; +import type { CredentialPromptOptions, Credentials, ShareKind } from './finder-host'; export type LoginCredentials = Credentials; export type LoginPromptOptions = CredentialPromptOptions; -/** Modal AFP login (guest or username/password). */ +/** Modal login (guest or username/password) for AFP, SMB, and NCP. */ export class LoginDialog extends HTMLElement { private opts: LoginPromptOptions | null = null; private pending: ((v: LoginCredentials | null) => void) | null = null; @@ -31,7 +31,7 @@ export class LoginDialog extends HTMLElement { this.opts = opts; this.hidden = false; this.render(); - log.info(`Login dialog for “${opts.serverName}” UAMs=[${opts.uams.join(', ')}]`, 'afp'); + log.info(`Login dialog for “${opts.serverName}” ${authLabel(opts.kind)}=[${opts.uams.join(', ')}]`, opts.kind || 'afp'); queueMicrotask(() => { const user = this.querySelector('[data-field="user"]'); user?.focus(); @@ -75,8 +75,8 @@ export class LoginDialog extends HTMLElement { this.innerHTML = ''; return; } - const name = escapeHtml(opts.serverName || 'AFP server'); - const uams = opts.uams.length ? escapeHtml(opts.uams.join(', ')) : 'none advertised'; + const name = escapeHtml(opts.serverName || serverNoun(opts.kind)); + const methods = opts.uams.length ? escapeHtml(opts.uams.join(', ')) : 'none advertised'; const err = opts.error && !this.busy ? `` : ''; const guest = opts.allowGuest @@ -85,6 +85,7 @@ export class LoginDialog extends HTMLElement { const connectLabel = this.busy ? ` Signing in…` : 'Connect'; + const passMax = opts.kind === 'afp' || !opts.kind ? 'maxlength="8" ' : ''; this.innerHTML = `