From 4440fdc294f70a10a34dab2bcfee0acde5d5139d Mon Sep 17 00:00:00 2001 From: pgodwin Date: Fri, 1 May 2026 16:17:06 +1000 Subject: [PATCH] Rename to ClassicStack. --- .github/workflows/pr-ci.yml | 10 +- .github/workflows/release-main.yml | 36 +- .gitignore | 4 +- ARCHITECTURE.md | 30 +- CLAUDE.md | 14 +- Makefile | 4 +- README.md | 44 +- .../afp_disabled.go | 4 +- cmd/{omnitalk => classicstack}/afp_enabled.go | 12 +- cmd/{omnitalk => classicstack}/afp_hook.go | 6 +- .../config_afp_test.go | 6 +- .../config_flags.go | 4 +- cmd/{omnitalk => classicstack}/config_ini.go | 6 +- cmd/{omnitalk => classicstack}/config_test.go | 0 cmd/{omnitalk => classicstack}/doc.go | 2 +- .../extension_map.go | 2 +- .../extension_map_test.go | 0 cmd/classicstack/macgarden_register.go | 5 + .../macip_disabled.go | 2 +- .../macip_enabled.go | 10 +- cmd/{omnitalk => classicstack}/macip_hook.go | 4 +- cmd/{omnitalk => classicstack}/macip_test.go | 0 cmd/{omnitalk => classicstack}/main.go | 34 +- .../main_macip_test.go | 0 cmd/{omnitalk => classicstack}/packetdump.go | 2 +- cmd/{omnitalk => classicstack}/version.go | 0 cmd/omnitalk/macgarden_register.go | 5 - config/config.go | 4 +- config/loadtoml_test.go | 4 +- dist/Shared/welcome.txt | 2 +- dist/server.toml | 2 +- go.mod | 2 +- internal/testutil/mock_port.go | 6 +- internal/testutil/mock_router.go | 6 +- netlog/netlog.go | 6 +- omnitalk | Bin 15697408 -> 15708225 bytes pkg/hwaddr/hwaddr.go | 6 +- pkg/telemetry/telemetry.go | 22 +- port/ethertalk/doc.go | 2 +- port/ethertalk/ethertalk.go | 6 +- port/ethertalk/ethertalk_bridge.go | 2 +- port/ethertalk/ethertalk_bridge_test.go | 2 +- port/ethertalk/metrics.go | 4 +- port/ethertalk/pcap.go | 6 +- port/ethertalk/tap.go | 2 +- port/localtalk/doc.go | 2 +- port/localtalk/llap.go | 2 +- port/localtalk/localtalk.go | 12 +- port/localtalk/ltoudp.go | 4 +- port/localtalk/tashtalk.go | 4 +- port/nat/ipnat.go | 6 +- port/port.go | 2 +- protocol/asp/asp.go | 2 +- protocol/atp/atp.go | 4 +- protocol/nbp/nbp.go | 2 +- protocol/protocol.go | 2 +- router/doc.go | 2 +- router/router.go | 22 +- router/routing_table.go | 4 +- router/zone_information_table.go | 2 +- scripts/ci/build.ps1 | 22 +- scripts/ci/build.sh | 8 +- scripts/ci/package-release.ps1 | 8 +- scripts/ci/package-release.sh | 34 +- server.ini | 84 + server.toml | 2 +- server.toml.example | 2 +- service/aep/aep.go | 10 +- service/afp/appledouble_backend.go | 2 +- service/afp/appledouble_lifecycle_test.go | 2 +- service/afp/catsearch.go | 2 +- service/afp/cnid.go | 4 +- service/afp/desktop.go | 2 +- service/afp/desktop_models.go | 2 +- service/afp/desktop_rebuild.go | 4 +- service/afp/desktopdb.go | 4 +- service/afp/directory.go | 3 +- service/afp/directory_models.go | 2 +- service/afp/dispatcher.go | 2 +- service/afp/enumerate_encoding_test.go | 2 +- service/afp/file.go | 3 +- service/afp/filedir.go | 3 +- service/afp/filedir_models.go | 2 +- service/afp/filedir_pack.go | 2 +- service/afp/fork.go | 6 +- service/afp/fork_models.go | 2 +- service/afp/info.go | 2 +- service/afp/logging.go | 3 +- service/afp/macgarden_fs.go | 1810 +++++++++++++++++ service/afp/metadata.go | 4 +- service/afp/metrics.go | 4 +- service/afp/pascal_string.go | 2 +- service/afp/path_codec.go | 2 +- service/afp/paths.go | 3 +- service/afp/server.go | 9 +- service/afp/server_calls.go | 3 +- service/afp/server_models.go | 2 +- service/afp/server_models_golden_test.go | 4 +- service/afp/transport.go | 6 +- service/afp/volume.go | 6 +- service/afp/volume_models.go | 2 +- service/afpfs/macgarden/fs.go | 12 +- service/afpfs/macgarden/fs_test.go | 4 +- service/asp/asp.go | 16 +- service/asp/asp_test.go | 2 +- service/asp/session.go | 4 +- service/asp/types.go | 14 +- service/atp/transaction.go | 4 +- service/atp/wire.go | 2 +- service/dsi/dsi.go | 12 +- service/llap/llap.go | 10 +- service/llap/llap_test.go | 6 +- service/macgarden/client.go | 2 +- service/macgarden/client_test.go | 6 +- service/macip/dhcp_client.go | 6 +- service/macip/etherlink.go | 4 +- service/macip/macip.go | 16 +- service/macip/pool.go | 2 +- service/macip/state.go | 2 +- service/rtmp/responding.go | 6 +- service/rtmp/routing_table_aging.go | 6 +- service/rtmp/rtmp.go | 6 +- service/rtmp/sending.go | 6 +- service/service.go | 4 +- service/zip/mock_test.go | 2 +- service/zip/name_information.go | 10 +- service/zip/name_information_test.go | 6 +- service/zip/responding.go | 10 +- service/zip/sending.go | 6 +- service/zip/zip.go | 2 +- spec/00-overview.md | 2 +- 131 files changed, 2285 insertions(+), 388 deletions(-) rename cmd/{omnitalk => classicstack}/afp_disabled.go (89%) rename cmd/{omnitalk => classicstack}/afp_enabled.go (93%) rename cmd/{omnitalk => classicstack}/afp_hook.go (91%) rename cmd/{omnitalk => classicstack}/config_afp_test.go (97%) rename cmd/{omnitalk => classicstack}/config_flags.go (95%) rename cmd/{omnitalk => classicstack}/config_ini.go (96%) rename cmd/{omnitalk => classicstack}/config_test.go (100%) rename cmd/{omnitalk => classicstack}/doc.go (87%) rename cmd/{omnitalk => classicstack}/extension_map.go (95%) rename cmd/{omnitalk => classicstack}/extension_map_test.go (100%) create mode 100644 cmd/classicstack/macgarden_register.go rename cmd/{omnitalk => classicstack}/macip_disabled.go (88%) rename cmd/{omnitalk => classicstack}/macip_enabled.go (94%) rename cmd/{omnitalk => classicstack}/macip_hook.go (93%) rename cmd/{omnitalk => classicstack}/macip_test.go (100%) rename cmd/{omnitalk => classicstack}/main.go (94%) rename cmd/{omnitalk => classicstack}/main_macip_test.go (100%) rename cmd/{omnitalk => classicstack}/packetdump.go (95%) rename cmd/{omnitalk => classicstack}/version.go (100%) delete mode 100644 cmd/omnitalk/macgarden_register.go create mode 100644 server.ini create mode 100644 service/afp/macgarden_fs.go 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 2859c973973524ac6cbdf82e2a883f11c2685791..296801c028fc3c26b31a912d2d9e490637bb97e0 100644 GIT binary patch delta 70024 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 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.