diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 307806d..727ae07 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -110,13 +110,13 @@ jobs: include: - os: ubuntu-latest script: bash scripts/ci/build.sh - output: out/omnitalk + output: out/classicstack - os: macos-latest script: bash scripts/ci/build.sh - output: out/omnitalk + output: out/classicstack - os: windows-latest script: ./scripts/ci/build.ps1 - output: out/omnitalk.exe + output: out/classicstack.exe steps: - name: Checkout uses: actions/checkout@v4 @@ -132,7 +132,7 @@ jobs: sudo apt-get update sudo apt-get install -y libpcap-dev - - name: Build omnitalk (Linux/macOS) + - name: Build classicstack (Linux/macOS) if: runner.os != 'Windows' shell: bash env: @@ -141,7 +141,7 @@ jobs: OUTPUT: ${{ matrix.output }} run: ${{ matrix.script }} - - name: Build omnitalk (Windows) + - name: Build classicstack (Windows) if: runner.os == 'Windows' shell: pwsh env: diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml index 6cdf567..5ff401c 100644 --- a/.github/workflows/release-main.yml +++ b/.github/workflows/release-main.yml @@ -49,57 +49,57 @@ jobs: # Linux - all - os: ubuntu-latest variant: all - artifact_name: omnitalk-linux - archive_name: omnitalk-${{ needs.version.outputs.release_tag }}-linux-amd64.tar.gz + artifact_name: classicstack-linux + archive_name: classicstack-${{ needs.version.outputs.release_tag }}-linux-amd64.tar.gz build_script: bash scripts/ci/build.sh package_script: bash scripts/ci/package-release.sh target_os: linux - output: out/omnitalk + output: out/classicstack # Linux - router - os: ubuntu-latest variant: router - artifact_name: omnitalk-router-linux - archive_name: omnitalk-router-${{ needs.version.outputs.release_tag }}-linux-amd64.tar.gz + artifact_name: classicstack-router-linux + archive_name: classicstack-router-${{ needs.version.outputs.release_tag }}-linux-amd64.tar.gz build_script: bash scripts/ci/build.sh package_script: bash scripts/ci/package-release.sh target_os: linux - output: out/omnitalk-router + output: out/classicstack-router # macOS - all - os: macos-latest variant: all - artifact_name: omnitalk-macos - archive_name: omnitalk-${{ needs.version.outputs.release_tag }}-macos-amd64.zip + artifact_name: classicstack-macos + archive_name: classicstack-${{ needs.version.outputs.release_tag }}-macos-amd64.zip build_script: bash scripts/ci/build.sh package_script: bash scripts/ci/package-release.sh target_os: macos - output: out/omnitalk + output: out/classicstack # macOS - router - os: macos-latest variant: router - artifact_name: omnitalk-router-macos - archive_name: omnitalk-router-${{ needs.version.outputs.release_tag }}-macos-amd64.zip + artifact_name: classicstack-router-macos + archive_name: classicstack-router-${{ needs.version.outputs.release_tag }}-macos-amd64.zip build_script: bash scripts/ci/build.sh package_script: bash scripts/ci/package-release.sh target_os: macos - output: out/omnitalk-router + output: out/classicstack-router # Windows - all - os: windows-latest variant: all - artifact_name: omnitalk-windows - archive_name: omnitalk-${{ needs.version.outputs.release_tag }}-windows-amd64.zip + artifact_name: classicstack-windows + archive_name: classicstack-${{ needs.version.outputs.release_tag }}-windows-amd64.zip build_script: ./scripts/ci/build.ps1 package_script: ./scripts/ci/package-release.ps1 target_os: windows - output: out/omnitalk.exe + output: out/classicstack.exe # Windows - router - os: windows-latest variant: router - artifact_name: omnitalk-router-windows - archive_name: omnitalk-router-${{ needs.version.outputs.release_tag }}-windows-amd64.zip + artifact_name: classicstack-router-windows + archive_name: classicstack-router-${{ needs.version.outputs.release_tag }}-windows-amd64.zip build_script: ./scripts/ci/build.ps1 package_script: ./scripts/ci/package-release.ps1 target_os: windows - output: out/omnitalk-router.exe + output: out/classicstack-router.exe steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 88c9d22..f0a8c74 100644 --- a/.gitignore +++ b/.gitignore @@ -35,8 +35,8 @@ go.work.sum /leases.txt # Generated by scripts/ci/build.ps1 -/cmd/omnitalk/resource.syso -/cmd/omnitalk/versioninfo.json +/cmd/classicstack/resource.syso +/cmd/classicstack/versioninfo.json ._htmlcache/ .macgarden/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9c13d55..d73e15f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ -# OmniTalk Architecture +# ClassicStack Architecture -OmniTalk is a Go AppleTalk Phase 2 router and AFP file server. It bridges +ClassicStack is a Go AppleTalk Phase 2 router and AFP file server. It bridges legacy Apple networking protocols to modern environments — EtherTalk (raw Ethernet), LToUDP (multicast UDP), TashTalk (serial), and virtual LocalTalk transports — and serves AFP volumes over both the @@ -13,7 +13,7 @@ import. ## Module map ``` -cmd/omnitalk/ wiring only — flag/INI parsing, service registration +cmd/classicstack/ wiring only — flag/INI parsing, service registration config/ single typed config tree; INI loader, validation protocol/ wire format only (codec + constants, zero I/O) ddp/ DDP datagram + MacRoman codec @@ -59,11 +59,11 @@ cmd → service → (protocol | port | pkg) not about higher protocols. - `service/*` owns sockets, sessions, and state machines. It composes `protocol` codecs over `port` transports. -- `pkg/*` is reusable outside OmniTalk. It must not import anything +- `pkg/*` is reusable outside ClassicStack. It must not import anything under `service/`, `port/`, `cmd/`, or `router/`. -- `internal/*` is private to OmniTalk. Mocks and shared test harness +- `internal/*` is private to ClassicStack. Mocks and shared test harness live here. -- `cmd/omnitalk/` does no business logic. It parses configuration +- `cmd/classicstack/` does no business logic. It parses configuration and wires services together. ## Core interfaces @@ -86,7 +86,7 @@ Single typed tree in `config/`. Two loaders feed it: 1. TOML — `config.Load(path)` parses `server.toml` via `knadh/koanf` with the `pelletier/go-toml` v2 parser. -2. Flags — `cmd/omnitalk/main.go` overlays CLI flags on top of the +2. Flags — `cmd/classicstack/main.go` overlays CLI flags on top of the file defaults. `config.Root.Validate()` runs once before services start. Services @@ -95,14 +95,14 @@ are immutable: ports do not mutate themselves after `Start()`. ## Logging and telemetry -OmniTalk has two logging packages with distinct jobs: +ClassicStack has two logging packages with distinct jobs: - **`netlog/`** is the call-site API. Services and ports use `netlog.Debug`, `netlog.Info`, `netlog.Warn`. The facade keeps call sites short (no per-package `*slog.Logger` plumbing) while still - routing through whatever structured handler `cmd/omnitalk` installs. + routing through whatever structured handler `cmd/classicstack` installs. - **`pkg/logging/`** is the slog factory used once at startup. - `cmd/omnitalk` calls `logging.New("OmniTalk", ...)` to build a + `cmd/classicstack` calls `logging.New("ClassicStack", ...)` to build a `*slog.Logger` with the configured handler (console, JSON, or both) and installs it via `netlog.SetLogger`. Use this directly only when you need a `*slog.Logger` value — e.g. attaching structured fields @@ -114,14 +114,14 @@ format, and the slog handler stamps every record with a `source` attribute that JSON consumers can filter on. Stdlib `log.Printf` and `log.Fatal` are not used inside library code. -`cmd/omnitalk/main.go` uses `log.Fatal*` only for unrecoverable startup +`cmd/classicstack/main.go` uses `log.Fatal*` only for unrecoverable startup errors before any logger is wired. Telemetry is `pkg/telemetry`, separate from logs. Default backend is `expvar` (stdlib, zero deps). Initial counters: -- `omnitalk_router_frames_in_total` -- `omnitalk_afp_commands_total` -- `omnitalk_aarp_probe_retries_total` +- `classicstack_router_frames_in_total` +- `classicstack_afp_commands_total` +- `classicstack_aarp_probe_retries_total` A future `//go:build otel` file will swap in an OpenTelemetry backend without touching call sites. @@ -150,7 +150,7 @@ proceeds one type per commit with golden hex round-trip tests. ## Timer and retry patterns -OmniTalk does not use exponential backoff. The protocols predate it. +ClassicStack does not use exponential backoff. The protocols predate it. Three canonical shapes: 1. **Reliable-delivery retransmits** (ATP-style). Per-transaction diff --git a/CLAUDE.md b/CLAUDE.md index 21f8b17..a68af64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,16 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -OmniTalk is a Go-based AppleTalk Phase 2 router and AFP file server. It bridges legacy Apple networking protocols to modern environments, supporting EtherTalk (raw Ethernet), LToUDP (multicast UDP), TashTalk (serial), and virtual LocalTalk transports. +ClassicStack is a Go-based AppleTalk Phase 2 router and AFP file server. It bridges legacy Apple networking protocols to modern environments, supporting EtherTalk (raw Ethernet), LToUDP (multicast UDP), TashTalk (serial), and virtual LocalTalk transports. -**Module:** `github.com/pgodw/omnitalk` +**Module:** `github.com/ObsoleteMadness/ClassicStack` **Go version:** 1.23.0 ## Commands ```bash # Build -go build -o omnitalk ./cmd/omnitalk +go build -o classicstack ./cmd/classicstack # Run all tests go test ./... @@ -22,10 +22,10 @@ go test ./... go test ./service/afp/... # Run with TOML config -./omnitalk # auto-loads server.toml if present +./classicstack # auto-loads server.toml if present # Run with flags (see README.md for full list) -./omnitalk -ethertalk eth0 -zone "MyZone" +./classicstack -ethertalk eth0 -zone "MyZone" ``` ## Architecture @@ -33,10 +33,10 @@ go test ./service/afp/... ### Core Data Flow ``` -cmd/omnitalk/main.go → Ports → Router → Services +cmd/classicstack/main.go → Ports → Router → Services ``` -1. **Entry point** (`cmd/omnitalk/`) parses CLI flags and `server.toml`, constructs ports, wires them to the router, and starts services. +1. **Entry point** (`cmd/classicstack/`) parses CLI flags and `server.toml`, constructs ports, wires them to the router, and starts services. 2. **Router** (`router/`) receives DDP datagrams from all ports, maintains the `RoutingTable` and `ZoneInformationTable`, and dispatches to services by socket number or forwards to other ports. 3. **Ports** (`port/`) abstract network interfaces. All implement `port.Port` (Unicast/Broadcast/Multicast). Implementations: `ethertalk`, `localtalk/ltoudp`, `localtalk/tashtalk`, `localtalk/virtual`. 4. **Services** (`service/`) plug into the router by registering socket numbers. Each implements `service.Service`. diff --git a/Makefile b/Makefile index 10ce0be..1836b16 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ TAGS ?= all .PHONY: build test test-race test-tags lint vuln gosec fuzz clean build: - go build -tags "$(TAGS)" -o omnitalk ./cmd/omnitalk + go build -tags "$(TAGS)" -o classicstack ./cmd/classicstack test: go test -tags "$(TAGS)" ./... @@ -30,5 +30,5 @@ fuzz: done clean: - rm -f omnitalk omnitalk.exe + rm -f classicstack classicstack.exe rm -rf out dist diff --git a/README.md b/README.md index e250338..9859074 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@
-OmniTalk +ClassicStack -# OmniTalk +# ClassicStack -### OmniTalk is a all-in-one AppleTalk Phase 2 router, MacIP Router and AFP file server for bridging classic Apple networking into modern environments. 🍏💾 +### ClassicStack is a all-in-one AppleTalk Phase 2 router, MacIP Router and AFP file server for bridging classic Apple networking into modern environments. 🍏💾
@@ -30,23 +30,23 @@ core interfaces, logging/telemetry, and the AFP design — see ## Quick start - Copy server.toml.example to server.toml and edit values. -- Run OmniTalk with no flags to auto-load server.toml. +- Run ClassicStack with no flags to auto-load server.toml. - Or pass a config file explicitly with -config. Examples: ~~~bash -./omnitalk -config server.toml +./classicstack -config server.toml ~~~ ~~~powershell -.\omnitalk.exe -config server.toml +.\classicstack.exe -config server.toml ~~~ Config-loading rule: - -config cannot be combined with other flags. -- If no flags are supplied, OmniTalk auto-loads server.toml if present. +- If no flags are supplied, ClassicStack auto-loads server.toml if present. --- @@ -65,13 +65,13 @@ Requirements: Build from repository root: ~~~bash -go build ./cmd/omnitalk +go build ./cmd/classicstack ~~~ Build with explicit binary name: ~~~bash -go build -o omnitalk ./cmd/omnitalk +go build -o classicstack ./cmd/classicstack ~~~ Build with explicit semantic version metadata: @@ -79,7 +79,7 @@ Build with explicit semantic version metadata: ~~~bash go build -trimpath \ -ldflags "-X main.BuildVersion=1.2.3 -X main.BuildCommit=$(git rev-parse --short HEAD) -X main.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - -o omnitalk ./cmd/omnitalk + -o classicstack ./cmd/classicstack ~~~ Build using the shared local/CI scripts: @@ -97,7 +97,7 @@ bash scripts/ci/test.sh Print runtime/build version info: ~~~bash -./omnitalk -version +./classicstack -version ~~~ Run tests: @@ -114,8 +114,8 @@ go test ./... - GitHub Actions calls the same scripts under `scripts/ci/` that you can run locally. - Release assets are produced for Linux, macOS, and Windows. - Release packages include the repository `dist/` content. -- Windows release binaries include icon and file version metadata from `icons/omnitalk.ico`. -- macOS release bundles include app icon metadata from `icons/omnitalk.icns`. +- Windows release binaries include icon and file version metadata from `icons/classicstack.ico`. +- macOS release bundles include app icon metadata from `icons/classicstack.icns`. - Go build/test already ignores non-Go folders; additionally `scripts/ci/test.sh` and `scripts/ci/test.ps1` explicitly exclude `dist`, `icon`, and `icons` from the package list. ## Status and provenance @@ -140,7 +140,7 @@ Route AppleTalk between EtherTalk and LocalTalk ports, with RTMP/ZIP/NBP service Use the built-in pcap listing mode: ~~~powershell -.\omnitalk.exe -list-pcap-devices +.\classicstack.exe -list-pcap-devices ~~~ This prints available interface names and pcap device IDs. Use the device string in [EtherTalk] device, for example: @@ -248,14 +248,14 @@ Why `wifi` mode exists: - Many Wi-Fi adapters and AP paths reject or rewrite frames when the source MAC does not match the host adapter MAC. - On Windows, the miniport/NDIS path commonly drops transmit frames when source hardware address does not match the host adapter MAC. -- In `wifi` mode OmniTalk rewrites outbound EtherTalk frame source MAC to the host adapter MAC and updates AARP hardware fields accordingly. -- For inbound traffic, OmniTalk reverses destination rewrite using a short-lived peer-to-virtual mapping so the EtherTalk port still sees the expected virtual MAC identity. +- In `wifi` mode ClassicStack rewrites outbound EtherTalk frame source MAC to the host adapter MAC and updates AARP hardware fields accordingly. +- For inbound traffic, ClassicStack reverses destination rewrite using a short-lived peer-to-virtual mapping so the EtherTalk port still sees the expected virtual MAC identity. - This is effectively an L2 NAT-style shim for MAC identities (not MacIP IP-layer NAT). Recommended settings: - On Wi-Fi, set `bridge_mode=wifi` (or leave `auto` and verify it detected Wi-Fi correctly). -- Set `bridge_host_mac` to your actual Wi-Fi adapter MAC when needed; if blank, OmniTalk falls back to `hw_address`. +- Set `bridge_host_mac` to your actual Wi-Fi adapter MAC when needed; if blank, ClassicStack falls back to `hw_address`. - On wired Ethernet, prefer `bridge_mode=ethernet` or `auto`. ##### Wi-Fi troubleshooting @@ -264,7 +264,7 @@ Common symptoms: - You see AppleTalk traffic in one direction only. - AARP appears unanswered even when peers are present. -- OmniTalk works on wired Ethernet but fails on the same host over Wi-Fi. +- ClassicStack works on wired Ethernet but fails on the same host over Wi-Fi. Checks and fixes: @@ -315,7 +315,7 @@ lease_file = "leases.txt" ## AFP -OmniTalk includes an AFP file server focused on AFP 2.0-level behavior, with selective AFP 2.1/2.2 calls, exposed over both classic AppleTalk transport and modern TCP transport: +ClassicStack includes an AFP file server focused on AFP 2.0-level behavior, with selective AFP 2.1/2.2 calls, exposed over both classic AppleTalk transport and modern TCP transport: - DDP stack: DDP -> ATP -> ASP -> AFP - TCP stack: TCP -> DSI -> AFP @@ -466,8 +466,8 @@ Volume naming: #### Netatalk compatibility - Compatible formats: Netatalk-style extension map syntax and AppleDouble modern/legacy sidecar layouts. -- Known differences: CNID database implementation is OmniTalk-specific (sqlite or memory), not a drop-in Netatalk CNID store. -- OmniTalk does not currently provide a Netatalk-style extended-attribute metadata backend. +- Known differences: CNID database implementation is ClassicStack-specific (sqlite or memory), not a drop-in Netatalk CNID store. +- ClassicStack does not currently provide a Netatalk-style extended-attribute metadata backend. - AFP feature coverage is practical but incomplete (for example catalog search is currently implemented as name-based search and backend-dependent). ### [Logging] @@ -493,7 +493,7 @@ Use server.toml for repeatable deployments; use flags for quick experiments. ## Rough project layout -- cmd/omnitalk: entrypoint, flag handling, TOML config loading, runtime wiring. +- cmd/classicstack: entrypoint, flag handling, TOML config loading, runtime wiring. - router: datagram dispatch, routing table, zone information table. - port: transport implementations (EtherTalk, LocalTalk variants, rawlink, NAT helpers). - service: protocol/application services (AEP, RTMP, ZIP, ASP/ATP/DSI, AFP, MacIP, LLAP). diff --git a/cmd/omnitalk/afp_disabled.go b/cmd/classicstack/afp_disabled.go similarity index 89% rename from cmd/omnitalk/afp_disabled.go rename to cmd/classicstack/afp_disabled.go index cd74146..d61d95e 100644 --- a/cmd/omnitalk/afp_disabled.go +++ b/cmd/classicstack/afp_disabled.go @@ -3,8 +3,8 @@ package main import ( - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/service" ) type afpHookDisabled struct{} diff --git a/cmd/omnitalk/afp_enabled.go b/cmd/classicstack/afp_enabled.go similarity index 93% rename from cmd/omnitalk/afp_enabled.go rename to cmd/classicstack/afp_enabled.go index d4268a9..3d13902 100644 --- a/cmd/omnitalk/afp_enabled.go +++ b/cmd/classicstack/afp_enabled.go @@ -7,12 +7,12 @@ import ( "path/filepath" "strings" - "github.com/pgodw/omnitalk/config" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/service" - "github.com/pgodw/omnitalk/service/afp" - "github.com/pgodw/omnitalk/service/asp" - "github.com/pgodw/omnitalk/service/dsi" + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/service" + "github.com/ObsoleteMadness/ClassicStack/service/afp" + "github.com/ObsoleteMadness/ClassicStack/service/asp" + "github.com/ObsoleteMadness/ClassicStack/service/dsi" ) type afpHookEnabled struct { diff --git a/cmd/omnitalk/afp_hook.go b/cmd/classicstack/afp_hook.go similarity index 91% rename from cmd/omnitalk/afp_hook.go rename to cmd/classicstack/afp_hook.go index bbbc551..bea9a1c 100644 --- a/cmd/omnitalk/afp_hook.go +++ b/cmd/classicstack/afp_hook.go @@ -1,9 +1,9 @@ package main import ( - "github.com/pgodw/omnitalk/config" - "github.com/pgodw/omnitalk/service" - "github.com/pgodw/omnitalk/service/zip" + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/service" + "github.com/ObsoleteMadness/ClassicStack/service/zip" ) // AFPHook is the cmd-layer abstraction over the optional AFP file diff --git a/cmd/omnitalk/config_afp_test.go b/cmd/classicstack/config_afp_test.go similarity index 97% rename from cmd/omnitalk/config_afp_test.go rename to cmd/classicstack/config_afp_test.go index 081ec71..704721f 100644 --- a/cmd/omnitalk/config_afp_test.go +++ b/cmd/classicstack/config_afp_test.go @@ -7,8 +7,8 @@ import ( "path/filepath" "testing" - "github.com/pgodw/omnitalk/config" - "github.com/pgodw/omnitalk/service/afp" + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/service/afp" ) // loadAFPForTest is a small helper that mirrors what wireAFP does on @@ -32,7 +32,7 @@ func TestLoadAFPConfig_VolumesAndExtensionMap(t *testing.T) { cfgPath := filepath.Join(dir, "server.toml") content := `[AFP] enabled = true -name = "OmniTalk" +name = "ClassicStack" zone = "EtherTalk Network" protocols = "ddp,tcp" binding = ":548" diff --git a/cmd/omnitalk/config_flags.go b/cmd/classicstack/config_flags.go similarity index 95% rename from cmd/omnitalk/config_flags.go rename to cmd/classicstack/config_flags.go index 329faa3..113cac1 100644 --- a/cmd/omnitalk/config_flags.go +++ b/cmd/classicstack/config_flags.go @@ -1,8 +1,8 @@ package main import ( - "github.com/pgodw/omnitalk/port/ethertalk" - "github.com/pgodw/omnitalk/port/localtalk" + "github.com/ObsoleteMadness/ClassicStack/port/ethertalk" + "github.com/ObsoleteMadness/ClassicStack/port/localtalk" ) // flagInputs collects raw values from the CLI flags. main.go derefs each diff --git a/cmd/omnitalk/config_ini.go b/cmd/classicstack/config_ini.go similarity index 96% rename from cmd/omnitalk/config_ini.go rename to cmd/classicstack/config_ini.go index c6a6ca4..10f7afa 100644 --- a/cmd/omnitalk/config_ini.go +++ b/cmd/classicstack/config_ini.go @@ -6,9 +6,9 @@ import ( "github.com/knadh/koanf/v2" - "github.com/pgodw/omnitalk/config" - "github.com/pgodw/omnitalk/port/ethertalk" - "github.com/pgodw/omnitalk/port/localtalk" + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/port/ethertalk" + "github.com/ObsoleteMadness/ClassicStack/port/localtalk" ) // appConfig is the cmd-local view of resolved configuration. Each diff --git a/cmd/omnitalk/config_test.go b/cmd/classicstack/config_test.go similarity index 100% rename from cmd/omnitalk/config_test.go rename to cmd/classicstack/config_test.go diff --git a/cmd/omnitalk/doc.go b/cmd/classicstack/doc.go similarity index 87% rename from cmd/omnitalk/doc.go rename to cmd/classicstack/doc.go index 65847c1..9dcd51b 100644 --- a/cmd/omnitalk/doc.go +++ b/cmd/classicstack/doc.go @@ -1,5 +1,5 @@ /* -Command omnitalk is the AppleTalk Phase 2 router and AFP file server. +Command classicstack is the AppleTalk Phase 2 router and AFP file server. It wires ports (EtherTalk, LToUDP, TashTalk, virtual LocalTalk) to a router, registers the requested services (RTMP, ZIP, NBP, AEP, AFP over diff --git a/cmd/omnitalk/extension_map.go b/cmd/classicstack/extension_map.go similarity index 95% rename from cmd/omnitalk/extension_map.go rename to cmd/classicstack/extension_map.go index bded9ae..f04a7d4 100644 --- a/cmd/omnitalk/extension_map.go +++ b/cmd/classicstack/extension_map.go @@ -8,7 +8,7 @@ import ( "regexp" "strings" - "github.com/pgodw/omnitalk/service/afp" + "github.com/ObsoleteMadness/ClassicStack/service/afp" ) var extMapLinePattern = regexp.MustCompile(`^(\S+)\s+"([^"]*)"\s+"([^"]*)"`) diff --git a/cmd/omnitalk/extension_map_test.go b/cmd/classicstack/extension_map_test.go similarity index 100% rename from cmd/omnitalk/extension_map_test.go rename to cmd/classicstack/extension_map_test.go diff --git a/cmd/classicstack/macgarden_register.go b/cmd/classicstack/macgarden_register.go new file mode 100644 index 0000000..4a03def --- /dev/null +++ b/cmd/classicstack/macgarden_register.go @@ -0,0 +1,5 @@ +//go:build (afp && macgarden) || all + +package main + +import _ "github.com/ObsoleteMadness/ClassicStack/service/afpfs/macgarden" diff --git a/cmd/omnitalk/macip_disabled.go b/cmd/classicstack/macip_disabled.go similarity index 88% rename from cmd/omnitalk/macip_disabled.go rename to cmd/classicstack/macip_disabled.go index 29a11d6..81c75fc 100644 --- a/cmd/omnitalk/macip_disabled.go +++ b/cmd/classicstack/macip_disabled.go @@ -2,7 +2,7 @@ package main -import "github.com/pgodw/omnitalk/netlog" +import "github.com/ObsoleteMadness/ClassicStack/netlog" // wireMacIP is the no-op stub used when the binary is built without the // macip tag. It logs a warning if the operator asked for MacIP and exits diff --git a/cmd/omnitalk/macip_enabled.go b/cmd/classicstack/macip_enabled.go similarity index 94% rename from cmd/omnitalk/macip_enabled.go rename to cmd/classicstack/macip_enabled.go index c253295..d948ef0 100644 --- a/cmd/omnitalk/macip_enabled.go +++ b/cmd/classicstack/macip_enabled.go @@ -7,11 +7,11 @@ import ( "net" "strings" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/hwaddr" - "github.com/pgodw/omnitalk/port/rawlink" - "github.com/pgodw/omnitalk/service" - "github.com/pgodw/omnitalk/service/macip" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" + "github.com/ObsoleteMadness/ClassicStack/service" + "github.com/ObsoleteMadness/ClassicStack/service/macip" ) type macipHook struct { diff --git a/cmd/omnitalk/macip_hook.go b/cmd/classicstack/macip_hook.go similarity index 93% rename from cmd/omnitalk/macip_hook.go rename to cmd/classicstack/macip_hook.go index f1c2144..adcbbe9 100644 --- a/cmd/omnitalk/macip_hook.go +++ b/cmd/classicstack/macip_hook.go @@ -1,8 +1,8 @@ package main import ( - "github.com/pgodw/omnitalk/service" - "github.com/pgodw/omnitalk/service/zip" + "github.com/ObsoleteMadness/ClassicStack/service" + "github.com/ObsoleteMadness/ClassicStack/service/zip" ) // MacIPHook is the cmd-layer abstraction over the optional MacIP gateway. diff --git a/cmd/omnitalk/macip_test.go b/cmd/classicstack/macip_test.go similarity index 100% rename from cmd/omnitalk/macip_test.go rename to cmd/classicstack/macip_test.go diff --git a/cmd/omnitalk/main.go b/cmd/classicstack/main.go similarity index 94% rename from cmd/omnitalk/main.go rename to cmd/classicstack/main.go index cdc80f6..3fa59cb 100644 --- a/cmd/omnitalk/main.go +++ b/cmd/classicstack/main.go @@ -12,27 +12,27 @@ import ( "strings" "syscall" - "github.com/pgodw/omnitalk/config" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/hwaddr" - "github.com/pgodw/omnitalk/pkg/logging" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/port/ethertalk" - "github.com/pgodw/omnitalk/port/localtalk" - "github.com/pgodw/omnitalk/port/rawlink" - "github.com/pgodw/omnitalk/router" - "github.com/pgodw/omnitalk/service" - "github.com/pgodw/omnitalk/service/aep" - "github.com/pgodw/omnitalk/service/llap" - "github.com/pgodw/omnitalk/service/rtmp" - "github.com/pgodw/omnitalk/service/zip" + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" + "github.com/ObsoleteMadness/ClassicStack/pkg/logging" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/port/ethertalk" + "github.com/ObsoleteMadness/ClassicStack/port/localtalk" + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" + "github.com/ObsoleteMadness/ClassicStack/router" + "github.com/ObsoleteMadness/ClassicStack/service" + "github.com/ObsoleteMadness/ClassicStack/service/aep" + "github.com/ObsoleteMadness/ClassicStack/service/llap" + "github.com/ObsoleteMadness/ClassicStack/service/rtmp" + "github.com/ObsoleteMadness/ClassicStack/service/zip" ) func main() { log.SetFlags(log.LstdFlags | log.Lmicroseconds) configPath := flag.String("config", "", "Path to TOML config file (cannot be combined with other flags)") - showVersion := flag.Bool("version", false, "Print OmniTalk version information and exit") + showVersion := flag.Bool("version", false, "Print ClassicStack version information and exit") logLevel := flag.String("log-level", "info", "Minimum log level: debug, info, warn") logTraffic := flag.Bool("log-traffic", false, "Log network traffic at debug level (requires -log-level debug)") @@ -90,7 +90,7 @@ func main() { flag.Parse() if *showVersion { - fmt.Printf("omnitalk %s\n", BuildVersion) + fmt.Printf("classicstack %s\n", BuildVersion) fmt.Printf("commit: %s\n", BuildCommit) fmt.Printf("built: %s\n", BuildDate) fmt.Printf("go: %s\n", runtime.Version()) @@ -178,7 +178,7 @@ func main() { // attributes. Each service will eventually take a *slog.Logger // directly; until then, netlog.* calls forward here. slogLevel, _ := logging.ParseLevel(cfg.LogLevel) - rootLogger := logging.New("OmniTalk", logging.Options{ + rootLogger := logging.New("ClassicStack", logging.Options{ Sinks: []logging.Sink{{Writer: os.Stderr, Format: logging.FormatConsole, Level: slogLevel}}, }) logging.SetDefault(rootLogger) diff --git a/cmd/omnitalk/main_macip_test.go b/cmd/classicstack/main_macip_test.go similarity index 100% rename from cmd/omnitalk/main_macip_test.go rename to cmd/classicstack/main_macip_test.go diff --git a/cmd/omnitalk/packetdump.go b/cmd/classicstack/packetdump.go similarity index 95% rename from cmd/omnitalk/packetdump.go rename to cmd/classicstack/packetdump.go index df1e1b5..8f26be4 100644 --- a/cmd/omnitalk/packetdump.go +++ b/cmd/classicstack/packetdump.go @@ -6,7 +6,7 @@ import ( "log" "os" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/service" ) // PacketDumper is a generic sink used by services to emit parsed packet logs. diff --git a/cmd/omnitalk/version.go b/cmd/classicstack/version.go similarity index 100% rename from cmd/omnitalk/version.go rename to cmd/classicstack/version.go diff --git a/cmd/omnitalk/macgarden_register.go b/cmd/omnitalk/macgarden_register.go deleted file mode 100644 index 061765b..0000000 --- a/cmd/omnitalk/macgarden_register.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build (afp && macgarden) || all - -package main - -import _ "github.com/pgodw/omnitalk/service/afpfs/macgarden" diff --git a/config/config.go b/config/config.go index 9f75994..abe607a 100644 --- a/config/config.go +++ b/config/config.go @@ -1,10 +1,10 @@ -// Package config abstracts where OmniTalk's configuration comes from +// Package config abstracts where ClassicStack's configuration comes from // (TOML file today; environment variables, JSON, etc. tomorrow). It owns // no schema knowledge: each component decides what keys it consumes by // reading from the returned koanf instance. // // Defaults live with the consumers (typically as flag defaults in -// cmd/omnitalk). The config package's only job is to surface a populated +// cmd/classicstack). The config package's only job is to surface a populated // koanf source to those consumers. package config diff --git a/config/loadtoml_test.go b/config/loadtoml_test.go index ffce6e8..f2f12e0 100644 --- a/config/loadtoml_test.go +++ b/config/loadtoml_test.go @@ -10,8 +10,8 @@ func TestLoad_ExampleFile(t *testing.T) { if err != nil { t.Fatalf("Load(server.toml.example): %v", err) } - if got := src.K.String("AFP.name"); got != "OmniTalk" { - t.Fatalf("AFP.name = %q, want %q", got, "OmniTalk") + if got := src.K.String("AFP.name"); got != "ClassicStack" { + t.Fatalf("AFP.name = %q, want %q", got, "ClassicStack") } if vols := src.K.MapKeys("AFP.Volumes"); len(vols) != 2 { t.Fatalf("AFP.Volumes = %d, want 2", len(vols)) diff --git a/dist/Shared/welcome.txt b/dist/Shared/welcome.txt index 7c04d69..a9e87f3 100644 --- a/dist/Shared/welcome.txt +++ b/dist/Shared/welcome.txt @@ -1,3 +1,3 @@ -Welcome to OmniTalk. +Welcome to ClassicStack. This volume is configured to be read/write. \ No newline at end of file diff --git a/dist/server.toml b/dist/server.toml index b221dc0..b5b2340 100644 --- a/dist/server.toml +++ b/dist/server.toml @@ -35,7 +35,7 @@ nameserver = "1.1.1.1" [AFP] enabled = true -name = "OmniTalk" +name = "ClassicStack" zone = "EtherTalk Network" protocols = "ddp,tcp" binding = ":548" diff --git a/go.mod b/go.mod index 5c63d15..31b40ba 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/pgodw/omnitalk +module github.com/ObsoleteMadness/ClassicStack go 1.23.0 diff --git a/internal/testutil/mock_port.go b/internal/testutil/mock_port.go index dba2b4b..dca47f3 100644 --- a/internal/testutil/mock_port.go +++ b/internal/testutil/mock_port.go @@ -1,11 +1,11 @@ -// Package testutil provides shared test helpers used across OmniTalk's +// Package testutil provides shared test helpers used across ClassicStack's // service and port packages. Live under internal/ so external consumers // cannot depend on these mocks; only project tests may import. package testutil import ( - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" ) // MockPort is a fake port.Port whose behaviour is driven by func fields. diff --git a/internal/testutil/mock_router.go b/internal/testutil/mock_router.go index dac9925..e59f579 100644 --- a/internal/testutil/mock_router.go +++ b/internal/testutil/mock_router.go @@ -1,9 +1,9 @@ package testutil import ( - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/protocol/ddp" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/service" ) // MockRouter is a fake service.Router whose behaviour is driven by func diff --git a/netlog/netlog.go b/netlog/netlog.go index a8b7261..7a41537 100644 --- a/netlog/netlog.go +++ b/netlog/netlog.go @@ -1,6 +1,6 @@ -// Package netlog is OmniTalk's logging API. +// Package netlog is ClassicStack's logging API. // -// It is a thin facade over log/slog: cmd/omnitalk constructs a structured +// It is a thin facade over log/slog: cmd/classicstack constructs a structured // logger via pkg/logging and installs it here with SetLogger, then every // service calls Debug/Info/Warn from this package. The facade keeps call // sites short (no per-package logger plumbing) while still letting the @@ -20,7 +20,7 @@ import ( "strings" "sync" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" ) // Level mirrors the legacy three-value enum but maps onto slog.Level. diff --git a/omnitalk b/omnitalk index 2859c97..296801c 100644 Binary files a/omnitalk and b/omnitalk differ diff --git a/pkg/hwaddr/hwaddr.go b/pkg/hwaddr/hwaddr.go index ce844c6..f608e97 100644 --- a/pkg/hwaddr/hwaddr.go +++ b/pkg/hwaddr/hwaddr.go @@ -1,7 +1,7 @@ // Package hwaddr provides unified hardware-address types covering Ethernet // (EUI-48), LocalTalk (8-bit LLAP node ID), and AppleTalk (24-bit DDP // address), plus parsing, formatting, generation, and conversion between -// them. It replaces ad-hoc helpers previously scattered across cmd/omnitalk, +// them. It replaces ad-hoc helpers previously scattered across cmd/classicstack, // port/ethertalk, port/localtalk, and service/macip. package hwaddr @@ -31,7 +31,7 @@ type AppleTalk struct { // synthesising Ethernet addresses from AppleTalk addresses. var AppleOUI = [3]byte{0x00, 0x00, 0x07} -// MacIPOUI is the locally administered prefix historically used by OmniTalk's +// MacIPOUI is the locally administered prefix historically used by ClassicStack's // MacIP gateway to fabricate per-node MACs for DHCP. Bit 1 of the first octet // is set, marking the address as locally administered. var MacIPOUI = [3]byte{0x02, 0x00, 0x00} @@ -188,7 +188,7 @@ func AppleTalkFromEthernet(oui [3]byte, e Ethernet) (AppleTalk, bool) { // Layout: 0x02 (locally administered) | netHi | netLo | node | 'M' | 'I'. // The suffix "MI" distinguishes these addresses from generic AARP-style // syntheses and preserves wire-level compatibility with existing DHCP -// leases issued against OmniTalk MacIP. +// leases issued against ClassicStack MacIP. func MacIPEthernetFromAppleTalk(a AppleTalk) Ethernet { return Ethernet{0x02, byte(a.Network >> 8), byte(a.Network), a.Node, 'M', 'I'} } diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 260e198..e06edfe 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -1,4 +1,4 @@ -// Package telemetry is OmniTalk's metrics abstraction. It exposes +// Package telemetry is ClassicStack's metrics abstraction. It exposes // Counter, Gauge, and Histogram types with a default expvar-backed // implementation that ships as part of the stdlib and requires no // extra dependencies. A build-tagged OpenTelemetry backend may be @@ -10,13 +10,13 @@ // // Usage: // -// var framesIn = telemetry.NewCounter("omnitalk_router_frames_in_total") +// var framesIn = telemetry.NewCounter("classicstack_router_frames_in_total") // framesIn.Inc() // framesIn.Add(n) // // Metric names follow Prometheus-style lower_snake_case with a unit // suffix (_total, _seconds, _bytes). Labels are encoded into the name -// for the expvar backend (e.g. "omnitalk_afp_commands_total_OpenFork") +// for the expvar backend (e.g. "classicstack_afp_commands_total_OpenFork") // because expvar does not support label dimensions natively; the OTel // backend splits them back out. package telemetry @@ -88,17 +88,17 @@ func NewHistogram(name string) Histogram { type expvarCounter struct{ n atomic.Int64 } -func (c *expvarCounter) Inc() { c.n.Add(1) } -func (c *expvarCounter) Add(d int64) { c.n.Add(d) } -func (c *expvarCounter) Value() int64 { return c.n.Load() } -func (c *expvarCounter) String() string { return i64string(c.n.Load()) } +func (c *expvarCounter) Inc() { c.n.Add(1) } +func (c *expvarCounter) Add(d int64) { c.n.Add(d) } +func (c *expvarCounter) Value() int64 { return c.n.Load() } +func (c *expvarCounter) String() string { return i64string(c.n.Load()) } type expvarGauge struct{ n atomic.Int64 } -func (g *expvarGauge) Set(v int64) { g.n.Store(v) } -func (g *expvarGauge) Add(d int64) { g.n.Add(d) } -func (g *expvarGauge) Value() int64 { return g.n.Load() } -func (g *expvarGauge) String() string { return i64string(g.n.Load()) } +func (g *expvarGauge) Set(v int64) { g.n.Store(v) } +func (g *expvarGauge) Add(d int64) { g.n.Add(d) } +func (g *expvarGauge) Value() int64 { return g.n.Load() } +func (g *expvarGauge) String() string { return i64string(g.n.Load()) } type expvarHistogram struct { count atomic.Int64 diff --git a/port/ethertalk/doc.go b/port/ethertalk/doc.go index bf95b21..fbcdac5 100644 --- a/port/ethertalk/doc.go +++ b/port/ethertalk/doc.go @@ -1,5 +1,5 @@ // Package ethertalk implements EtherTalk (AppleTalk Phase 2 over -// Ethernet) as an OmniTalk port. +// Ethernet) as an ClassicStack port. // // Frames are sent and received via libpcap/Npcap on the host // interface. The package also implements AARP (RFC 1742, Appendix A) diff --git a/port/ethertalk/ethertalk.go b/port/ethertalk/ethertalk.go index 839150f..c0dc236 100644 --- a/port/ethertalk/ethertalk.go +++ b/port/ethertalk/ethertalk.go @@ -7,10 +7,10 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" ) var ( diff --git a/port/ethertalk/ethertalk_bridge.go b/port/ethertalk/ethertalk_bridge.go index ede28fb..3091a17 100644 --- a/port/ethertalk/ethertalk_bridge.go +++ b/port/ethertalk/ethertalk_bridge.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/port/rawlink" + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" ) type bridgeMode uint8 diff --git a/port/ethertalk/ethertalk_bridge_test.go b/port/ethertalk/ethertalk_bridge_test.go index f3440af..05c5543 100644 --- a/port/ethertalk/ethertalk_bridge_test.go +++ b/port/ethertalk/ethertalk_bridge_test.go @@ -5,7 +5,7 @@ import ( "encoding/binary" "testing" - "github.com/pgodw/omnitalk/port/rawlink" + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" ) func TestBridgeAdapterInboundPassThroughCopy(t *testing.T) { diff --git a/port/ethertalk/metrics.go b/port/ethertalk/metrics.go index c4c72a9..acfded1 100644 --- a/port/ethertalk/metrics.go +++ b/port/ethertalk/metrics.go @@ -1,5 +1,5 @@ package ethertalk -import "github.com/pgodw/omnitalk/pkg/telemetry" +import "github.com/ObsoleteMadness/ClassicStack/pkg/telemetry" -var aarpProbeRetriesTotal = telemetry.NewCounter("omnitalk_aarp_probe_retries_total") +var aarpProbeRetriesTotal = telemetry.NewCounter("classicstack_aarp_probe_retries_total") diff --git a/port/ethertalk/pcap.go b/port/ethertalk/pcap.go index b257dcd..63ca7ed 100644 --- a/port/ethertalk/pcap.go +++ b/port/ethertalk/pcap.go @@ -3,9 +3,9 @@ package ethertalk import ( "net" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/port/rawlink" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" ) // etherTalkBPFFilter selects EtherTalk Phase 2 frames carried as diff --git a/port/ethertalk/tap.go b/port/ethertalk/tap.go index 5e2a3e0..79330a5 100644 --- a/port/ethertalk/tap.go +++ b/port/ethertalk/tap.go @@ -1,6 +1,6 @@ package ethertalk -import "github.com/pgodw/omnitalk/port/rawlink" +import "github.com/ObsoleteMadness/ClassicStack/port/rawlink" // NewTapPort creates an EtherTalk port over a TAP-style raw link backend. // TAP support depends on rawlink.OpenTAP for the current platform. diff --git a/port/localtalk/doc.go b/port/localtalk/doc.go index 393d32e..8d0a3b6 100644 --- a/port/localtalk/doc.go +++ b/port/localtalk/doc.go @@ -1,5 +1,5 @@ // Package localtalk implements LocalTalk (AppleTalk Phase 1) as an -// OmniTalk port. +// ClassicStack port. // // LLAP frames travel over one of several physical/virtual transports // implemented in subpackages: LToUDP (UDP multicast on diff --git a/port/localtalk/llap.go b/port/localtalk/llap.go index bb47335..5e2eb42 100644 --- a/port/localtalk/llap.go +++ b/port/localtalk/llap.go @@ -1,6 +1,6 @@ package localtalk -import "github.com/pgodw/omnitalk/protocol/llap" +import "github.com/ObsoleteMadness/ClassicStack/protocol/llap" // LLAP wire-format types and codes have moved to protocol/llap. // These aliases keep existing port-internal call sites unchanged while diff --git a/port/localtalk/localtalk.go b/port/localtalk/localtalk.go index a9a6341..acc5a3f 100644 --- a/port/localtalk/localtalk.go +++ b/port/localtalk/localtalk.go @@ -6,10 +6,10 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" ) const ( @@ -86,9 +86,9 @@ func (p *Port) ConfigureSendFrame(f func(frame []byte) error) { p.sendFrameFunc // callers that already pass closures. func (p *Port) SetFrameSender(fs FrameSender) { p.sendFrameFunc = fs.SendFrame } -func (p *Port) ShortString() string { return "LocalTalk" } -func (p *Port) SetLLAPLinkManager(m LinkManager) { p.linkManager = m } -func (p *Port) SetNodeIDChangeHook(hook func(node uint8)) { p.onNodeIDChange = hook } +func (p *Port) ShortString() string { return "LocalTalk" } +func (p *Port) SetLLAPLinkManager(m LinkManager) { p.linkManager = m } +func (p *Port) SetNodeIDChangeHook(hook func(node uint8)) { p.onNodeIDChange = hook } func (p *Port) SetCTSResponseTimeout(timeout time.Duration) { p.mu.Lock() diff --git a/port/localtalk/ltoudp.go b/port/localtalk/ltoudp.go index b64bad9..841e9e5 100644 --- a/port/localtalk/ltoudp.go +++ b/port/localtalk/ltoudp.go @@ -12,8 +12,8 @@ import ( "golang.org/x/net/ipv4" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" ) const ( diff --git a/port/localtalk/tashtalk.go b/port/localtalk/tashtalk.go index 7ff1216..05c4680 100644 --- a/port/localtalk/tashtalk.go +++ b/port/localtalk/tashtalk.go @@ -9,10 +9,10 @@ import ( "sync" "time" + "github.com/ObsoleteMadness/ClassicStack/netlog" serial "github.com/jacobsa/go-serial/serial" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" + "github.com/ObsoleteMadness/ClassicStack/port" ) type TashTalkPort struct { diff --git a/port/nat/ipnat.go b/port/nat/ipnat.go index 387f51f..626b5b5 100644 --- a/port/nat/ipnat.go +++ b/port/nat/ipnat.go @@ -13,10 +13,10 @@ import ( "golang.org/x/net/icmp" "golang.org/x/net/ipv4" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/service" ) const ( diff --git a/port/port.go b/port/port.go index 0f90e9d..6956023 100644 --- a/port/port.go +++ b/port/port.go @@ -1,6 +1,6 @@ package port -import "github.com/pgodw/omnitalk/protocol/ddp" +import "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" type RouterHooks interface { Inbound(datagram ddp.Datagram, rx Port) diff --git a/protocol/asp/asp.go b/protocol/asp/asp.go index 91fbac8..4bd3791 100644 --- a/protocol/asp/asp.go +++ b/protocol/asp/asp.go @@ -19,7 +19,7 @@ package asp import ( "time" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) // --------------------------------------------------------------------------- diff --git a/protocol/atp/atp.go b/protocol/atp/atp.go index b299d87..3607e37 100644 --- a/protocol/atp/atp.go +++ b/protocol/atp/atp.go @@ -14,8 +14,8 @@ import ( "fmt" "time" - "github.com/pgodw/omnitalk/pkg/binutil" - "github.com/pgodw/omnitalk/protocol" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/protocol" ) // ATP Control bit masks. diff --git a/protocol/nbp/nbp.go b/protocol/nbp/nbp.go index 49f2178..d1ae3e5 100644 --- a/protocol/nbp/nbp.go +++ b/protocol/nbp/nbp.go @@ -38,7 +38,7 @@ var ErrMalformed = errors.New("nbp: malformed packet") // Tuple is a single NBP tuple: an address (network/node/socket), an // enumerator, and an entity name (object:type@zone). Inbound packets -// carry exactly one tuple in OmniTalk's NBP handler; LkUp-Rply may +// carry exactly one tuple in ClassicStack's NBP handler; LkUp-Rply may // pack several but the registered service emits one per match. type Tuple struct { Network uint16 diff --git a/protocol/protocol.go b/protocol/protocol.go index 8acaf73..5498c8a 100644 --- a/protocol/protocol.go +++ b/protocol/protocol.go @@ -1,4 +1,4 @@ -// Package protocol defines cross-protocol contracts used by OmniTalk's wire +// Package protocol defines cross-protocol contracts used by ClassicStack's wire // implementations (DDP, ATP, ASP, ZIP, RTMP, AEP, LLAP, NBP). Each protocol // lives in its own subpackage; this package carries only interfaces common to // all of them. diff --git a/router/doc.go b/router/doc.go index b1e15f0..b4ea220 100644 --- a/router/doc.go +++ b/router/doc.go @@ -1,4 +1,4 @@ -// Package router implements the OmniTalk AppleTalk Phase 2 router core. +// Package router implements the ClassicStack AppleTalk Phase 2 router core. // // The router maintains the routing table (RTMP) and zone information // table (ZIP), receives DDP datagrams from every registered Port, and diff --git a/router/router.go b/router/router.go index bf6da8e..28c3999 100644 --- a/router/router.go +++ b/router/router.go @@ -4,20 +4,20 @@ import ( "context" "errors" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/telemetry" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/port/localtalk" - "github.com/pgodw/omnitalk/service" - "github.com/pgodw/omnitalk/service/aep" - "github.com/pgodw/omnitalk/service/llap" - "github.com/pgodw/omnitalk/service/rtmp" - "github.com/pgodw/omnitalk/service/zip" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/telemetry" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/port/localtalk" + "github.com/ObsoleteMadness/ClassicStack/service" + "github.com/ObsoleteMadness/ClassicStack/service/aep" + "github.com/ObsoleteMadness/ClassicStack/service/llap" + "github.com/ObsoleteMadness/ClassicStack/service/rtmp" + "github.com/ObsoleteMadness/ClassicStack/service/zip" ) -var framesInTotal = telemetry.NewCounter("omnitalk_router_frames_in_total") +var framesInTotal = telemetry.NewCounter("classicstack_router_frames_in_total") type Router struct { shortStr string diff --git a/router/routing_table.go b/router/routing_table.go index 1e30e1e..5126b1b 100644 --- a/router/routing_table.go +++ b/router/routing_table.go @@ -4,8 +4,8 @@ import ( "fmt" "sync" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" ) type RoutingTableEntry struct { diff --git a/router/zone_information_table.go b/router/zone_information_table.go index 5289105..12d30e5 100644 --- a/router/zone_information_table.go +++ b/router/zone_information_table.go @@ -5,7 +5,7 @@ import ( "fmt" "sync" - "github.com/pgodw/omnitalk/pkg/encoding" + "github.com/ObsoleteMadness/ClassicStack/pkg/encoding" ) func UCase(input []byte) []byte { diff --git a/scripts/ci/build.ps1 b/scripts/ci/build.ps1 index fc58ce6..d159716 100644 --- a/scripts/ci/build.ps1 +++ b/scripts/ci/build.ps1 @@ -14,9 +14,9 @@ switch ($buildVariant) { if ($env:OUTPUT) { $output = $env:OUTPUT } elseif ($buildVariant -eq 'all') { - $output = 'out/omnitalk.exe' + $output = 'out/classicstack.exe' } else { - $output = "out/omnitalk-$buildVariant.exe" + $output = "out/classicstack-$buildVariant.exe" } $versionForRc = '0.0.0.0' @@ -35,14 +35,14 @@ $descriptionSuffix = if ($buildVariant -eq 'all') { '' } else { " ($buildVariant @" { "StringFileInfo": { - "Comments": "OmniTalk", + "Comments": "ClassicStack", "CompanyName": "ObsoleteMadness", - "FileDescription": "OmniTalk AppleTalk Router$descriptionSuffix", + "FileDescription": "ClassicStack AppleTalk Router$descriptionSuffix", "FileVersion": "$buildVersion", - "InternalName": "omnitalk", + "InternalName": "classicstack", "LegalCopyright": "GPL-3.0", "OriginalFilename": "$exeName", - "ProductName": "OmniTalk", + "ProductName": "ClassicStack", "ProductVersion": "$buildVersion" }, "FixedFileInfo": { @@ -64,14 +64,14 @@ $descriptionSuffix = if ($buildVariant -eq 'all') { '' } else { " ($buildVariant "FileType": "01", "FileSubType": "00" }, - "IconPath": "../../icons/omnitalk.ico" + "IconPath": "../../icons/classicstack.ico" } -"@ | Set-Content -Path cmd/omnitalk/versioninfo.json -NoNewline +"@ | Set-Content -Path cmd/classicstack/versioninfo.json -NoNewline if (-not (Get-Command goversioninfo -ErrorAction SilentlyContinue)) { go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest } -Push-Location cmd/omnitalk +Push-Location cmd/classicstack goversioninfo -64 Pop-Location @@ -83,7 +83,7 @@ if ($parent) { $ldflags = "-s -w -X main.BuildVersion=$buildVersion -X main.BuildCommit=$buildCommit -X main.BuildDate=$buildDate" if ($tags) { - go build -trimpath -tags $tags -ldflags $ldflags -o $output ./cmd/omnitalk + go build -trimpath -tags $tags -ldflags $ldflags -o $output ./cmd/classicstack } else { - go build -trimpath -ldflags $ldflags -o $output ./cmd/omnitalk + go build -trimpath -ldflags $ldflags -o $output ./cmd/classicstack } diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh index 5b60ad3..9db9af1 100644 --- a/scripts/ci/build.sh +++ b/scripts/ci/build.sh @@ -18,9 +18,9 @@ esac if [[ -n "${OUTPUT:-}" ]]; then output="$OUTPUT" elif [[ "$build_variant" == "all" ]]; then - output="out/omnitalk" + output="out/classicstack" else - output="out/omnitalk-${build_variant}" + output="out/classicstack-${build_variant}" fi mkdir -p "$(dirname "$output")" @@ -28,7 +28,7 @@ mkdir -p "$(dirname "$output")" ldflags="-s -w -X main.BuildVersion=${build_version} -X main.BuildCommit=${build_commit} -X main.BuildDate=${build_date}" if [[ -n "$tags" ]]; then - go build -trimpath -tags "$tags" -ldflags "$ldflags" -o "$output" ./cmd/omnitalk + go build -trimpath -tags "$tags" -ldflags "$ldflags" -o "$output" ./cmd/classicstack else - go build -trimpath -ldflags "$ldflags" -o "$output" ./cmd/omnitalk + go build -trimpath -ldflags "$ldflags" -o "$output" ./cmd/classicstack fi diff --git a/scripts/ci/package-release.ps1 b/scripts/ci/package-release.ps1 index b15190b..b56435b 100644 --- a/scripts/ci/package-release.ps1 +++ b/scripts/ci/package-release.ps1 @@ -5,14 +5,14 @@ $buildVariant = if ($env:BUILD_VARIANT) { $env:BUILD_VARIANT } else { 'all' } if ($buildVariant -eq 'all') { $variantSlug = '' - $exeName = 'omnitalk.exe' + $exeName = 'classicstack.exe' } else { $variantSlug = "-$buildVariant" - $exeName = "omnitalk-$buildVariant.exe" + $exeName = "classicstack-$buildVariant.exe" } -$stage = "release/omnitalk$variantSlug-$releaseTag-windows-amd64" -$archiveName = "omnitalk$variantSlug-$releaseTag-windows-amd64.zip" +$stage = "release/classicstack$variantSlug-$releaseTag-windows-amd64" +$archiveName = "classicstack$variantSlug-$releaseTag-windows-amd64.zip" New-Item -ItemType Directory -Path $stage -Force | Out-Null Copy-Item "out/$exeName" "$stage/$exeName" diff --git a/scripts/ci/package-release.sh b/scripts/ci/package-release.sh index b7be877..d0f4182 100644 --- a/scripts/ci/package-release.sh +++ b/scripts/ci/package-release.sh @@ -13,15 +13,15 @@ fi if [[ "$build_variant" == "all" ]]; then variant_slug="" - exe_name="omnitalk" + exe_name="classicstack" else variant_slug="-${build_variant}" - exe_name="omnitalk-${build_variant}" + exe_name="classicstack-${build_variant}" fi if [[ "$target_os" == "linux" ]]; then - stage="release/omnitalk${variant_slug}-${release_tag}-linux-amd64" - archive_name="omnitalk${variant_slug}-${release_tag}-linux-amd64.tar.gz" + stage="release/classicstack${variant_slug}-${release_tag}-linux-amd64" + archive_name="classicstack${variant_slug}-${release_tag}-linux-amd64.tar.gz" mkdir -p "$stage" cp "out/${exe_name}" "$stage/${exe_name}" @@ -33,26 +33,26 @@ if [[ "$target_os" == "linux" ]]; then fi if [[ "$target_os" == "macos" ]]; then - stage="release/omnitalk${variant_slug}-${release_tag}-macos-amd64" - archive_name="omnitalk${variant_slug}-${release_tag}-macos-amd64.zip" + stage="release/classicstack${variant_slug}-${release_tag}-macos-amd64" + archive_name="classicstack${variant_slug}-${release_tag}-macos-amd64.zip" if [[ "$build_variant" == "all" ]]; then - bundle_name="OmniTalk.app" + bundle_name="ClassicStack.app" else - bundle_name="OmniTalk-${build_variant}.app" + bundle_name="ClassicStack-${build_variant}.app" fi app_root="$stage/${bundle_name}/Contents" mkdir -p "$app_root/MacOS" "$app_root/Resources" - cp "out/${exe_name}" "$app_root/MacOS/omnitalk" - chmod +x "$app_root/MacOS/omnitalk" - cp icons/omnitalk.icns "$app_root/Resources/omnitalk.icns" + cp "out/${exe_name}" "$app_root/MacOS/classicstack" + chmod +x "$app_root/MacOS/classicstack" + cp icons/classicstack.icns "$app_root/Resources/classicstack.icns" if [[ "$build_variant" == "all" ]]; then - display_name="OmniTalk" - bundle_id="com.obsoletemadness.omnitalk" + display_name="ClassicStack" + bundle_id="com.obsoletemadness.classicstack" else - display_name="OmniTalk (${build_variant})" - bundle_id="com.obsoletemadness.omnitalk.${build_variant}" + display_name="ClassicStack (${build_variant})" + bundle_id="com.obsoletemadness.classicstack.${build_variant}" fi cat > "$app_root/Info.plist" < CFBundleDisplayName${display_name} - CFBundleExecutableomnitalk - CFBundleIconFileomnitalk.icns + CFBundleExecutableclassicstack + CFBundleIconFileclassicstack.icns CFBundleIdentifier${bundle_id} CFBundleName${display_name} CFBundlePackageTypeAPPL diff --git a/server.ini b/server.ini new file mode 100644 index 0000000..f9f18ca --- /dev/null +++ b/server.ini @@ -0,0 +1,84 @@ +[LToUdp] +; LocalTalk over UDP Settings (used by Mini vMac UDP builds and SNOW emu) +enabled = true ; Enable LToUDP - true for on, false for off +seed_network = 1 ; LToUDO seed network number +seed_zone = "LToUDP Network" ; LToUDP seed zone name. + +[TashTalk] +; TashTalk is a PIC-based RS482 localtalk to serial adaptor +port = COM6 ; blank to disable, otherwise the serial port to use (eg COM1, /dev/ttyAMA0) +seed_network = 2 ; TashTalk seed network number +seed_zone = "TashTalk Network" ; TashTalk seed zone name + +[EtherTalk] +; Ethertalk is a pcap based Network Bridge +backend = pcap ; supported: pcap, tap, tun. Leave blank to disable ethertalk. +device = "\Device\NPF_{B7D4E073-2185-4912-BBE8-3948C6636D02}" ; PCap device name. Blank to disable ethertalk. Call with -list-pcap-devices to see what to use. Linux /dev/eth0. Windows: "\Device\NPF_{B7D4E073-2185-4912-BBE8-3948C6636D02}". +;device = "\Device\NPF_{7A63BBB0-EBC1-4FA7-A397-8E7F42E39A73}" ; PCap device name. Blank to disable ethertalk. Call with -list-pcap-devices to see what to use. Linux /dev/eth0. Windows: "\Device\NPF_{B7D4E073-2185-4912-BBE8-3948C6636D02}". +hw_address = "DE:AD:BE:EF:CA:FE" ; EtherTalk Hardware Address to use for router. +seed_network_min = 3 ; EtherTalk seed network number +seed_network_max = 5 ; EtherTalk seed network +seed_zone = "EtherTalk Network" ; EtherTalk seed zone name +bridge_mode = auto ; auto (default), ethernet, or wifi. Use wifi for bridge-shim rewriting on Wi-Fi adapters. +bridge_host_mac = ; optional host adapter MAC for Wi-Fi bridge shim. Defaults to hw_address when blank. + + +[MacIP] +; MacIP Gateway Settings. Allows TCP over DDP. +enabled = true ; true to enable MacIP Gateway, false to disable +mode = pcap ; modes are pcap or nat. +zone = ; MacIP Gateway Zone, defaults to EtherTalk zone, otherwise the first zone detected. +nat_subnet = ; in NAT mode, the subnet to use (eg 192.168.100.0/24) +nat_gw = ; in NAT mode, the IP Address to use for the gateway (eg 192.168.100.1) +lease_file = leases.txt ; in NAT mode, persist DHCP leases to the specified file +ip_gateway = ; Upstream/default gateway on the IP-side network +dhcp_relay = true ; DHCP Relay, converts MacTCP Auto Config to DHCP requests +nameserver = 1.1.1.1 ; Name server for DNS + + +[AFP] +; Apple Filing Protocol Server Settings +enabled = true ; true to enable AFP Server, false to disable +name = "ClassicStack" ; Name of the server to use. Max length of 31 characters. +zone = "EtherTalk Network" ; Name of the AppleTalk Zone to list the server in +protocols = ddp,tcp ; Protocols to use. Supports ddp (AppleTalk) and tcp (TCP/IP). They can be combined (eg ddp,tcp) +binding = ":548" ; When TCP is enabled, the IP+Port to bind the service to. +extension_map = "extmap.conf" ; Netatalk compatible extension mapping file + +[Volumes.Default] +name = "Welcome" +path = "./dist/Sample Volume" +read_only = true + +[Volumes.TestVolume] +; AFP Volume Configuration. Each volume must have a section for this. +name = "Test Volume" ; Volume Name. Max Length of 31 characters. +path = "C:\Mac\Test" ; Host path for the volume. Eg "/media/Mac", "C:\Foo" +cnid_backend = ; leave blank for default. Default is "memory" and is currently the only mode supported +use_decomposed_names = true ; Encode host-reserved filename characters using 0xNN tokens when mapping AFP paths. Default is true. +fork_backend = AppleDouble ; Fork backend to use. Currently only "AppleDouble" is implemented. +appledouble_mode = "modern" ; AppleDouble mode to use if using AppleDouble. Supported options are "legacy" and "modern". + ; Legacy is the NetaTalk 2.x ".appledouble" folder approach. + ; Modern is the NetaTalk 4.x method of "._" side cars. Default is "modern". +rebuild_desktop_db = false ; When true, rebuilds the desktop database from resource forks. Default is false. + +[Volumes.Volume68k] +; AFP Volume Configuration. Each volume must have a section for this. +name = "Volume 68K" ; Volume Name. Max Length of 31 characters. +path = "C:\Mac\Volume68K" ; Host path for the volume. Eg "/media/Mac", "C:\Foo" +cnid_backend = ; leave blank for default. Default is "memory" and is currently the only mode supported +use_decomposed_names = true ; Encode host-reserved filename characters using 0xNN tokens when mapping AFP paths. Default is true. +fork_backend = AppleDouble ; Fork backend to use. Currently only "AppleDouble" is implemented. +appledouble_mode = "legacy" ; AppleDouble mode to use if using AppleDouble. Supported options are "legacy" and "modern". + ; Legacy is the NetaTalk 2.x ".appledouble" folder approach. + ; Modern is the NetaTalk 4.x method of "._" side cars. Default is "modern". +rebuild_desktop_db = false ; When true, rebuilds the desktop database from resource forks. + + +[Logging] +level = debug +parse_packets = true +log_traffic = true + + + diff --git a/server.toml b/server.toml index 6fdc912..8f0dcf1 100644 --- a/server.toml +++ b/server.toml @@ -37,7 +37,7 @@ nameserver = "1.1.1.1" # DNS nameserver [AFP] # Apple Filing Protocol server settings enabled = true -name = "OmniTalk" # Server name. Max 31 characters. +name = "ClassicStack" # Server name. Max 31 characters. zone = "EtherTalk Network" protocols = "ddp,tcp" # Comma-separated: ddp, tcp, or both binding = ":548" diff --git a/server.toml.example b/server.toml.example index 8be1d6b..3544700 100644 --- a/server.toml.example +++ b/server.toml.example @@ -40,7 +40,7 @@ nameserver = "1.1.1.1" # DNS nameserver [AFP] # Apple Filing Protocol server settings enabled = true # true to enable AFP server -name = "OmniTalk" # Server name. Max 31 characters. +name = "ClassicStack" # Server name. Max 31 characters. zone = "EtherTalk Network" # AppleTalk zone to advertise the server in protocols = "ddp,tcp" # Comma-separated: ddp, tcp, or both binding = ":548" # When TCP is enabled, the bind address diff --git a/service/aep/aep.go b/service/aep/aep.go index 4196772..231ed13 100644 --- a/service/aep/aep.go +++ b/service/aep/aep.go @@ -1,5 +1,5 @@ /* -Package aep implements the AppleTalk Echo Protocol (AEP) as a omnitalk service. +Package aep implements the AppleTalk Echo Protocol (AEP) as a classicstack service. AEP uses DDP type 4 on socket 4. An echo request (command byte 1) is reflected back to the sender as an echo reply (command byte 2). @@ -12,11 +12,11 @@ import ( "context" "sync" - "github.com/pgodw/omnitalk/protocol/aep" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/aep" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" ) // Socket is the well-known AEP socket number, re-exported from protocol/aep diff --git a/service/afp/appledouble_backend.go b/service/afp/appledouble_backend.go index 08a477e..f9535b9 100644 --- a/service/afp/appledouble_backend.go +++ b/service/afp/appledouble_backend.go @@ -11,7 +11,7 @@ import ( "path/filepath" "strings" - "github.com/pgodw/omnitalk/pkg/appledouble" + "github.com/ObsoleteMadness/ClassicStack/pkg/appledouble" ) const defaultAppleDoubleMode = AppleDoubleModeModern diff --git a/service/afp/appledouble_lifecycle_test.go b/service/afp/appledouble_lifecycle_test.go index 05d1fc0..bb54935 100644 --- a/service/afp/appledouble_lifecycle_test.go +++ b/service/afp/appledouble_lifecycle_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/pgodw/omnitalk/pkg/appledouble" + "github.com/ObsoleteMadness/ClassicStack/pkg/appledouble" ) func TestHandleRename_MovesAppleDoubleSidecar(t *testing.T) { diff --git a/service/afp/catsearch.go b/service/afp/catsearch.go index aea308e..33be325 100644 --- a/service/afp/catsearch.go +++ b/service/afp/catsearch.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/pgodw/omnitalk/netlog" + "github.com/ObsoleteMadness/ClassicStack/netlog" ) // catSearchMaxDataLen is the maximum bytes of ResultsRecord data per reply. diff --git a/service/afp/cnid.go b/service/afp/cnid.go index 23e5cd7..b2ea57f 100644 --- a/service/afp/cnid.go +++ b/service/afp/cnid.go @@ -3,8 +3,8 @@ package afp import ( - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/cnid" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/cnid" ) // CNID constants and the Store interface now live in pkg/cnid. These diff --git a/service/afp/desktop.go b/service/afp/desktop.go index 13d0179..0535729 100644 --- a/service/afp/desktop.go +++ b/service/afp/desktop.go @@ -8,7 +8,7 @@ import ( "io/fs" "path/filepath" - "github.com/pgodw/omnitalk/netlog" + "github.com/ObsoleteMadness/ClassicStack/netlog" ) // getDesktopDB looks up the DesktopDB associated with a DTRefNum. The diff --git a/service/afp/desktop_models.go b/service/afp/desktop_models.go index 160a0d5..72bbe29 100644 --- a/service/afp/desktop_models.go +++ b/service/afp/desktop_models.go @@ -6,7 +6,7 @@ import ( "encoding/binary" "fmt" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) // FPOpenDT - open the Desktop Database for a volume. diff --git a/service/afp/desktop_rebuild.go b/service/afp/desktop_rebuild.go index 870ebf8..fe3c6c7 100644 --- a/service/afp/desktop_rebuild.go +++ b/service/afp/desktop_rebuild.go @@ -14,8 +14,8 @@ import ( "path/filepath" "strings" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/appledouble" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/appledouble" ) // EnableAppleDoubleIconFallback controls whether FPGetIcon misses trigger a diff --git a/service/afp/desktopdb.go b/service/afp/desktopdb.go index edc5e19..f143105 100644 --- a/service/afp/desktopdb.go +++ b/service/afp/desktopdb.go @@ -7,8 +7,8 @@ import ( "fmt" "sync" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/cnid" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/cnid" ) const desktopDBFilename = ".desktop.db" diff --git a/service/afp/directory.go b/service/afp/directory.go index b06c6b5..baa16c7 100644 --- a/service/afp/directory.go +++ b/service/afp/directory.go @@ -3,12 +3,13 @@ package afp import ( - "github.com/pgodw/omnitalk/netlog" "bytes" "errors" "io/fs" "os" "path/filepath" + + "github.com/ObsoleteMadness/ClassicStack/netlog" ) func (s *Service) handleOpenDir(req *FPOpenDirReq) (*FPOpenDirRes, int32) { diff --git a/service/afp/directory_models.go b/service/afp/directory_models.go index 5061545..48f7cdb 100644 --- a/service/afp/directory_models.go +++ b/service/afp/directory_models.go @@ -7,7 +7,7 @@ import ( "fmt" "strings" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) func formatDirBitmap(bitmap uint16) string { diff --git a/service/afp/dispatcher.go b/service/afp/dispatcher.go index f12b39f..3a933a7 100644 --- a/service/afp/dispatcher.go +++ b/service/afp/dispatcher.go @@ -5,7 +5,7 @@ package afp import ( "runtime/debug" - "github.com/pgodw/omnitalk/netlog" + "github.com/ObsoleteMadness/ClassicStack/netlog" ) // Request is the decoded form of an inbound AFP command. diff --git a/service/afp/enumerate_encoding_test.go b/service/afp/enumerate_encoding_test.go index 32fd59e..e5710a8 100644 --- a/service/afp/enumerate_encoding_test.go +++ b/service/afp/enumerate_encoding_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/pgodw/omnitalk/pkg/encoding" + "github.com/ObsoleteMadness/ClassicStack/pkg/encoding" ) type enumStubInfo struct { diff --git a/service/afp/file.go b/service/afp/file.go index 7ba1ec1..5db889b 100644 --- a/service/afp/file.go +++ b/service/afp/file.go @@ -3,11 +3,12 @@ package afp import ( - "github.com/pgodw/omnitalk/netlog" "errors" "io" "os" "path/filepath" + + "github.com/ObsoleteMadness/ClassicStack/netlog" ) func (s *Service) handleSetFileParms(req *FPSetFileParmsReq) (*FPSetFileParmsRes, int32) { diff --git a/service/afp/filedir.go b/service/afp/filedir.go index 2d5d79d..2c11661 100644 --- a/service/afp/filedir.go +++ b/service/afp/filedir.go @@ -3,10 +3,11 @@ package afp import ( - "github.com/pgodw/omnitalk/netlog" "bytes" "io/fs" "path/filepath" + + "github.com/ObsoleteMadness/ClassicStack/netlog" ) func (s *Service) handleGetFileDirParms(req *FPGetFileDirParmsReq) (*FPGetFileDirParmsRes, int32) { diff --git a/service/afp/filedir_models.go b/service/afp/filedir_models.go index d3fc363..72b4c7b 100644 --- a/service/afp/filedir_models.go +++ b/service/afp/filedir_models.go @@ -6,7 +6,7 @@ import ( "encoding/binary" "fmt" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) type FPGetFileDirParmsReq struct { diff --git a/service/afp/filedir_pack.go b/service/afp/filedir_pack.go index 8629769..fa55100 100644 --- a/service/afp/filedir_pack.go +++ b/service/afp/filedir_pack.go @@ -8,7 +8,7 @@ import ( "path/filepath" "time" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) // toAFPTime converts a Go time.Time to AFP's seconds-since-1904 epoch. diff --git a/service/afp/fork.go b/service/afp/fork.go index c4d2a93..bd9f3fb 100644 --- a/service/afp/fork.go +++ b/service/afp/fork.go @@ -13,9 +13,9 @@ import ( "path/filepath" "syscall" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/appledouble" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/appledouble" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) func (s *Service) handleOpenFork(req *FPOpenForkReq) (*FPOpenForkRes, int32) { diff --git a/service/afp/fork_models.go b/service/afp/fork_models.go index c8db4a6..cf59cad 100644 --- a/service/afp/fork_models.go +++ b/service/afp/fork_models.go @@ -6,7 +6,7 @@ import ( "encoding/binary" "fmt" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) // Fork type constants for FPOpenFork. diff --git a/service/afp/info.go b/service/afp/info.go index 5beea0d..a4f555c 100644 --- a/service/afp/info.go +++ b/service/afp/info.go @@ -5,7 +5,7 @@ package afp import ( "bytes" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) // BuildServerInfo constructs the payload for an AFP FPGetSrvrInfo or ASP GetStatus reply. diff --git a/service/afp/logging.go b/service/afp/logging.go index 7de473c..d185667 100644 --- a/service/afp/logging.go +++ b/service/afp/logging.go @@ -3,8 +3,9 @@ package afp import ( - "github.com/pgodw/omnitalk/netlog" "fmt" + + "github.com/ObsoleteMadness/ClassicStack/netlog" ) func (s *Service) logPacket(format string, args ...any) { diff --git a/service/afp/macgarden_fs.go b/service/afp/macgarden_fs.go new file mode 100644 index 0000000..83ef2e0 --- /dev/null +++ b/service/afp/macgarden_fs.go @@ -0,0 +1,1810 @@ +//go:build macgarden + +package afp + +import ( + "errors" + "fmt" + "io" + "io/fs" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + "unicode" + + "github.com/ObsoleteMadness/ClassicStack/netlog" + garden "github.com/ObsoleteMadness/ClassicStack/service/macgarden" +) + +const macGardenEnumerateWindow = 10 +const macGardenSearchPageSize = 20 + +type macGardenFileInfo struct { + name string + size int64 + mode fs.FileMode + modTime time.Time + isDir bool +} + +func (i *macGardenFileInfo) Name() string { return i.name } +func (i *macGardenFileInfo) Size() int64 { return i.size } +func (i *macGardenFileInfo) Mode() fs.FileMode { return i.mode } +func (i *macGardenFileInfo) ModTime() time.Time { return i.modTime } +func (i *macGardenFileInfo) IsDir() bool { return i.isDir } +func (i *macGardenFileInfo) Sys() any { return nil } + +type macGardenDirEntry struct{ info fs.FileInfo } + +func (d macGardenDirEntry) Name() string { return d.info.Name() } +func (d macGardenDirEntry) IsDir() bool { return d.info.IsDir() } +func (d macGardenDirEntry) Type() fs.FileMode { return d.info.Mode().Type() } +func (d macGardenDirEntry) Info() (fs.FileInfo, error) { return d.info, nil } + +type macGardenCachedResult struct { + Name string + URL string +} + +type macGardenAsset struct { + Name string + URL string + Size int64 + Content []byte +} + +type macGardenCategoryPageMeta struct { + TotalCount uint16 + PageSize int + LastPageNumber int + LastPageCount int +} + +type macGardenFile struct { + asset macGardenAsset + client *garden.Client +} + +func (f *macGardenFile) ReadAt(p []byte, off int64) (n int, err error) { + if off < 0 { + return 0, fs.ErrInvalid + } + if len(f.asset.Content) > 0 { + if off >= int64(len(f.asset.Content)) { + return 0, io.EOF + } + n = copy(p, f.asset.Content[off:]) + if n < len(p) { + return n, io.EOF + } + return n, nil + } + // ReadURLRange applies the client's maxRangeSize cap internally, so it may + // return fewer bytes than len(p). Signal io.EOF only when the HTTP response + // is shorter than the bytes we actually requested — meaning we hit real EOF, + // not just the range cap. FPRead buffers are already bounded by the same cap + // (via handleRead.maxReadSize), so for that path len(data)==len(p) always. + // FPCopyFile re-reads in a loop, so getting n 0 && requested > max { + requested = max + } + data, readErr := f.client.ReadURLRange(f.asset.URL, off, len(p)) + if readErr != nil { + return 0, fmt.Errorf("%w: %v", ErrCopySourceReadEOF, readErr) + } + n = copy(p, data) + if len(data) < requested { + return n, io.EOF + } + return n, nil +} + +func (f *macGardenFile) WriteAt(_ []byte, _ int64) (n int, err error) { return 0, fs.ErrPermission } +func (f *macGardenFile) Truncate(_ int64) error { return fs.ErrPermission } +func (f *macGardenFile) Close() error { return nil } +func (f *macGardenFile) Sync() error { return nil } +func (f *macGardenFile) Stat() (fs.FileInfo, error) { + size := f.asset.Size + if size == 0 && f.asset.URL != "" { + if s, err := f.client.GetContentLength(f.asset.URL); err == nil { + size = s + } + } + return &macGardenFileInfo{name: filepath.Base(f.asset.Name), size: size, mode: 0o444, modTime: time.Now().UTC()}, nil +} + +// fetchAndCacheScreenshot downloads a screenshot URL and stores it in the +// in-memory cache. Subsequent OpenFile calls serve from cache without network I/O. +func (m *MacGardenFileSystem) fetchAndCacheScreenshot(url string) ([]byte, error) { + m.screenshotMu.RLock() + if data, ok := m.screenshotCache[url]; ok { + m.screenshotMu.RUnlock() + return data, nil + } + m.screenshotMu.RUnlock() + data, err := m.client.FetchFull(url) + if err != nil { + return nil, err + } + m.screenshotMu.Lock() + m.screenshotCache[url] = data + m.screenshotMu.Unlock() + return data, nil +} + +// resolveAssetSize returns the known size, or triggers a size fetch appropriate +// for the asset type. Called during FPGetFileDirParms so Finder sees the real size. +// Screenshots: full download cached in memory (avoids HEAD which gets blocked). +// Downloads: ranged GET to read the Content-Range total only. +func (m *MacGardenFileSystem) resolveAssetSize(a macGardenAsset) int64 { + if a.Size > 0 || a.URL == "" { + return a.Size + } + if strings.HasPrefix(a.Name, "Screenshots/") { + if data, err := m.fetchAndCacheScreenshot(a.URL); err == nil { + return int64(len(data)) + } + return 0 + } + if s, err := m.client.GetContentLength(a.URL); err == nil { + return s + } + return 0 +} + +// MacGardenFileSystem is a read-only virtual filesystem backed by macintoshgarden.org. +type macGardenSearchCache struct { + pages map[int][]garden.SearchResult // pageNumber -> results + exhausted bool // true when all pages have been fetched +} + +type MacGardenFileSystem struct { + root string + client *garden.Client + + mu sync.RWMutex + categories []garden.Category + searchByName map[string]macGardenCachedResult + itemURLByDir map[string]string + itemByURL map[string]*garden.SoftwareItem + itemsInCategory map[string][]garden.SearchResult // categoryURL -> items + categoryItemCount map[string]uint16 + categoryPageMeta map[string]macGardenCategoryPageMeta + categoryPageItems map[string]map[int][]garden.SearchResult + downloadByPath map[string]macGardenAsset + screenshotByPath map[string]macGardenAsset + descriptionByPath map[string]macGardenAsset + catSearchCache map[string]*macGardenSearchCache // normalized query -> cached results + + screenshotMu sync.RWMutex + screenshotCache map[string][]byte // URL -> full image bytes + + stop chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func init() { + RegisterFS(FSTypeMacGarden, func(cfg VolumeConfig) (FileSystem, error) { + return NewMacGardenFileSystem(filepath.Clean(cfg.Path)), nil + }) +} + +func NewMacGardenFileSystem(root string) *MacGardenFileSystem { + fsys := &MacGardenFileSystem{ + root: filepath.Clean(root), + client: garden.NewClient(), + searchByName: make(map[string]macGardenCachedResult), + itemURLByDir: make(map[string]string), + itemByURL: make(map[string]*garden.SoftwareItem), + itemsInCategory: make(map[string][]garden.SearchResult), + categoryItemCount: make(map[string]uint16), + categoryPageMeta: make(map[string]macGardenCategoryPageMeta), + categoryPageItems: make(map[string]map[int][]garden.SearchResult), + downloadByPath: make(map[string]macGardenAsset), + screenshotByPath: make(map[string]macGardenAsset), + descriptionByPath: make(map[string]macGardenAsset), + catSearchCache: make(map[string]*macGardenSearchCache), + screenshotCache: make(map[string][]byte), + stop: make(chan struct{}), + } + fsys.loadCategories() + return fsys +} + +func (m *MacGardenFileSystem) loadCategories() { + m.mu.RLock() + if len(m.categories) > 0 { + m.mu.RUnlock() + return + } + m.mu.RUnlock() + cats, err := m.client.GetCategories() + if err != nil { + netlog.Warn("[AFP][MacGarden] failed to fetch categories: %v", err) + return + } + sort.Slice(cats, func(i, j int) bool { return strings.ToLower(cats[i].Name) < strings.ToLower(cats[j].Name) }) + m.mu.Lock() + if len(m.categories) == 0 { + m.categories = cats + } + m.mu.Unlock() + if len(cats) == 0 { + netlog.Warn("[AFP][MacGarden] category fetch succeeded but returned no categories") + } +} + +func (m *MacGardenFileSystem) normalize(path string) (string, error) { + clean := filepath.Clean(path) + rel, err := filepath.Rel(m.root, clean) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fs.ErrPermission + } + if rel == "." { + return "", nil + } + return filepath.ToSlash(rel), nil +} + +// readDirCore resolves a normalized relative path to directory entries. It is +// the shared implementation used by both ReadDir and ReadDirRange. Callers are +// responsible for running it in a goroutine if a timeout is needed. +func (m *MacGardenFileSystem) readDirCore(rel string) ([]fs.DirEntry, error) { + if rel == "" { + netlog.Debug("[AFP][MacGarden] ReadDir root") + return []fs.DirEntry{ + macGardenDirEntry{info: &macGardenFileInfo{name: "Apps", mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}, + macGardenDirEntry{info: &macGardenFileInfo{name: "Games", mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}, + macGardenDirEntry{info: &macGardenFileInfo{name: "search", mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}, + }, nil + } + + parts := strings.Split(rel, "/") + + // Apps or Games level: show categories for that type. + if len(parts) == 1 && (parts[0] == "Apps" || parts[0] == "Games") { + netlog.Debug("[AFP][MacGarden] ReadDir %s", parts[0]) + m.loadCategories() + catType := parts[0] + urlPrefix := "/apps/" + if catType == "Games" { + urlPrefix = "/games/" + } + m.mu.RLock() + defer m.mu.RUnlock() + entries := make([]fs.DirEntry, 0, len(m.categories)) + for _, cat := range m.categories { + if strings.HasPrefix(strings.ToLower(urlPathFromAbsolute(cat.URL)), urlPrefix) { + entries = append(entries, macGardenDirEntry{info: &macGardenFileInfo{name: cat.Name, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}) + } + } + netlog.Info("[AFP][MacGarden] ReadDir %s returning %d entries", catType, len(entries)) + return entries, nil + } + + // /search — list all cached search queries as subdirectories. + if len(parts) == 1 && parts[0] == "search" { + m.mu.RLock() + queries := make([]string, 0, len(m.catSearchCache)) + for q := range m.catSearchCache { + queries = append(queries, q) + } + m.mu.RUnlock() + sort.Strings(queries) + entries := make([]fs.DirEntry, 0, len(queries)) + for _, q := range queries { + entries = append(entries, macGardenDirEntry{info: &macGardenFileInfo{name: q, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}) + } + return entries, nil + } + + // /search/ — list type subdirectories (App, Game) plus untyped items. + if len(parts) == 2 && parts[0] == "search" { + m.mu.RLock() + cache, ok := m.catSearchCache[parts[1]] + m.mu.RUnlock() + if !ok { + return nil, fs.ErrNotExist + } + pageNums := make([]int, 0, len(cache.pages)) + for k := range cache.pages { + pageNums = append(pageNums, k) + } + sort.Ints(pageNums) + typesSeen := map[string]struct{}{} + untypedSeen := map[string]struct{}{} + var typeNames, untypedNames []string + for _, pn := range pageNums { + for _, r := range cache.pages[pn] { + if r.Type != "" { + if _, exists := typesSeen[r.Type]; !exists { + typesSeen[r.Type] = struct{}{} + typeNames = append(typeNames, r.Type) + } + } else { + if name := sanitizeGardenName(r.Name); name != "" { + if _, exists := untypedSeen[name]; !exists { + untypedSeen[name] = struct{}{} + untypedNames = append(untypedNames, name) + } + } + } + } + } + sort.Strings(typeNames) + sort.Strings(untypedNames) + entries := make([]fs.DirEntry, 0, len(typeNames)+len(untypedNames)) + for _, name := range typeNames { + entries = append(entries, macGardenDirEntry{info: &macGardenFileInfo{name: name, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}) + } + for _, name := range untypedNames { + entries = append(entries, macGardenDirEntry{info: &macGardenFileInfo{name: name, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}) + } + return entries, nil + } + + // /search// — virtual type subdirectory (App/Game). + if len(parts) == 3 && parts[0] == "search" && isSearchResultType(parts[2]) { + m.mu.RLock() + cache, ok := m.catSearchCache[parts[1]] + m.mu.RUnlock() + if !ok { + return nil, fs.ErrNotExist + } + resultType := parts[2] + var names []string + for _, page := range cache.pages { + for _, r := range page { + if r.Type == resultType { + if name := sanitizeGardenName(r.Name); name != "" { + names = append(names, name) + } + } + } + } + sort.Strings(names) + entries := make([]fs.DirEntry, 0, len(names)) + for _, name := range names { + entries = append(entries, macGardenDirEntry{info: &macGardenFileInfo{name: name, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}) + } + return entries, nil + } + + // /search// — assets for that item. + if len(parts) == 3 && parts[0] == "search" { + itemName := parts[2] + m.mu.RLock() + search, ok := m.searchByName[itemName] + m.mu.RUnlock() + if !ok { + return nil, fs.ErrNotExist + } + if err := m.ensureItemForDir(itemName, search.URL); err != nil { + return nil, err + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return nil, err + } + return buildItemDirEntries(assets, ""), nil + } + + // /search///[/] — typed item or its subdirectory. + if len(parts) >= 4 && parts[0] == "search" && isSearchResultType(parts[2]) { + itemName := parts[3] + subPath := filepath.ToSlash(filepath.Join(parts[4:]...)) + m.mu.RLock() + search, ok := m.searchByName[itemName] + m.mu.RUnlock() + if !ok { + return nil, fs.ErrNotExist + } + if err := m.ensureItemForDir(itemName, search.URL); err != nil { + return nil, err + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return nil, err + } + return buildItemDirEntries(assets, subPath), nil + } + + // /search/// — subdirectory within an item. + if len(parts) >= 4 && parts[0] == "search" { + itemName := parts[2] + subPath := filepath.ToSlash(filepath.Join(parts[3:]...)) + m.mu.RLock() + search, ok := m.searchByName[itemName] + m.mu.RUnlock() + if !ok { + return nil, fs.ErrNotExist + } + if err := m.ensureItemForDir(itemName, search.URL); err != nil { + return nil, err + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return nil, err + } + return buildItemDirEntries(assets, subPath), nil + } + + // Apps/Games/CategoryName/ItemName — assets for a software item + if len(parts) == 3 && (parts[0] == "Apps" || parts[0] == "Games") { + catName, itemName := parts[1], parts[2] + catURL := m.getCategoryURL(catName) + if catURL == "" { + return nil, fs.ErrNotExist + } + itemURL, err := m.getItemURLInCategory(catURL, itemName) + if err != nil { + return nil, fs.ErrNotExist + } + if err := m.ensureItemForDir(itemName, itemURL); err != nil { + return nil, err + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return nil, err + } + return buildItemDirEntries(assets, ""), nil + } + + // Apps/Games/CategoryName/ItemName/SubDir... — subdirectory within an item + if len(parts) >= 4 && (parts[0] == "Apps" || parts[0] == "Games") { + catName, itemName := parts[1], parts[2] + subPath := filepath.ToSlash(filepath.Join(parts[3:]...)) + catURL := m.getCategoryURL(catName) + if catURL == "" { + return nil, fs.ErrNotExist + } + itemURL, err := m.getItemURLInCategory(catURL, itemName) + if err != nil { + return nil, fs.ErrNotExist + } + if err := m.ensureItemForDir(itemName, itemURL); err != nil { + return nil, err + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return nil, err + } + return buildItemDirEntries(assets, subPath), nil + } + + return nil, fs.ErrNotExist +} + +func (m *MacGardenFileSystem) ReadDir(path string) ([]fs.DirEntry, error) { + rel, err := m.normalize(path) + if err != nil { + return nil, err + } + return m.readDirCore(rel) +} + +func (m *MacGardenFileSystem) ReadDirRange(path string, startIndex uint16, reqCount uint16) ([]fs.DirEntry, uint16, error) { + if reqCount == 0 { + return nil, 0, nil + } + rel, err := m.normalize(path) + if err != nil { + return nil, 0, err + } + parts := strings.Split(rel, "/") + if len(parts) == 1 && (parts[0] == "Apps" || parts[0] == "Games") { + m.loadCategories() + prefix := "/apps/" + if parts[0] == "Games" { + prefix = "/games/" + } + m.mu.RLock() + filtered := make([]fs.DirEntry, 0, len(m.categories)) + for _, cat := range m.categories { + if strings.HasPrefix(strings.ToLower(urlPathFromAbsolute(cat.URL)), prefix) { + filtered = append(filtered, macGardenDirEntry{info: &macGardenFileInfo{name: cat.Name, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}) + } + } + m.mu.RUnlock() + total := uint16(len(filtered)) + if startIndex < 1 { + startIndex = 1 + } + if int(startIndex) > len(filtered) { + return nil, total, nil + } + start := int(startIndex) - 1 + end := start + int(reqCount) + if end > len(filtered) { + end = len(filtered) + } + return append([]fs.DirEntry(nil), filtered[start:end]...), total, nil + } + if len(parts) == 2 && (parts[0] == "Apps" || parts[0] == "Games") { + catURL := m.getCategoryURL(parts[1]) + if catURL == "" { + return nil, 0, fs.ErrNotExist + } + return m.readCategoryDirRange(catURL, startIndex, reqCount) + } + entries, err := m.readDirCore(rel) + if err != nil { + return nil, 0, err + } + total := uint16(len(entries)) + if startIndex < 1 { + startIndex = 1 + } + if int(startIndex) > len(entries) { + return nil, total, nil + } + start := int(startIndex) - 1 + end := start + int(reqCount) + if end > len(entries) { + end = len(entries) + } + return append([]fs.DirEntry(nil), entries[start:end]...), total, nil +} + +func (m *MacGardenFileSystem) Stat(path string) (fs.FileInfo, error) { + rel, err := m.normalize(path) + if err != nil { + return nil, err + } + if rel == "" { + return &macGardenFileInfo{name: filepath.Base(m.root), mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + + parts := strings.Split(rel, "/") + + // Apps or Games level + if len(parts) == 1 && (parts[0] == "Apps" || parts[0] == "Games") { + return &macGardenFileInfo{name: parts[0], mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + + // /search virtual directory + if len(parts) == 1 && parts[0] == "search" { + return &macGardenFileInfo{name: "search", mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + + // /search/ + if len(parts) == 2 && parts[0] == "search" { + m.mu.RLock() + _, ok := m.catSearchCache[parts[1]] + m.mu.RUnlock() + if ok { + return &macGardenFileInfo{name: parts[1], mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + return nil, fs.ErrNotExist + } + + // /search// — virtual type subdirectory (App/Game) + // /search// — item directory + if len(parts) == 3 && parts[0] == "search" { + if isSearchResultType(parts[2]) { + return &macGardenFileInfo{name: parts[2], mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + itemName := parts[2] + m.mu.RLock() + cache, ok := m.catSearchCache[parts[1]] + m.mu.RUnlock() + if !ok { + return nil, fs.ErrNotExist + } + for _, page := range cache.pages { + for _, r := range page { + if sanitizeGardenName(r.Name) == itemName { + return &macGardenFileInfo{name: itemName, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + } + } + return nil, fs.ErrNotExist + } + + // /search///[/] or /search/// + if len(parts) >= 4 && parts[0] == "search" { + var itemName, fileName string + if isSearchResultType(parts[2]) { + itemName = parts[3] + fileName = strings.Join(parts[4:], "/") + } else { + itemName = parts[2] + fileName = strings.Join(parts[3:], "/") + } + if fileName == "" { + // It's the item directory itself under a type subdirectory + return &macGardenFileInfo{name: itemName, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + m.mu.RLock() + search, ok := m.searchByName[itemName] + loaded := false + if ok { + _, loaded = m.itemByURL[search.URL] + } + m.mu.RUnlock() + if !ok || !loaded { + return nil, fs.ErrNotExist + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return nil, err + } + for _, a := range assets { + if a.Name == fileName { + return &macGardenFileInfo{name: filepath.Base(a.Name), size: m.resolveAssetSize(a), mode: 0o444, modTime: time.Now().UTC()}, nil + } + } + prefix := fileName + "/" + for _, a := range assets { + if strings.HasPrefix(a.Name, prefix) { + return &macGardenFileInfo{name: filepath.Base(fileName), mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + } + return nil, fs.ErrNotExist + } + + // Search-hit item directory at root level (legacy, retained for compatibility). + if len(parts) == 1 { + m.mu.RLock() + _, ok := m.searchByName[parts[0]] + m.mu.RUnlock() + if ok { + return &macGardenFileInfo{name: parts[0], mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + } + + // Category level - return immediately without fetching items + // Stat should be lightweight; items are fetched lazily only on ReadDir + if len(parts) == 2 && (parts[0] == "Apps" || parts[0] == "Games") { + catName := parts[1] + catURL := m.getCategoryURL(catName) + if catURL != "" { + netlog.Debug("[AFP][MacGarden] Stat returning category (no lazy fetch): %s", catName) + return &macGardenFileInfo{name: catName, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + return nil, fs.ErrNotExist + } + + // Item level - return immediately without fetching items + if len(parts) == 3 && (parts[0] == "Apps" || parts[0] == "Games") { + itemName := parts[2] + // Don't fetch the item here; just return dir info + // Real items are fetched lazily when ReadDir is called + netlog.Debug("[AFP][MacGarden] Stat returning item (no lazy fetch): %s", itemName) + return &macGardenFileInfo{name: itemName, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + + // macOS probes certain well-known system paths on every directory it visits. + // Reject them quickly so we never trigger network fetches for them. + macSystemNames := map[string]bool{ + "Configuration": true, + "Network Trash Folder": true, + "TheVolumeSettingsFolder": true, + "Temporary Items": true, + ".DS_Store": true, + "Icon\r": true, + } + if len(parts) >= 3 && macSystemNames[parts[len(parts)-1]] { + return nil, fs.ErrNotExist + } + + // Asset level (file) + if len(parts) >= 4 && (parts[0] == "Apps" || parts[0] == "Games") { + catName := parts[1] + itemName := parts[2] + fileName := strings.Join(parts[3:], "/") + + catURL := m.getCategoryURL(catName) + if catURL == "" { + return nil, fs.ErrNotExist + } + + itemURL, err := m.getItemURLInCategory(catURL, itemName) + if err != nil { + return nil, fs.ErrNotExist + } + + // Keep Stat lazy for item children: if the item has not been opened yet, + // do not fetch details just to probe a potential child path. + m.mu.RLock() + _, loaded := m.itemByURL[itemURL] + m.mu.RUnlock() + if !loaded { + return nil, fs.ErrNotExist + } + + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return nil, err + } + + for _, a := range assets { + if a.Name == fileName { + return &macGardenFileInfo{name: filepath.Base(a.Name), size: m.resolveAssetSize(a), mode: 0o444, modTime: time.Now().UTC()}, nil + } + } + prefix := fileName + "/" + for _, a := range assets { + if strings.HasPrefix(a.Name, prefix) { + return &macGardenFileInfo{name: filepath.Base(fileName), mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}, nil + } + } + } + + // Asset-level file under root search-hit item dir: ItemName/Asset + if len(parts) >= 2 && parts[0] != "Apps" && parts[0] != "Games" { + itemName := parts[0] + fileName := filepath.Join(parts[1:]...) + m.mu.RLock() + search, ok := m.searchByName[itemName] + loaded := false + if ok { + _, loaded = m.itemByURL[search.URL] + } + m.mu.RUnlock() + if !ok || !loaded { + return nil, fs.ErrNotExist + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return nil, err + } + for _, a := range assets { + if a.Name == fileName { + return &macGardenFileInfo{name: a.Name, size: a.Size, mode: 0o444, modTime: time.Now().UTC()}, nil + } + } + } + + return nil, fs.ErrNotExist +} + +func (m *MacGardenFileSystem) DiskUsage(_ string) (totalBytes uint64, freeBytes uint64, err error) { + return 0x20000000, 0x18000000, nil +} + +func (m *MacGardenFileSystem) ChildCount(path string) (uint16, error) { + rel, err := m.normalize(path) + if err != nil { + return 0, err + } + if rel == "" { + return 3, nil // Apps + Games + search + } + + m.loadCategories() + parts := strings.Split(rel, "/") + if len(parts) == 1 { + switch parts[0] { + case "Apps": + return m.countCategoriesWithPrefix("/apps/"), nil + case "Games": + return m.countCategoriesWithPrefix("/games/"), nil + } + } + if len(parts) == 2 && (parts[0] == "Apps" || parts[0] == "Games") { + catURL := m.getCategoryURL(parts[1]) + if catURL == "" { + return 0, nil + } + m.mu.RLock() + if count, ok := m.categoryItemCount[catURL]; ok { + m.mu.RUnlock() + return count, nil + } + m.mu.RUnlock() + // Category counts must remain fully lazy. Until a category has actually + // been opened and its items fetched, report an unknown count as zero + // rather than triggering remote requests during parent directory enumerate. + return 0, nil + } + if len(parts) == 3 && (parts[0] == "Apps" || parts[0] == "Games") { + itemName := parts[2] + m.mu.RLock() + itemURL := m.itemURLByDir[itemName] + item := m.itemByURL[itemURL] + m.mu.RUnlock() + if item == nil { + return 0, nil + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return 0, nil + } + return uint16(len(buildItemDirEntries(assets, ""))), nil + } + if len(parts) >= 4 && (parts[0] == "Apps" || parts[0] == "Games") { + itemName := parts[2] + subPath := strings.Join(parts[3:], "/") + m.mu.RLock() + itemURL := m.itemURLByDir[itemName] + item := m.itemByURL[itemURL] + m.mu.RUnlock() + if item == nil { + return 0, nil + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return 0, nil + } + return uint16(len(buildItemDirEntries(assets, subPath))), nil + } + if len(parts) >= 1 && parts[0] == "search" { + switch len(parts) { + case 1: + // /search — number of cached queries. + m.mu.RLock() + n := uint16(len(m.catSearchCache)) + m.mu.RUnlock() + return n, nil + case 2: + // /search/ — count distinct type dirs + untyped items. + m.mu.RLock() + cache, ok := m.catSearchCache[parts[1]] + m.mu.RUnlock() + if !ok { + return 0, nil + } + typesSeen := map[string]struct{}{} + untypedSeen := map[string]struct{}{} + for _, page := range cache.pages { + for _, r := range page { + if r.Type != "" { + typesSeen[r.Type] = struct{}{} + } else if name := sanitizeGardenName(r.Name); name != "" { + untypedSeen[name] = struct{}{} + } + } + } + return clampGardenCount(len(typesSeen) + len(untypedSeen)), nil + case 3: + // /search// — count items of that type. + if isSearchResultType(parts[2]) { + m.mu.RLock() + cache, ok := m.catSearchCache[parts[1]] + m.mu.RUnlock() + if !ok { + return 0, nil + } + seen := map[string]struct{}{} + for _, page := range cache.pages { + for _, r := range page { + if r.Type == parts[2] { + if name := sanitizeGardenName(r.Name); name != "" { + seen[name] = struct{}{} + } + } + } + } + return clampGardenCount(len(seen)), nil + } + // /search// — offspring count for item root. + itemName := parts[2] + m.mu.RLock() + itemURL := m.itemURLByDir[itemName] + item := m.itemByURL[itemURL] + m.mu.RUnlock() + if item == nil { + return 0, nil + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return 0, nil + } + return uint16(len(buildItemDirEntries(assets, ""))), nil + default: + // /search///[/] or /search/// + var itemName, subPath string + if isSearchResultType(parts[2]) { + itemName = parts[3] + subPath = strings.Join(parts[4:], "/") + } else { + itemName = parts[2] + subPath = strings.Join(parts[3:], "/") + } + m.mu.RLock() + itemURL := m.itemURLByDir[itemName] + item := m.itemByURL[itemURL] + m.mu.RUnlock() + if item == nil { + return 0, nil + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return 0, nil + } + return uint16(len(buildItemDirEntries(assets, subPath))), nil + } + } + if len(parts) == 1 { + return 0, nil + } + return 0, newNotSupported("ChildCount") +} + +// DirAttributes returns AFP directory attribute bits for a path. +// /search is flagged invisible so it stays hidden from normal Finder browsing. +func (m *MacGardenFileSystem) DirAttributes(path string) (uint16, error) { + rel, err := m.normalize(path) + if err != nil { + return 0, err + } + if rel == "search" { + return DirAttrInvisible, nil + } + return 0, nil +} + +func (m *MacGardenFileSystem) IsReadOnly(_ string) (bool, error) { + return true, nil +} + +// SetMaxRangeSize limits each HTTP range request to at most n bytes. +// Called by the AFP service with the ASP quantum size so that reads from +// macintoshgarden.org never exceed what can fit in one ASP reply. +func (m *MacGardenFileSystem) SetMaxRangeSize(n int) { + m.client.SetMaxRangeSize(n) +} + +func (m *MacGardenFileSystem) SupportsCatSearch(_ string) (bool, error) { + return true, nil +} + +func (m *MacGardenFileSystem) Capabilities() FileSystemCapabilities { + return FileSystemCapabilities{ + CatSearch: true, + ChildCount: true, + ReadDirRange: true, + DirAttributes: true, + ReadOnlyState: true, + } +} + +func (m *MacGardenFileSystem) Close() error { + m.stopOnce.Do(func() { close(m.stop) }) + m.wg.Wait() + return nil +} + +func (m *MacGardenFileSystem) CreateDir(_ string) error { return fs.ErrPermission } +func (m *MacGardenFileSystem) CreateFile(_ string) (File, error) { return nil, fs.ErrPermission } +func (m *MacGardenFileSystem) Remove(_ string) error { return fs.ErrPermission } +func (m *MacGardenFileSystem) Rename(_, _ string) error { return fs.ErrPermission } + +// openAsset wraps an asset in a macGardenFile, populating Content from the +// in-memory screenshot cache when the image has already been downloaded. +func (m *MacGardenFileSystem) openAsset(a macGardenAsset) *macGardenFile { + if strings.HasPrefix(a.Name, "Screenshots/") && a.URL != "" && len(a.Content) == 0 { + m.screenshotMu.RLock() + data, ok := m.screenshotCache[a.URL] + m.screenshotMu.RUnlock() + if ok { + a.Content = data + a.Size = int64(len(data)) + } + } + return &macGardenFile{asset: a, client: m.client} +} + +func (m *MacGardenFileSystem) OpenFile(path string, flag int) (File, error) { + if flag&(os.O_WRONLY|os.O_RDWR|os.O_APPEND|os.O_CREATE|os.O_TRUNC) != 0 { + return nil, fs.ErrPermission + } + rel, err := m.normalize(path) + if err != nil { + return nil, err + } + + parts := strings.Split(rel, "/") + + // /search//[/]/ + if len(parts) >= 4 && parts[0] == "search" { + var itemName, fileName string + if isSearchResultType(parts[2]) { + if len(parts) < 5 { + return nil, fs.ErrInvalid + } + itemName = parts[3] + fileName = strings.Join(parts[4:], "/") + } else { + itemName = parts[2] + fileName = strings.Join(parts[3:], "/") + } + m.mu.RLock() + search, ok := m.searchByName[itemName] + m.mu.RUnlock() + if !ok { + return nil, fs.ErrNotExist + } + if err := m.ensureItemForDir(itemName, search.URL); err != nil { + return nil, fs.ErrNotExist + } + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return nil, err + } + for _, a := range assets { + if a.Name == fileName { + return m.openAsset(a), nil + } + } + return nil, fs.ErrNotExist + } + + // Must be asset level: Apps/Category/Item/Asset or deeper + if len(parts) < 4 || (parts[0] != "Apps" && parts[0] != "Games") { + return nil, fs.ErrInvalid + } + + catName := parts[1] + itemName := parts[2] + fileName := strings.Join(parts[3:], "/") + + catURL := m.getCategoryURL(catName) + if catURL == "" { + return nil, fs.ErrNotExist + } + + itemURL, err := m.getItemURLInCategory(catURL, itemName) + if err != nil { + return nil, fs.ErrNotExist + } + + if err := m.ensureItemForDir(itemName, itemURL); err != nil { + return nil, fs.ErrNotExist + } + + assets, err := m.itemAssetsByDir(itemName) + if err != nil { + return nil, err + } + + for _, a := range assets { + if a.Name == fileName { + return m.openAsset(a), nil + } + } + return nil, fs.ErrNotExist +} + +func (m *MacGardenFileSystem) CatSearch(_ string, query string, reqMatches int32, cursor [16]byte) ([]string, [16]byte, int32) { + rawQuery := strings.TrimSpace(query) + if rawQuery == "" { + return nil, cursor, ErrParamErr + } + normalizedQuery := normalizeMacGardenSearchQuery(rawQuery) + if normalizedQuery == "" { + return nil, cursor, ErrParamErr + } + + limit := int(reqMatches) + if limit <= 0 { + limit = 25 + } + + isContinuation := cursor[0] == 0x01 + cursorQueryHash := uint32(cursor[1])<<16 | uint32(cursor[2])<<8 | uint32(cursor[3]) + cursorOffset := uint32(cursor[4])<<24 | uint32(cursor[5])<<16 | uint32(cursor[6])<<8 | uint32(cursor[7]) + + queryHash := uint32(0) + if len(normalizedQuery) >= 3 { + queryHash = uint32(normalizedQuery[0])<<16 | uint32(normalizedQuery[1])<<8 | uint32(normalizedQuery[2]) + } else if len(normalizedQuery) > 0 { + for i := 0; i < len(normalizedQuery); i++ { + queryHash = (queryHash << 8) | uint32(normalizedQuery[i]) + } + } + + startIdx := 0 + if isContinuation && cursorQueryHash == queryHash { + startIdx = int(cursorOffset) + } else { + netlog.Debug("[MacGarden][CatSearch] starting new search for %q", normalizedQuery) + } + + // Determine which page startIdx falls on and skip to the right entry within it. + firstPage := startIdx / macGardenSearchPageSize + skipInFirst := startIdx % macGardenSearchPageSize + + type hit struct { + result garden.SearchResult + name string + } + hits := make([]hit, 0, limit) + exhausted := false + + for pageNum := firstPage; len(hits) < limit; pageNum++ { + m.ensureSearchPage(normalizedQuery, pageNum) + + m.mu.RLock() + cache := m.catSearchCache[normalizedQuery] + var page []garden.SearchResult + if cache != nil { + page = cache.pages[pageNum] + exhausted = cache.exhausted + } + m.mu.RUnlock() + + if len(page) == 0 { + break + } + + skip := 0 + if pageNum == firstPage { + skip = skipInFirst + } + for i := skip; i < len(page) && len(hits) < limit; i++ { + name := sanitizeGardenName(page[i].Name) + if name != "" { + hits = append(hits, hit{result: page[i], name: name}) + } + } + + if len(page) < macGardenSearchPageSize || exhausted { + break + } + } + + netlog.Debug("[MacGarden][CatSearch] query=%q startIdx=%d firstPage=%d skip=%d returned=%d exhausted=%v", + normalizedQuery, startIdx, firstPage, skipInFirst, len(hits), exhausted) + + paths := make([]string, 0, len(hits)) + m.mu.Lock() + for _, h := range hits { + dir := h.name + if h.result.Type != "" { + dir = filepath.Join(h.result.Type, h.name) + } + paths = append(paths, filepath.Join(m.root, "search", normalizedQuery, dir)) + m.searchByName[h.name] = macGardenCachedResult{Name: h.result.Name, URL: h.result.URL} + m.itemURLByDir[h.name] = h.result.URL + } + m.mu.Unlock() + + moreAvailable := len(hits) == limit || !exhausted + + nextCursor := [16]byte{} + nextCursor[1] = byte((queryHash >> 16) & 0xFF) + nextCursor[2] = byte((queryHash >> 8) & 0xFF) + nextCursor[3] = byte(queryHash & 0xFF) + if moreAvailable { + nextCursor[0] = 0x01 + nextOffset := uint32(startIdx + len(hits)) + nextCursor[4] = byte((nextOffset >> 24) & 0xFF) + nextCursor[5] = byte((nextOffset >> 16) & 0xFF) + nextCursor[6] = byte((nextOffset >> 8) & 0xFF) + nextCursor[7] = byte(nextOffset & 0xFF) + } + + return paths, nextCursor, NoErr +} + +// ensureSearchPage fetches a single MacGarden search page into the cache if it +// is not already there. Marks the cache exhausted when the page is partial +// (fewer than macGardenSearchPageSize items) or returns an error. +func (m *MacGardenFileSystem) ensureSearchPage(normalizedQuery string, pageNum int) { + m.mu.RLock() + cache, ok := m.catSearchCache[normalizedQuery] + if ok { + if _, cached := cache.pages[pageNum]; cached { + m.mu.RUnlock() + return + } + if cache.exhausted { + m.mu.RUnlock() + return + } + } + m.mu.RUnlock() + + netlog.Debug("[MacGarden][CatSearch] fetching search page %d for %q", pageNum, normalizedQuery) + pageResults, err := m.client.GetSearchPage(normalizedQuery, pageNum) + + m.mu.Lock() + cache, ok = m.catSearchCache[normalizedQuery] + if !ok { + cache = &macGardenSearchCache{pages: make(map[int][]garden.SearchResult)} + } + if _, alreadyCached := cache.pages[pageNum]; !alreadyCached { + if err != nil { + netlog.Warn("[MacGarden][CatSearch] page %d fetch failed for %q: %v", pageNum, normalizedQuery, err) + cache.exhausted = true + } else { + cache.pages[pageNum] = pageResults + if len(pageResults) < macGardenSearchPageSize { + netlog.Debug("[MacGarden][CatSearch] page %d: %d results for %q (last page)", pageNum, len(pageResults), normalizedQuery) + cache.exhausted = true + } else { + netlog.Debug("[MacGarden][CatSearch] page %d: %d results for %q", pageNum, len(pageResults), normalizedQuery) + } + } + m.catSearchCache[normalizedQuery] = cache + } + m.mu.Unlock() +} + +func normalizeMacGardenSearchQuery(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + lower := strings.ToLower(s) + for _, marker := range []string{" type:app,game", " type:app", " type:game", "type:app,game", "type:app", "type:game"} { + if idx := strings.Index(lower, marker); idx >= 0 { + s = s[:idx] + lower = strings.ToLower(s) + } + } + quoted := extractQuotedSegments(s) + if len(quoted) > 0 { + best := "" + bestScore := -1 + for _, q := range quoted { + cand := cleanMacGardenCandidate(q) + score := 0 + for _, r := range cand { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + score++ + } + } + if score > bestScore { + bestScore = score + best = cand + } + } + if best != "" { + return best + } + } + return cleanMacGardenCandidate(s) +} + +func mirrorFolderForURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "mirror-unknown" + } + switch strings.ToLower(u.Host) { + case "old.mac.gdn": + return "mirror-old" + case "download.macintoshgarden.org": + return "mirror-download" + default: + return "mirror-unknown" + } +} + +func buildItemDirEntries(assets []macGardenAsset, subPath string) []fs.DirEntry { + subPath = strings.Trim(strings.ReplaceAll(subPath, "\\", "/"), "/") + dirSeen := make(map[string]struct{}) + fileSeen := make(map[string]struct{}) + entries := make([]fs.DirEntry, 0, len(assets)) + + for _, a := range assets { + name := strings.Trim(strings.ReplaceAll(a.Name, "\\", "/"), "/") + if name == "" { + continue + } + if subPath != "" { + prefix := subPath + "/" + if !strings.HasPrefix(name, prefix) { + continue + } + name = strings.TrimPrefix(name, prefix) + if name == "" { + continue + } + } + + if idx := strings.Index(name, "/"); idx >= 0 { + dirName := name[:idx] + if dirName == "" { + continue + } + if _, ok := dirSeen[dirName]; ok { + continue + } + dirSeen[dirName] = struct{}{} + entries = append(entries, macGardenDirEntry{info: &macGardenFileInfo{name: dirName, mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}) + continue + } + + if _, ok := fileSeen[name]; ok { + continue + } + fileSeen[name] = struct{}{} + entries = append(entries, macGardenDirEntry{info: &macGardenFileInfo{name: name, size: a.Size, mode: 0o444, modTime: time.Now().UTC()}}) + } + + sort.Slice(entries, func(i, j int) bool { + return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name()) + }) + return entries +} + +func cleanMacGardenCandidate(s string) string { + s = strings.NewReplacer("$", "", "@", " ", "\"", " ").Replace(s) + s = strings.TrimSpace(s) + s = strings.Trim(s, ".,:;()[]{}<>' ") + s = strings.Join(strings.Fields(s), " ") + if s == "" || s == "." { + return "" + } + return s +} + +func extractQuotedSegments(s string) []string { + segments := make([]string, 0, 2) + start := -1 + for i, r := range s { + if r != '"' { + continue + } + if start < 0 { + start = i + 1 + continue + } + if start <= i { + segments = append(segments, s[start:i]) + } + start = -1 + } + return segments +} + +func (m *MacGardenFileSystem) ensureItemForDir(dirName string, fallbackURL string) error { + dirName = strings.TrimSpace(pathBase(dirName)) + if dirName == "" { + return fs.ErrNotExist + } + m.mu.RLock() + itemURL := m.itemURLByDir[dirName] + m.mu.RUnlock() + if itemURL == "" { + itemURL = fallbackURL + } + if itemURL == "" { + return fs.ErrNotExist + } + + m.mu.RLock() + _, ok := m.itemByURL[itemURL] + m.mu.RUnlock() + if ok { + return nil + } + + item, err := m.client.GetSoftwareItem(itemURL) + if err != nil { + return err + } + m.mu.Lock() + m.itemByURL[itemURL] = item + m.itemURLByDir[dirName] = itemURL + m.mu.Unlock() + return nil +} + +func (m *MacGardenFileSystem) itemAssetsByDir(dirName string) ([]macGardenAsset, error) { + dirName = pathBase(dirName) + m.mu.RLock() + itemURL := m.itemURLByDir[dirName] + item := m.itemByURL[itemURL] + m.mu.RUnlock() + if itemURL == "" || item == nil { + return nil, fs.ErrNotExist + } + + netlog.Info("[AFP][MacGarden] building assets for %q: %d screenshot(s), %d download group(s)", dirName, len(item.Screenshots), len(item.Downloads)) + assets := make([]macGardenAsset, 0, len(item.Downloads)+len(item.Screenshots)+2) + txtPath := filepath.Join(dirName, "Description.txt") + htmlPath := filepath.Join(dirName, "Description.html") + descMac := strings.ReplaceAll(item.Description, "\n", "\r") + txtBytes := []byte(descMac) + htmlBytes := []byte("
" + htmlEscape(item.Description) + "
") + assets = append(assets, + macGardenAsset{Name: "Description.txt", Content: txtBytes, Size: int64(len(txtBytes))}, + macGardenAsset{Name: "Description.html", Content: htmlBytes, Size: int64(len(htmlBytes))}, + ) + + m.mu.Lock() + m.descriptionByPath[txtPath] = assets[0] + m.descriptionByPath[htmlPath] = assets[1] + m.mu.Unlock() + + // For each URL use the cached size if available; collect uncached URLs for + // background probing so this function never blocks on network I/O. + var needsProbe []string + + shotIdx := 1 + for _, shotURL := range item.Screenshots { + if !strings.HasPrefix(shotURL, "http://") && !strings.HasPrefix(shotURL, "https://") { + continue + } + name := fmt.Sprintf("Screenshots/Screenshot %02d %s", shotIdx, garden.FileNameFromURL(shotURL, "image")) + size, cached := m.client.CachedContentLength(shotURL) + if !cached { + netlog.Debug("[AFP][MacGarden] screenshot %d/%d not yet cached, will probe in background", shotIdx, len(item.Screenshots)) + needsProbe = append(needsProbe, shotURL) + } else { + netlog.Debug("[AFP][MacGarden] screenshot %d size: %d bytes (cached)", shotIdx, size) + } + asset := macGardenAsset{Name: name, URL: shotURL, Size: size} + assets = append(assets, asset) + m.mu.Lock() + m.screenshotByPath[filepath.Join(dirName, name)] = asset + m.mu.Unlock() + shotIdx++ + } + + for _, dl := range item.Downloads { + for _, link := range dl.Links { + if !strings.HasPrefix(link.URL, "http://") && !strings.HasPrefix(link.URL, "https://") { + continue + } + // Skip MD5 checksum links — they are not downloadable files. + if strings.Contains(link.URL, "arch_md5.php") { + continue + } + base := garden.FileNameFromURL(link.URL, dl.Title) + if base == "" { + base = sanitizeGardenName(dl.Title) + } + name := mirrorFolderForURL(link.URL) + "/" + base + size, cached := m.client.CachedContentLength(link.URL) + if !cached { + netlog.Debug("[AFP][MacGarden] download %q not yet cached, will probe in background", dl.Title) + needsProbe = append(needsProbe, link.URL) + } else { + netlog.Debug("[AFP][MacGarden] download %q size: %d bytes (cached)", dl.Title, size) + } + asset := macGardenAsset{Name: name, URL: link.URL, Size: size} + assets = append(assets, asset) + m.mu.Lock() + m.downloadByPath[filepath.Join(dirName, name)] = asset + m.mu.Unlock() + } + } + + if len(needsProbe) > 0 && m.client.FetchHead() { + netlog.Info("[AFP][MacGarden] probing %d uncached asset size(s) for %q in background", len(needsProbe), dirName) + urls := needsProbe + m.wg.Add(1) + go func() { + defer m.wg.Done() + for _, u := range urls { + select { + case <-m.stop: + return + default: + } + if _, err := m.client.HeadContentLength(u); err != nil { + netlog.Warn("[AFP][MacGarden] background probe failed for %q: %v", u, err) + } + } + netlog.Info("[AFP][MacGarden] background probe complete for %q", dirName) + }() + } + + netlog.Info("[AFP][MacGarden] built %d asset(s) for %q", len(assets), dirName) + return assets, nil +} + +func (m *MacGardenFileSystem) categoryByName(name string) (garden.Category, bool) { + for _, c := range m.categories { + if c.Name == name { + return c, true + } + } + return garden.Category{}, false +} + +func (m *MacGardenFileSystem) getCategoryURL(catName string) string { + m.loadCategories() + m.mu.RLock() + defer m.mu.RUnlock() + for _, c := range m.categories { + if c.Name == catName { + return c.URL + } + } + return "" +} + +func (m *MacGardenFileSystem) getCategoryPageMeta(catURL string) (macGardenCategoryPageMeta, error) { + m.mu.RLock() + if meta, ok := m.categoryPageMeta[catURL]; ok { + m.mu.RUnlock() + return meta, nil + } + m.mu.RUnlock() + + info, err := m.client.GetCategoryPageInfo(catURL) + if err != nil { + return macGardenCategoryPageMeta{}, err + } + meta := macGardenCategoryPageMeta{ + TotalCount: clampGardenCount(info.TotalCount), + PageSize: info.PageSize, + LastPageNumber: info.LastPageNumber, + LastPageCount: info.LastPageCount, + } + m.mu.Lock() + m.categoryPageMeta[catURL] = meta + m.categoryItemCount[catURL] = meta.TotalCount + m.cacheCategoryPageLocked(catURL, 0, info.FirstPage) + if info.LastPageNumber > 0 { + m.cacheCategoryPageLocked(catURL, info.LastPageNumber, info.LastPage) + } + m.mu.Unlock() + return meta, nil +} + +func (m *MacGardenFileSystem) getCategoryPage(catURL string, pageNumber int) ([]garden.SearchResult, error) { + m.mu.RLock() + if pages, ok := m.categoryPageItems[catURL]; ok { + if items, ok := pages[pageNumber]; ok { + cached := append([]garden.SearchResult(nil), items...) + m.mu.RUnlock() + return cached, nil + } + } + m.mu.RUnlock() + + items, err := m.client.GetCategoryPage(catURL, pageNumber) + if err != nil { + return nil, err + } + m.mu.Lock() + m.cacheCategoryPageLocked(catURL, pageNumber, items) + m.mu.Unlock() + return append([]garden.SearchResult(nil), items...), nil +} + +func (m *MacGardenFileSystem) cacheCategoryPageLocked(catURL string, pageNumber int, items []garden.SearchResult) { + if _, ok := m.categoryPageItems[catURL]; !ok { + m.categoryPageItems[catURL] = make(map[int][]garden.SearchResult) + } + cloned := append([]garden.SearchResult(nil), items...) + m.categoryPageItems[catURL][pageNumber] = cloned + for _, item := range cloned { + name := sanitizeGardenName(item.Name) + if name == "" { + continue + } + m.itemURLByDir[name] = item.URL + } +} + +func (m *MacGardenFileSystem) readCategoryDirRange(catURL string, startIndex uint16, reqCount uint16) ([]fs.DirEntry, uint16, error) { + if reqCount > macGardenEnumerateWindow { + reqCount = macGardenEnumerateWindow + } + meta, err := m.getCategoryPageMeta(catURL) + if err != nil { + return nil, 0, err + } + total := meta.TotalCount + if total == 0 { + return nil, 0, nil + } + if startIndex < 1 { + startIndex = 1 + } + if startIndex > total { + return nil, total, nil + } + if reqCount == 0 { + return nil, total, nil + } + pageSize := meta.PageSize + if pageSize <= 0 { + return nil, total, nil + } + startOffset := int(startIndex) - 1 + endOffset := startOffset + int(reqCount) + if endOffset > int(total) { + endOffset = int(total) + } + firstPage := startOffset / pageSize + lastPage := (endOffset - 1) / pageSize + results := make([]garden.SearchResult, 0, endOffset-startOffset) + for pageNumber := firstPage; pageNumber <= lastPage; pageNumber++ { + items, err := m.getCategoryPage(catURL, pageNumber) + if err != nil { + return nil, total, err + } + pageStart := 0 + if pageNumber == firstPage { + pageStart = startOffset - pageNumber*pageSize + } + pageEnd := len(items) + if pageNumber == lastPage { + pageLimit := endOffset - pageNumber*pageSize + if pageLimit < pageEnd { + pageEnd = pageLimit + } + } + if pageStart < 0 { + pageStart = 0 + } + if pageStart > len(items) { + pageStart = len(items) + } + if pageEnd < pageStart { + pageEnd = pageStart + } + results = append(results, items[pageStart:pageEnd]...) + } + entries := make([]fs.DirEntry, 0, len(results)) + for _, item := range results { + entries = append(entries, macGardenDirEntry{info: &macGardenFileInfo{name: sanitizeGardenName(item.Name), mode: fs.ModeDir | 0o555, isDir: true, modTime: time.Now().UTC()}}) + } + return entries, total, nil +} + +func (m *MacGardenFileSystem) getCategoryItems(catURL string) ([]garden.SearchResult, error) { + netlog.Debug("[AFP][MacGarden] getCategoryItems for URL: %s", catURL) + m.mu.RLock() + if items, ok := m.itemsInCategory[catURL]; ok { + m.mu.RUnlock() + netlog.Debug("[AFP][MacGarden] getCategoryItems found %d cached items for %s", len(items), catURL) + return items, nil + } + m.mu.RUnlock() + + meta, err := m.getCategoryPageMeta(catURL) + if err != nil { + netlog.Warn("[AFP][MacGarden] failed to fetch category page metadata: %v", err) + return nil, err + } + + netlog.Debug("[AFP][MacGarden] fetching all pages for category URL: %s", catURL) + items := make([]garden.SearchResult, 0, int(meta.TotalCount)) + for pageNumber := 0; pageNumber <= meta.LastPageNumber; pageNumber++ { + pageItems, err := m.getCategoryPage(catURL, pageNumber) + if err != nil { + netlog.Warn("[AFP][MacGarden] failed to fetch category page %d: %v", pageNumber, err) + return nil, err + } + items = append(items, pageItems...) + } + + netlog.Info("[AFP][MacGarden] got %d items from category %s", len(items), catURL) + m.mu.Lock() + m.itemsInCategory[catURL] = items + m.categoryItemCount[catURL] = clampGardenCount(len(items)) + m.mu.Unlock() + return items, nil +} + +func clampGardenCount(count int) uint16 { + if count <= 0 { + return 0 + } + if count > 0xffff { + return 0xffff + } + return uint16(count) +} + +func (m *MacGardenFileSystem) countCategoriesWithPrefix(prefix string) uint16 { + m.mu.RLock() + defer m.mu.RUnlock() + count := uint16(0) + for _, cat := range m.categories { + if strings.HasPrefix(strings.ToLower(urlPathFromAbsolute(cat.URL)), prefix) { + count++ + } + } + return count +} + +func (m *MacGardenFileSystem) getItemURLInCategory(catURL string, itemName string) (string, error) { + // Fast path: if the item URL is already cached from prior ranged enumeration, + // avoid forcing a full category crawl. + m.mu.RLock() + if cachedURL := m.itemURLByDir[itemName]; cachedURL != "" { + m.mu.RUnlock() + return cachedURL, nil + } + if cachedItems, ok := m.itemsInCategory[catURL]; ok { + for _, item := range cachedItems { + if sanitizeGardenName(item.Name) == itemName { + m.mu.RUnlock() + return item.URL, nil + } + } + } + if cachedPages, ok := m.categoryPageItems[catURL]; ok { + for _, pageItems := range cachedPages { + for _, item := range pageItems { + if sanitizeGardenName(item.Name) == itemName { + m.mu.RUnlock() + return item.URL, nil + } + } + } + } + m.mu.RUnlock() + + meta, err := m.getCategoryPageMeta(catURL) + if err != nil { + return "", err + } + + for pageNumber := 0; pageNumber <= meta.LastPageNumber; pageNumber++ { + pageItems, err := m.getCategoryPage(catURL, pageNumber) + if err != nil { + return "", err + } + for _, item := range pageItems { + if sanitizeGardenName(item.Name) == itemName { + return item.URL, nil + } + } + } + return "", fs.ErrNotExist +} + +func isSearchResultType(s string) bool { return s == "App" || s == "Game" } + +func sanitizeGardenName(s string) string { + s = strings.TrimSpace(s) + replacer := strings.NewReplacer( + "\\", "_", + "/", "_", + ":", "-", + "*", "_", + "?", "", + "\"", "", + "<", "(", + ">", ")", + "|", "_", + ) + s = replacer.Replace(s) + if s == "" { + return "Item" + } + return s +} + +func htmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +func pathBase(s string) string { + s = filepath.ToSlash(s) + parts := strings.Split(s, "/") + return parts[len(parts)-1] +} + +func pathDir(s string) string { + s = filepath.ToSlash(s) + idx := strings.LastIndex(s, "/") + if idx < 0 { + return "" + } + return s[:idx] +} + +func urlPathFromAbsolute(absURL string) string { + u, err := url.Parse(absURL) + if err != nil { + return "" + } + return u.Path +} + +var _ FileSystem = (*MacGardenFileSystem)(nil) + +var errMacGardenNotFound = errors.New("macgarden: not found") diff --git a/service/afp/metadata.go b/service/afp/metadata.go index ee0c883..cdda9f9 100644 --- a/service/afp/metadata.go +++ b/service/afp/metadata.go @@ -8,8 +8,8 @@ import ( "path/filepath" "strings" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/cnid" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/cnid" ) // AppleDouble sidecar / hidden-name / icon canonicalisation helpers. diff --git a/service/afp/metrics.go b/service/afp/metrics.go index c2f119d..12cbce6 100644 --- a/service/afp/metrics.go +++ b/service/afp/metrics.go @@ -2,6 +2,6 @@ package afp -import "github.com/pgodw/omnitalk/pkg/telemetry" +import "github.com/ObsoleteMadness/ClassicStack/pkg/telemetry" -var afpCommandsTotal = telemetry.NewCounter("omnitalk_afp_commands_total") +var afpCommandsTotal = telemetry.NewCounter("classicstack_afp_commands_total") diff --git a/service/afp/pascal_string.go b/service/afp/pascal_string.go index 33a0418..2275065 100644 --- a/service/afp/pascal_string.go +++ b/service/afp/pascal_string.go @@ -2,7 +2,7 @@ package afp -import "github.com/pgodw/omnitalk/pkg/encoding" +import "github.com/ObsoleteMadness/ClassicStack/pkg/encoding" // ReadPascalString reads a length-prefixed MacRoman string at idx and returns UTF-8 text plus bytes consumed. func ReadPascalString(data []byte, idx int) (string, int) { diff --git a/service/afp/path_codec.go b/service/afp/path_codec.go index eb090a0..bbfb68d 100644 --- a/service/afp/path_codec.go +++ b/service/afp/path_codec.go @@ -9,7 +9,7 @@ import ( "strings" "unicode/utf8" - "github.com/pgodw/omnitalk/pkg/encoding" + "github.com/ObsoleteMadness/ClassicStack/pkg/encoding" ) // AFPOptions controls AFP filename/path translation behavior. diff --git a/service/afp/paths.go b/service/afp/paths.go index 43a95c8..dfb9632 100644 --- a/service/afp/paths.go +++ b/service/afp/paths.go @@ -3,9 +3,10 @@ package afp import ( - "github.com/pgodw/omnitalk/netlog" "path/filepath" "strings" + + "github.com/ObsoleteMadness/ClassicStack/netlog" ) // CNID-backed path/DID resolution and AFP path-string parsing. The diff --git a/service/afp/server.go b/service/afp/server.go index ec2832f..43b1ca4 100644 --- a/service/afp/server.go +++ b/service/afp/server.go @@ -17,9 +17,9 @@ import ( "fmt" "sync" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/protocol/ddp" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/service" ) // Service implements AppleTalk Filing Protocol. @@ -109,7 +109,6 @@ func NewService(serverName string, configs []VolumeConfig, fs FileSystem, transp return s } - // Start initializes all underlying transports and resolves the read-size cap // from whichever transport advertises the smallest non-zero quantum. func (s *Service) Start(ctx context.Context, router service.Router) error { @@ -182,5 +181,3 @@ func (s *Service) Inbound(d ddp.Datagram, p port.Port) { func (s *Service) GetStatus() []byte { return BuildServerInfo(s.ServerName) } - - diff --git a/service/afp/server_calls.go b/service/afp/server_calls.go index 9d9a3d4..ab1d52a 100644 --- a/service/afp/server_calls.go +++ b/service/afp/server_calls.go @@ -3,8 +3,9 @@ package afp import ( - "github.com/pgodw/omnitalk/netlog" "time" + + "github.com/ObsoleteMadness/ClassicStack/netlog" ) func (s *Service) handleGetSrvrInfo(req *FPGetSrvrInfoReq) (*FPGetSrvrInfoRes, error) { diff --git a/service/afp/server_models.go b/service/afp/server_models.go index 9ab6b3e..7573275 100644 --- a/service/afp/server_models.go +++ b/service/afp/server_models.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) // FPGetSrvrInfoReq - request to obtain a block of descriptive information diff --git a/service/afp/server_models_golden_test.go b/service/afp/server_models_golden_test.go index e3bac0e..243043c 100644 --- a/service/afp/server_models_golden_test.go +++ b/service/afp/server_models_golden_test.go @@ -71,7 +71,7 @@ func TestFPMapNameRes_MarshalGolden(t *testing.T) { // TestFPGetSrvrMsgRes_MarshalGolden pins the wire-format output. func TestFPGetSrvrMsgRes_MarshalGolden(t *testing.T) { t.Parallel() - res := &FPGetSrvrMsgRes{MessageType: 1, Bitmap: 3, Message: "Welcome to OmniTalk"} + res := &FPGetSrvrMsgRes{MessageType: 1, Bitmap: 3, Message: "Welcome to ClassicStack"} got := res.Marshal() want := goldenBytes(t, "fpgetsrvrmsgres_basic.hex", got) if !bytes.Equal(got, want) { @@ -143,7 +143,7 @@ func TestFPLoginRes_MarshalGolden(t *testing.T) { func TestFPGetSrvrInfoRes_MarshalGolden(t *testing.T) { t.Parallel() res := &FPGetSrvrInfoRes{ - MachineType: "OmniTalk", + MachineType: "ClassicStack", AFPVersions: []string{"AFPVersion 1.1", "AFPVersion 2.0", "AFPVersion 2.1"}, UAMs: []string{"No User Authent", "Cleartxt Passwrd"}, ServerName: "Test Server", diff --git a/service/afp/transport.go b/service/afp/transport.go index 5da5895..eaf8681 100644 --- a/service/afp/transport.go +++ b/service/afp/transport.go @@ -5,10 +5,10 @@ package afp import ( "context" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" ) // CommandHandler handles decoded AFP commands from transport protocols. diff --git a/service/afp/volume.go b/service/afp/volume.go index aec8658..bd6766c 100644 --- a/service/afp/volume.go +++ b/service/afp/volume.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) const ( @@ -92,7 +92,7 @@ func constrainAFPVolumeType(volType uint16) uint16 { } func (s *Service) volumeType(_ *Volume) uint16 { - // OmniTalk exposes hierarchical volumes with CNID-based directory IDs, + // ClassicStack exposes hierarchical volumes with CNID-based directory IDs, // so we advertise Variable Directory ID semantics. return constrainAFPVolumeType(AFPVolumeTypeFixedDirID) } diff --git a/service/afp/volume_models.go b/service/afp/volume_models.go index c558945..91787ba 100644 --- a/service/afp/volume_models.go +++ b/service/afp/volume_models.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/pgodw/omnitalk/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" ) func formatVolBitmap(bitmap uint16) string { diff --git a/service/afpfs/macgarden/fs.go b/service/afpfs/macgarden/fs.go index 42bd234..efc6215 100644 --- a/service/afpfs/macgarden/fs.go +++ b/service/afpfs/macgarden/fs.go @@ -25,9 +25,9 @@ import ( "time" "unicode" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/service/afp" - garden "github.com/pgodw/omnitalk/service/macgarden" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/service/afp" + garden "github.com/ObsoleteMadness/ClassicStack/service/macgarden" ) const macGardenEnumerateWindow = 10 @@ -976,10 +976,10 @@ func (m *MacGardenFileSystem) Close() error { return nil } -func (m *MacGardenFileSystem) CreateDir(_ string) error { return fs.ErrPermission } +func (m *MacGardenFileSystem) CreateDir(_ string) error { return fs.ErrPermission } func (m *MacGardenFileSystem) CreateFile(_ string) (afp.File, error) { return nil, fs.ErrPermission } -func (m *MacGardenFileSystem) Remove(_ string) error { return fs.ErrPermission } -func (m *MacGardenFileSystem) Rename(_, _ string) error { return fs.ErrPermission } +func (m *MacGardenFileSystem) Remove(_ string) error { return fs.ErrPermission } +func (m *MacGardenFileSystem) Rename(_, _ string) error { return fs.ErrPermission } // openAsset wraps an asset in a macGardenFile, populating Content from the // in-memory screenshot cache when the image has already been downloaded. diff --git a/service/afpfs/macgarden/fs_test.go b/service/afpfs/macgarden/fs_test.go index 9ea0b21..faad7fc 100644 --- a/service/afpfs/macgarden/fs_test.go +++ b/service/afpfs/macgarden/fs_test.go @@ -7,8 +7,8 @@ import ( "path/filepath" "testing" - "github.com/pgodw/omnitalk/service/afp" - garden "github.com/pgodw/omnitalk/service/macgarden" + "github.com/ObsoleteMadness/ClassicStack/service/afp" + garden "github.com/ObsoleteMadness/ClassicStack/service/macgarden" ) func TestMacGardenChildCount_CategoryIsLazyUntilCached(t *testing.T) { diff --git a/service/asp/asp.go b/service/asp/asp.go index 1c1fbc9..88f1f92 100644 --- a/service/asp/asp.go +++ b/service/asp/asp.go @@ -1,7 +1,7 @@ //go:build afp || all /* -Package asp implements the AppleTalk Session Protocol (ASP) as a omnitalk +Package asp implements the AppleTalk Session Protocol (ASP) as a classicstack service. The ATP transaction layer is provided by go/service/atp; this file is concerned only with ASP semantics — session lifecycle, command/write dispatch, tickle keep-alives, attentions — and delegates all retry, XO @@ -18,14 +18,14 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/service" - "github.com/pgodw/omnitalk/service/afp" - "github.com/pgodw/omnitalk/service/atp" - "github.com/pgodw/omnitalk/service/zip" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" + "github.com/ObsoleteMadness/ClassicStack/service/afp" + "github.com/ObsoleteMadness/ClassicStack/service/atp" + "github.com/ObsoleteMadness/ClassicStack/service/zip" ) // ServerSocket is the well-known AppleTalk socket for the AFP/ASP server. diff --git a/service/asp/asp_test.go b/service/asp/asp_test.go index 3c9ddc9..a8bb8ca 100644 --- a/service/asp/asp_test.go +++ b/service/asp/asp_test.go @@ -6,7 +6,7 @@ import ( "encoding/binary" "testing" - "github.com/pgodw/omnitalk/service/atp" + "github.com/ObsoleteMadness/ClassicStack/service/atp" ) type stubCommandHandler struct { diff --git a/service/asp/session.go b/service/asp/session.go index c1265ea..5f72b6c 100644 --- a/service/asp/session.go +++ b/service/asp/session.go @@ -16,8 +16,8 @@ import ( "sync/atomic" "time" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/service/atp" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/service/atp" ) // sessionState names the lifecycle of an ASP session. Legal transitions: diff --git a/service/asp/types.go b/service/asp/types.go index a03b255..96f523e 100644 --- a/service/asp/types.go +++ b/service/asp/types.go @@ -3,7 +3,7 @@ package asp import ( - pasp "github.com/pgodw/omnitalk/protocol/asp" + pasp "github.com/ObsoleteMadness/ClassicStack/protocol/asp" ) // SPFunction codes. @@ -66,10 +66,10 @@ type ( // Parse helpers. var ( - ParseOpenSessPacket = pasp.ParseOpenSessPacket - ParseCloseSessPacket = pasp.ParseCloseSessPacket - ParseGetStatusPacket = pasp.ParseGetStatusPacket - ParseCommandPacket = pasp.ParseCommandPacket - ParseWritePacket = pasp.ParseWritePacket - CloseSessReplyUserData = pasp.CloseSessReplyUserData + ParseOpenSessPacket = pasp.ParseOpenSessPacket + ParseCloseSessPacket = pasp.ParseCloseSessPacket + ParseGetStatusPacket = pasp.ParseGetStatusPacket + ParseCommandPacket = pasp.ParseCommandPacket + ParseWritePacket = pasp.ParseWritePacket + CloseSessReplyUserData = pasp.CloseSessReplyUserData ) diff --git a/service/atp/transaction.go b/service/atp/transaction.go index 5324382..772afd9 100644 --- a/service/atp/transaction.go +++ b/service/atp/transaction.go @@ -17,8 +17,8 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/netlog" - patp "github.com/pgodw/omnitalk/protocol/atp" + "github.com/ObsoleteMadness/ClassicStack/netlog" + patp "github.com/ObsoleteMadness/ClassicStack/protocol/atp" ) // ----- Address / Sender / Clock ------------------------------------------- diff --git a/service/atp/wire.go b/service/atp/wire.go index 5832a0c..ab889af 100644 --- a/service/atp/wire.go +++ b/service/atp/wire.go @@ -7,7 +7,7 @@ package atp import ( - patp "github.com/pgodw/omnitalk/protocol/atp" + patp "github.com/ObsoleteMadness/ClassicStack/protocol/atp" ) // Header type. diff --git a/service/dsi/dsi.go b/service/dsi/dsi.go index 2c92cac..8b3b1c0 100644 --- a/service/dsi/dsi.go +++ b/service/dsi/dsi.go @@ -17,13 +17,13 @@ import ( "net" "sync" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/binutil" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/service" - "github.com/pgodw/omnitalk/service/afp" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/binutil" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" + "github.com/ObsoleteMadness/ClassicStack/service/afp" ) // DSI Command Codes diff --git a/service/llap/llap.go b/service/llap/llap.go index 617d2be..ad0b6b3 100644 --- a/service/llap/llap.go +++ b/service/llap/llap.go @@ -8,12 +8,12 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/port/localtalk" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/port/localtalk" + "github.com/ObsoleteMadness/ClassicStack/service" ) const ( diff --git a/service/llap/llap_test.go b/service/llap/llap_test.go index 137fd35..7a974d2 100644 --- a/service/llap/llap_test.go +++ b/service/llap/llap_test.go @@ -7,10 +7,10 @@ import ( "testing" "time" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port/localtalk" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port/localtalk" ) func TestDirectedTransmitLogsRetryAndBackoff(t *testing.T) { diff --git a/service/macgarden/client.go b/service/macgarden/client.go index d59fade..17d6ffb 100644 --- a/service/macgarden/client.go +++ b/service/macgarden/client.go @@ -21,8 +21,8 @@ import ( "sync" "time" + "github.com/ObsoleteMadness/ClassicStack/netlog" "github.com/PuerkitoBio/goquery" - "github.com/pgodw/omnitalk/netlog" ) const ( diff --git a/service/macgarden/client_test.go b/service/macgarden/client_test.go index 9f46862..b502d0a 100644 --- a/service/macgarden/client_test.go +++ b/service/macgarden/client_test.go @@ -17,11 +17,11 @@ import ( ) // requireLiveTests skips tests that reach the public Macintosh Garden site -// unless OMNITALK_LIVE_TESTS=1 is set. CI runners do not run these. +// unless CLASSICSTACK_LIVE_TESTS=1 is set. CI runners do not run these. func requireLiveTests(t *testing.T) { t.Helper() - if os.Getenv("OMNITALK_LIVE_TESTS") != "1" { - t.Skip("skipping live macintoshgarden.org test; set OMNITALK_LIVE_TESTS=1 to enable") + if os.Getenv("CLASSICSTACK_LIVE_TESTS") != "1" { + t.Skip("skipping live macintoshgarden.org test; set CLASSICSTACK_LIVE_TESTS=1 to enable") } } diff --git a/service/macip/dhcp_client.go b/service/macip/dhcp_client.go index c38e8c8..5e3965d 100644 --- a/service/macip/dhcp_client.go +++ b/service/macip/dhcp_client.go @@ -14,9 +14,9 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/pkg/hwaddr" - "github.com/pgodw/omnitalk/port/nat" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" + "github.com/ObsoleteMadness/ClassicStack/port/nat" ) const ( diff --git a/service/macip/etherlink.go b/service/macip/etherlink.go index fd2c6c4..7d79ed3 100644 --- a/service/macip/etherlink.go +++ b/service/macip/etherlink.go @@ -10,8 +10,8 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port/rawlink" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" ) const ( diff --git a/service/macip/macip.go b/service/macip/macip.go index fac9b1d..da0accb 100644 --- a/service/macip/macip.go +++ b/service/macip/macip.go @@ -18,14 +18,14 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/protocol/ddp" - - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/port/nat" - "github.com/pgodw/omnitalk/port/rawlink" - "github.com/pgodw/omnitalk/service" - "github.com/pgodw/omnitalk/service/zip" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/port/nat" + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" + "github.com/ObsoleteMadness/ClassicStack/service" + "github.com/ObsoleteMadness/ClassicStack/service/zip" ) const ( diff --git a/service/macip/pool.go b/service/macip/pool.go index 5e4480b..d8134f3 100644 --- a/service/macip/pool.go +++ b/service/macip/pool.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/netlog" + "github.com/ObsoleteMadness/ClassicStack/netlog" ) const leaseDuration = 5 * time.Minute diff --git a/service/macip/state.go b/service/macip/state.go index 7313835..28baa9e 100644 --- a/service/macip/state.go +++ b/service/macip/state.go @@ -8,7 +8,7 @@ import ( "os" "time" - "github.com/pgodw/omnitalk/netlog" + "github.com/ObsoleteMadness/ClassicStack/netlog" ) type savedLease struct { diff --git a/service/rtmp/responding.go b/service/rtmp/responding.go index ed21c44..80cbd7b 100644 --- a/service/rtmp/responding.go +++ b/service/rtmp/responding.go @@ -5,10 +5,10 @@ import ( "encoding/binary" "sync" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" ) type RespondingService struct { diff --git a/service/rtmp/routing_table_aging.go b/service/rtmp/routing_table_aging.go index 271e401..6800b35 100644 --- a/service/rtmp/routing_table_aging.go +++ b/service/rtmp/routing_table_aging.go @@ -5,10 +5,10 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" ) type RoutingTableAgingService struct { diff --git a/service/rtmp/rtmp.go b/service/rtmp/rtmp.go index efcf724..1d2cba8 100644 --- a/service/rtmp/rtmp.go +++ b/service/rtmp/rtmp.go @@ -3,10 +3,10 @@ package rtmp import ( "encoding/binary" - "github.com/pgodw/omnitalk/protocol/ddp" - prtmp "github.com/pgodw/omnitalk/protocol/rtmp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + prtmp "github.com/ObsoleteMadness/ClassicStack/protocol/rtmp" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/service" ) // Wire constants re-exported from protocol/rtmp. diff --git a/service/rtmp/sending.go b/service/rtmp/sending.go index 87f1628..9a4be4c 100644 --- a/service/rtmp/sending.go +++ b/service/rtmp/sending.go @@ -5,10 +5,10 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" ) type SendingService struct { diff --git a/service/service.go b/service/service.go index b0306aa..5936b44 100644 --- a/service/service.go +++ b/service/service.go @@ -3,9 +3,9 @@ package service import ( "context" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/port" + "github.com/ObsoleteMadness/ClassicStack/port" ) // Service is the contract every service registered with the router diff --git a/service/zip/mock_test.go b/service/zip/mock_test.go index cc19368..f2116d6 100644 --- a/service/zip/mock_test.go +++ b/service/zip/mock_test.go @@ -1,6 +1,6 @@ package zip -import "github.com/pgodw/omnitalk/internal/testutil" +import "github.com/ObsoleteMadness/ClassicStack/internal/testutil" // Package-local aliases that let existing tests keep using the lowercase // names. The real mocks live in internal/testutil so any future package diff --git a/service/zip/name_information.go b/service/zip/name_information.go index 51b85b2..b4022e4 100644 --- a/service/zip/name_information.go +++ b/service/zip/name_information.go @@ -5,12 +5,12 @@ import ( "context" "sync" - "github.com/pgodw/omnitalk/protocol/ddp" - "github.com/pgodw/omnitalk/protocol/nbp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/nbp" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" ) // NBP wire-format constants are re-exported from protocol/nbp so the diff --git a/service/zip/name_information_test.go b/service/zip/name_information_test.go index 4490fe9..6df16a2 100644 --- a/service/zip/name_information_test.go +++ b/service/zip/name_information_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/pgodw/omnitalk/internal/testutil" - "github.com/pgodw/omnitalk/protocol/ddp" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/internal/testutil" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/service" ) func newMockPort(network uint16, node uint8, shortString string, isExtended bool) *mockPort { diff --git a/service/zip/responding.go b/service/zip/responding.go index 03513a7..7c7ceaa 100644 --- a/service/zip/responding.go +++ b/service/zip/responding.go @@ -6,12 +6,12 @@ import ( "encoding/binary" "sync" - "github.com/pgodw/omnitalk/pkg/encoding" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/pkg/encoding" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/netlog" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" ) type RespondingService struct { diff --git a/service/zip/sending.go b/service/zip/sending.go index 542f32a..8edffd9 100644 --- a/service/zip/sending.go +++ b/service/zip/sending.go @@ -5,10 +5,10 @@ import ( "sync" "time" - "github.com/pgodw/omnitalk/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" - "github.com/pgodw/omnitalk/port" - "github.com/pgodw/omnitalk/service" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" ) type SendingService struct { diff --git a/service/zip/zip.go b/service/zip/zip.go index 5bc1787..81a553d 100644 --- a/service/zip/zip.go +++ b/service/zip/zip.go @@ -1,6 +1,6 @@ package zip -import pzip "github.com/pgodw/omnitalk/protocol/zip" +import pzip "github.com/ObsoleteMadness/ClassicStack/protocol/zip" // Wire constants re-exported from protocol/zip. const ( diff --git a/spec/00-overview.md b/spec/00-overview.md index 6f5c012..bbabe4d 100644 --- a/spec/00-overview.md +++ b/spec/00-overview.md @@ -1,4 +1,4 @@ -# OmniTalk Service Specifications — Overview +# ClassicStack Service Specifications — Overview This directory contains implementation-level specifications for each service in the OmniRouter AppleTalk router. These documents are intended to provide sufficient detail for an independent implementor to create a conformant implementation.