Compare commits

...
27 Commits
Author SHA1 Message Date
codinget 84437ed033 chore(docs): add notice of AI review of the tailshare implementation
CI / format (pull_request) Successful in 1m37s
CI / lint (pull_request) Successful in 1m40s
CI / install (pull_request) Successful in 4m57s
CI / typetest (pull_request) Successful in 1m42s
CI / typecheck (pull_request) Successful in 1m57s
CI / node-tests (pull_request) Successful in 1m58s
CI / browser-tests (pull_request) Successful in 3m19s
2026-07-25 20:58:34 +00:00
codinget a291b371f6 chore(tailshare): fix lint and typescript issues
CI / install (pull_request) Canceled after 3m2s
CI / lint (pull_request) Successful in 1m30s
CI / format (pull_request) Successful in 1m30s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
2026-07-25 20:53:08 +00:00
codinget 120baafa82 chore(tailshare): remove unused deps 2026-07-25 20:43:07 +00:00
codinget dd9055276c fix(tailshare): properly use stableNodeID for exit nodes, not nodeKey
CI / lint (pull_request) Successful in 1m38s
CI / format (pull_request) Successful in 1m41s
CI / install (pull_request) Successful in 4m57s
CI / typetest (pull_request) Successful in 1m27s
CI / typecheck (pull_request) Successful in 1m40s
CI / node-tests (pull_request) Successful in 1m43s
CI / browser-tests (pull_request) Successful in 2m55s
2026-07-25 20:41:45 +00:00
codinget 21e1b4b666 refactor(tailshare): remove duplicate code that got extracted to other packages
CI / lint (pull_request) Successful in 1m27s
CI / format (pull_request) Successful in 1m29s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
CI / install (pull_request) Canceled after 2m31s
2026-07-25 20:39:29 +00:00
codinget 5232539476 chore: fix npm package-lock deps for npm 11
CI / lint (pull_request) Successful in 1m15s
CI / format (pull_request) Successful in 1m24s
CI / install (pull_request) Successful in 4m38s
CI / typecheck (pull_request) Successful in 1m14s
CI / typetest (pull_request) Successful in 1m21s
CI / node-tests (pull_request) Successful in 1m28s
CI / browser-tests (pull_request) Successful in 2m46s
2026-07-25 17:57:04 +00:00
codinget 0a79ffdc2a fix(tailshare): streaming downloads where available 2026-07-25 17:56:02 +00:00
codinget 1caa24239b fix(tailshare): non-SI units 2026-07-25 17:56:02 +00:00
codinget f885ec50cd fix(tailshare): wrong key for OutgoingFileNotifier 2026-07-25 17:56:02 +00:00
codinget 9101ee93bd chore(tailshare): add todo 2026-07-25 17:56:02 +00:00
codinget 52dd50c4b8 feat(tailshare): remove taildrop setting (always enable), use real asterisks and flag authKey warn 2026-07-25 17:56:02 +00:00
codinget 3dbd09a2c4 fix(tailshare): drawer shouldn't default to open 2026-07-25 17:56:02 +00:00
codinget f0ef99c635 chore(tailshare): remove useless comments 2026-07-25 17:56:02 +00:00
codinget 77b6686e0d fix(tailshare): potential crash if a peer disconnects in the middle of a transfer 2026-07-25 17:56:02 +00:00
codinget 2f5290797e chore(tailshare): remove dead code 2026-07-25 17:56:02 +00:00
codinget 5c968c281e fix(tailshare): use oncancel 2026-07-25 17:56:02 +00:00
codinget 8d3aa16e23 fix(tailshare): fix broken import 2026-07-25 17:56:02 +00:00
codinget 5a94656194 fix(tailshare): use correct version of sendFile() after streaming switch 2026-07-25 17:56:02 +00:00
codinget 80ec3460f1 feat(tailshare): show tailnet and send files via taildrop 2026-07-25 17:56:02 +00:00
codinget f09ca3285d feat(tailshare): download incoming taildrop files 2026-07-25 17:56:02 +00:00
codinget 49580e3f48 feat(tailshare): block multiple instances across tabs 2026-07-25 17:56:02 +00:00
codinget 167387f81e feat(tailshare): incoming taildrop file notifier 2026-07-25 17:56:02 +00:00
codinget af264ff3fd fix(tailshare): use a useSyncExternalStore base impl of useLocalStorage 2026-07-25 17:56:02 +00:00
codinget e14b168d88 feat(tailshare): save exit node to localStorage 2026-07-25 17:56:02 +00:00
codinget a9d588d7fa feat(tailshare): save tailscale settings to localStorage 2026-07-25 17:56:02 +00:00
codinget 8dfb67c25d feat(tailshare): continue implementing tailscale integration 2026-07-25 17:56:02 +00:00
codinget 0ef4529afe feat(tailshare): initialize app 2026-07-25 17:56:02 +00:00
35 changed files with 2301 additions and 2971 deletions
+4 -3
View File
@@ -47,7 +47,7 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
1. `initIPN` accepted only a URL string and called `fetch()`, which does not support `file://` URLs in Node.js. Claude Code added a `WasmSource` union type (`string | URL | ArrayBuffer | ArrayBufferView | Response | ReadableStream<Uint8Array>`) dispatching to `WebAssembly.instantiate` for binary sources and `WebAssembly.instantiateStreaming` for URL/Response/ReadableStream inputs.
2. `wasm_exec.js` installs ENOSYS stubs for `globalThis.fs` when it is falsy. In Node.js `globalThis.fs` is undefined, so the stubs are installed — and this is the correct behaviour: tsconnect's WASM routes all network calls through JavaScript's `fetch()` and WebSocket APIs (which use Node.js's own DNS resolver), so the Go net package's `/etc/resolv.conf` read should remain a no-op. An earlier iteration set `globalThis.fs` to Node.js's real `fs`, which caused Go to read the host's `/etc/resolv.conf` directly and attempt to use those nameservers, breaking in environments where they are unreachable (e.g. Tailscale-managed entries without Tailscale running). `wasm_exec.js` is now imported directly in `index.ts` and `Go` is extracted from `globalThis` inline — a separate `env-node.ts`/`env-web.ts` split was explored but both files were identical, so the indirection was removed.
3. In the `tailscale` submodule, two Go-side bugs were fixed: `safesocket_js.go` used a hardcoded memconn address `"Tailscale-IPN"`, so a second `newIPN()` call in the same WASM process would `log.Fatal`; an atomic counter now gives each instance a unique address. And `wasm_js.go`'s `listen()` rejected the standard `":0"` (any-interface) address form that netstack does not accept; it now normalises `:port` to `0.0.0.0:port`.
Claude Code also wrote the full automated test suite for `@webnet/tsconnect`: unit tests for `InMemoryFileOps` and `InMemoryState` (no WASM required), and integration tests that spin up real Tailscale nodes against a headscale control server, verifying `initIPN` WASM loading, `/localapi/v0/status`, and two-node TCP dial/listen.
Claude Code also wrote the full automated test suite for `@webnet/tsconnect`: unit tests for `InMemoryFileOps` and `InMemoryState` (no WASM required), and integration tests that spin up real Tailscale nodes against a headscale control server, verifying `initIPN` WASM loading, `/localapi/v0/status`, and two-node TCP dial/listen.
- **`@webnet/tsconnect``getIceServers()`**: Claude Code implemented `getIceServers(ipn)`, which fetches the tailnet's DERPMap via LocalAPI and converts it to an `RTCIceServer[]` list for use with `new RTCPeerConnection({ iceServers })`. DERP servers run an integrated RFC 5389-compliant STUN server on UDP 3478 by default; since tsconnect in the browser cannot use raw UDP (all traffic goes through DERP over WebSocket), WebRTC is the only way to establish a direct peer-to-peer connection. Also added `DERPMap`, `DERPRegion`, and `DERPNode` types. Unit and integration tests included.
- **`@webnet/tsconnect` — service advertisement**: Claude Code added `SetExplicitServices` to `LocalBackend` in the tailscale fork (bypassing the OS portlist gate), wired a `setServices(services)` WASM binding, and added a `services` field to the netmap JSON for both self and peers (stripping internal peerapi entries). On the TypeScript side: `IPNService` type, `services: IPNService[]` on `IPNNetMapNode`, and `IPN.setServices()`. In `@webnet/tsconnect-redux`: `getPeersByService`, `getPeersByServiceDescription` (both memoized with `createSelector`), and `getSelfServices` (referentially stable empty-array fallback). Claude Code also wrote integration tests for `setServices()` and fixed two bugs found while making them reliable: `SetExplicitServices` previously only sent a "lite" map update that discarded the control server's response, so `notifyNetMap` never fired with the new services — fixed by adding `Auto.RestartMap()` to force a fresh streaming netmap; and `userServicesFromView` returned a nil slice when a node advertised no services, which serialized as `null` instead of `[]` in the netmap JSON.
- **`@webnet/tsconnect` — taildrive WebDAV bridge and `listDrivePeers()`**: Claude Code implemented `jsFileSystemForRemote` in the tailscale fork (`cmd/tsconnect/wasm/drive.go`), a `drive.FileSystemForRemote` backed by a JS callback: request bodies stream to JS chunk-by-chunk via `readBodyChunk()`, response bodies stream back via `write()`/`end()` with `http.Flusher.Flush()` after each chunk, so large transfers never buffer in memory. `sys.DriveForRemote` is set at `newIPN` time; `setDriveHandler` (wired to `IPN.serveDrive(fn)`) registers the actual handler later, since the Tailscale auth/permission-parsing (`DriveSharingEnabled`, `ParsePermissions`) happens entirely Go-side before the JS handler is invoked. Also added `listDrivePeers` (wired to `IPN.listDrivePeers()`), mirroring native `driveRemotesFromPeers`: returns peers carrying `PeerCapabilityTaildriveSharer`, gated on `DriveAccessEnabled()`. On the TypeScript side: `DriveSharePermission`, `DrivePermissions`, `JsDriveRequest`, `JsDriveResponse`, `RawDriveHandler`, `IPNDrivePeer` types. Claude Code also created the new `@webnet/taildrive` package (`./server` sub-export) with `bridgeDriveHandler(handler)`, which adapts an `@webnet/http` `Handler` (e.g. from `@webnet/drive`'s `createDAVHandler`) to the Go bridge's raw request/response callback shape — kept in a separate package from `@webnet/tsconnect` and `@webnet/drive` to avoid a circular dependency between the two. Unit tests cover `bridgeDriveHandler`'s request/response translation (headers, all `Body` union variants, `hasBody` detection) and `IPN.serveDrive`/`listDrivePeers` argument validation and JSON handling against a fake `RawIPN`, so they run without a WASM build. A true end-to-end test against a live control plane is blocked on Headscale ACL support for `nodeAttrs`/`grants`/Taildrive, which only lands in Headscale v0.29.0 (not yet released stably at the time of writing).
@@ -79,7 +79,7 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
- **`AGENTS.md` / `CLAUDE.md` — PR labeling and auto-finalise**: Claude Code (Claude Sonnet 4.6) added guidance to apply the org-level `Agentic` and `Agent/<model-line>` labels (which already exist at org level; `Agent/*` are exclusive) to every agent-opened PR, and to auto-finalise PRs without waiting for the user when the work is trivial or fully specified upfront.
- **`@webnet/react` and `@webnet/utils` — new packages**: Claude Code (Claude Sonnet 4.6) extracted the `useClient` and `useLocalStorage` hooks from `packages/tailshare` into a new standalone `@webnet/react` package, and extracted the `download`, `upload`, `readBlob`, and `fmtSize` utilities into a new `@webnet/utils` package. Both packages are plain TypeScript with no dependencies beyond React (peer dep for `@webnet/react`) and are built with `tsc`.
- **`@webnet/react` and `@webnet/utils` — integration into `example-app` and `test-app`**: Claude Code (Claude Sonnet 4.6) wired `@webnet/react` and `@webnet/utils` into the consumer apps. In `example-app`: replaced the local `useClient` duplicate with the package version; added `useLocalStorage` to persist the SharedWorker toggle (`ipn:useWorker`) across page reloads; replaced the local `fmtSize` with `@webnet/utils`; simplified the `WaitingFileDebug` download handler to use `download()` from `@webnet/utils`. Two bugs were fixed in `@webnet/utils/download` during integration: a typo (`suggegestedName``suggestedName`) in the `showSaveFilePicker` call, and synchronous `URL.revokeObjectURL` reverted to a deferred `setTimeout` for Safari compatibility. In `test-app`: added `@webnet/utils` as a dependency and exposed it on `window.utils` for console testing.
- **`@webnet/tsconnect-worker` — flaky closed-state test fix**: Claude Code (Claude Sonnet 4.6) replaced racy `setTimeout(r, 10)` waits in four "rejects immediately when closed" tests (`read`, `write`, `accept`, `readFrom`) with a deterministic `while (!x.closed) await setImmediate()` poll. The 10 ms sleep was not always sufficient under CI load, causing the "closed" message to arrive *after* the method under test created a pending promise, yielding `"packet conn closed"` instead of the expected `"already closed"` error.
- **`@webnet/tsconnect-worker` — flaky closed-state test fix**: Claude Code (Claude Sonnet 4.6) replaced racy `setTimeout(r, 10)` waits in four "rejects immediately when closed" tests (`read`, `write`, `accept`, `readFrom`) with a deterministic `while (!x.closed) await setImmediate()` poll. The 10 ms sleep was not always sufficient under CI load, causing the "closed" message to arrive _after_ the method under test created a pending promise, yielding `"packet conn closed"` instead of the expected `"already closed"` error.
- **CI — consolidate workflows and eliminate redundant install/build**: Claude Code (Claude Sonnet 5) merged `checks.yml`, `test-node.yml`, and `test-browser.yml` into a single `.gitea/workflows/ci.yml`. Previously all 7 jobs independently repeated checkout + `tailscale` submodule clone + `npm ci`, and 5 of them independently rebuilt the whole workspace via Turbo. Live smoke tests against this Gitea instance found `actions/cache` (and `setup-node`'s built-in `cache: npm`, same API) times out against the built-in cache proxy, and `actions/upload-artifact`/`download-artifact` v4 refuse to run at all (GHES detection), but v3 of the artifact actions work correctly. Given that, a new `install` job now does the real work once (submodule clone, `npm ci`, build) and hands `node_modules` plus Turbo's local cache off to `typecheck`/`typetest`/`node-tests`/`browser-tests` (all `needs: install`) via `actions/upload-artifact@v3`/`download-artifact@v3`, instead of each job reinstalling and rebuilding from scratch. `lint`/`format` never touched the submodule or build output, so they dropped that step and stay independent for fast feedback. The shared checkout+submodule+setup-node preamble was factored into a composite action, `.gitea/actions/setup/action.yml` (composite actions require the repo to already be checked out, so `actions/checkout` stays a separate first step in every job rather than being absorbed into the composite action). A `concurrency` group cancels superseded runs on the same ref. One real bug was found and fixed during this work: `packages/xml` and `packages/vfs` pin a newer local TypeScript than the workspace root, installed by npm as nested `packages/{xml,vfs}/node_modules/typescript`; the first version of the `install` job's `node_modules` archive only captured the root `node_modules`, so downstream jobs silently typechecked those two packages against the wrong TypeScript version and produced spurious errors — fixed by archiving `packages/*/node_modules` alongside the root. Cross-run caching (reusing a previous run's install/build) is not available until the Gitea instance's cache backend is fixed server-side; that's a separate, out-of-scope follow-up.
- **`@webnet/tsconnect-worker` — remaining flaky message-wait fixes**: Claude Code (Claude Sonnet 5) found the same fixed-delay race pattern still present in the rest of `worker.test.ts` (the `WorkerSSHSession.resize()`/`close()` tests called out as flaky in CI, plus the other `close()`-message, callback-firing, and `pumpStreamToPort` tests), where a flat `setTimeout(r, 10)` was used to wait for a `postMessage` to be delivered before asserting on it. Added a generic `waitFor(predicate, timeoutMs?)` helper that polls via `setImmediate` and replaced every such fixed sleep with a poll on the actual condition (message present in the captured array, or callback fired). Following an autonomous review, the four remaining ad hoc `while (!x.closed) await setImmediate()` spin-loops from the earlier b917cd8 fix were also consolidated onto the same `waitFor` helper for consistency.
- **`AGENTS.md` / `CLAUDE.md``tea` mergeability and symlink clarifications**: Claude Code (Claude Fable 5) documented that `CLAUDE.md` is a symlink to `AGENTS.md`, that `tea` reports `mergeable: false` while the `WIP:` title prefix is present (so mergeability is only meaningful at finalisation), and that the conflicting-files section header in `tea` output is always printed — only files listed under it indicate actual conflicts.
@@ -111,9 +111,10 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
- **`@webnet/ssh` — SSH-2 package split out of `@webnet/sftp`, plus TCP port forwarding**: Claude Code (Claude Fable 5) extracted the hand-written SSH-2 stack (transport, curve25519 kex, ciphers, host keys, userauth, connection-protocol channels) out of `@webnet/sftp` into a new standalone `@webnet/ssh` package via pure `git mv`s, so it can be reused independently; `@webnet/sftp` now depends on `@webnet/ssh` and consumes its low-level pieces through `@webnet/ssh/_internals`. The server auth path was decoupled from `@webnet/vfs`: `authenticateServer<T>` is now generic over an authentication context and rejects with a new `SSHAuthError` rather than `VFSError`, which `@webnet/sftp` maps back to `VFSError("forbidden")` at its own boundary to preserve behaviour. On top of the split, TCP forwarding primitives were added. The channel mux (`ConnectionMux`) gained configurable accepted channel types and a global-request handler, generic `CHANNEL_OPEN` handling that surfaces incoming opens (session / direct-tcpip / forwarded-tcpip with parsed endpoints) as an `IncomingOpen` the consumer can `accept()`/`reject()`, outbound `openDirectTcpip`/`openForwardedTcpip`, and an in-order `globalRequest`/`onGlobalRequest` path for `tcpip-forward`/`cancel-tcpip-forward` (RFC 4254 §7). A `channelTransport()` adapter wraps a `Channel` as a `@webnet/transport` `RawTransport` (EOF surfaced as a throw with `readEnded`, `halfClose` as `CHANNEL_EOF`, non-blocking `close()` so a forwarded stream can't deadlock on the peer close handshake), and an internal `pipe()` bridges two transports. The public API exposes `SSHClientConnection` (connect over a `RawTransport`, `openSubsystem`, `openSession`, `openDirectTcpip`, `dialer()` returning a `RawDialer` for local forwarding, and `requestRemoteForward()` returning a `RemoteForward` that implements `RawListener` for remote forwarding) and `SSHServerConnection<T>` (auth hook returning the context, `acceptSession()`, and optional `directTcpip`/`tcpipForward` hooks that plug arbitrary `RawTransport`/`RawListener` implementations into the forwarding paths). The design deliberately keeps `openSession()`/session-channel handling generic so later PRs can add exec/shell/pty request plumbing (for scp/rsync clients and a shell-delegating server) without reshaping the connection API. `@webnet/sftp`'s client and server were refactored onto the new connection classes with no behaviour change (its full suite passes unmodified). New tests cover direct-tcpip open round-trips and accept/reject, global-request ordering/failure, the channel/RawTransport adapter (EOF, half-close, windowed backpressure), and loopback end-to-end forwarding in both directions; an env-gated (`SSH_TEST_*`) interop suite exercises `ssh -L`/`ssh -R` equivalents against a real OpenSSH sshd (direct-tcpip to the sshd banner, and a remote-forward loop dialed back through direct-tcpip). Two autonomous code-review rounds followed. A Sonnet review found port-only remote-forward keying (two forwards on the same port collided), unguarded `CHANNEL_OPEN_CONFIRMATION` sends in the accept loops, and unrejected pending `RemoteForward.accept()` waiters on close — all fixed. A second Fable review then found three more serious issues, all fixed by Claude Fable 5 with regression tests: (1) `Channel.send()` deadlocked forever if the peer closed the channel while the sender was blocked on window exhaustion (`_deliverClose` now wakes window waiters and `send()` aborts on a remotely-closed channel); (2) `pipe()`'s copy loop only guarded reads, so a write failure on a forwarded socket became an `unhandledRejection` that crashes the Node process by default — a remotely-triggerable DoS — now the write is guarded and both `void pipe()` call sites swallow; (3) connect-phase failures leaked the underlying socket (`SFTPClient.#doConnect` and `SSHClientConnection.connect` now close on `openSubsystem`/auth failure; verified against an sshd with no sftp subsystem, which previously hung the process to the runner timeout). Four smaller fixes: `authenticateServer` accepts a falsy auth context (uid `0` etc.) instead of rejecting it, `#matchForward`'s port fallback fails closed on an ambiguous port rather than misrouting, `acceptSession()` rejects instead of hanging once the accept loop has died, and a duplicate `tcpip-forward` closes the superseded listener (and `RemoteForward` teardown closes queued-but-unaccepted transports).
- **`@webnet/ftp` — FTP client state transfer**: GPT-5.6 Terra implemented transferable FTP control connections: `FTPClient.transferState()` exports worker-backed connections plus buffered reply bytes and negotiated session state; `FTPClient.adopt()` claims them or reconnects and authenticates on expiry. Active data transfers are refused, and focused tests cover claim, fallback, and the safety guard.
- **`@webnet/ftp` — state-transfer ownership fix**: GPT-5.6 Terra addressed a GPT-5.6 Sol review finding by making detachment atomic under the control lock; queued commands now reject after handoff and a regression test covers that race.
- **`@webnet/smb2` — cross-tab state transfer (`StateTransferable`)**: Claude Code (Fable 5) implemented worker-side ownership transfer for `SMB2Client` (issue #99), consistent with `DAVClient`. `SMB2Client.transferState()` detaches an idle client and exports a structured-cloneable `SMB2TransferState`: the parked-transport token plus everything needed to keep using the authenticated session on the adopting side — server/share/port config, negotiated dialect and max transact/read/write sizes, message-id and credit counters, session and tree ids, the 3.1.1 preauth hash, and the *derived* signing key with the signing-required flag. Credentials are never serialized; `SMB2Client.adopt(state, { claim, dialer, username, password, reconnect? })` takes them from the adopter's configuration, validates state version/shape before claiming, resumes message-id and signing state exactly once on the claimed transport, closes the claimed transport if resumption fails, and (only with `reconnect: true`) falls back to a fresh dial-on-demand session when the claim is expired/duplicate — transferred signing/session state is never reused on a new transport. Transfer is rejected unless the client is quiescent (no request in flight, no connect pending, no read/write stream still owning an SMB file handle, no buffered incoming bytes), and the source client becomes permanently detached after a successful transfer instead of silently reconnecting. Tests extend the mock server with per-connection signing state, an SMB 2.1 mode, request-signature verification, and message-id recording, covering: signed 2.1/3.1.1 sessions continuing with the next message id after a `structuredClone`d transfer (with response-signature verification still enforced), busy/streaming/connect-pending rejection, config-only transfer for never-connected clients or non-transferable transports, malformed/version-mismatched/duplicate/expired state handling, claimed-resource cleanup on failed adoption, and reconnect fallback; the opt-in Samba integration suite gained a live transfer round-trip. A browser two-tab transfer test is deferred: it needs a reachable SMB server inside the worker's tailnet, which the browser CI environment does not provide (the same limitation applies to the existing DAV/FTP transfer work).
- **`@webnet/smb2` — cross-tab state transfer (`StateTransferable`)**: Claude Code (Fable 5) implemented worker-side ownership transfer for `SMB2Client` (issue #99), consistent with `DAVClient`. `SMB2Client.transferState()` detaches an idle client and exports a structured-cloneable `SMB2TransferState`: the parked-transport token plus everything needed to keep using the authenticated session on the adopting side — server/share/port config, negotiated dialect and max transact/read/write sizes, message-id and credit counters, session and tree ids, the 3.1.1 preauth hash, and the _derived_ signing key with the signing-required flag. Credentials are never serialized; `SMB2Client.adopt(state, { claim, dialer, username, password, reconnect? })` takes them from the adopter's configuration, validates state version/shape before claiming, resumes message-id and signing state exactly once on the claimed transport, closes the claimed transport if resumption fails, and (only with `reconnect: true`) falls back to a fresh dial-on-demand session when the claim is expired/duplicate — transferred signing/session state is never reused on a new transport. Transfer is rejected unless the client is quiescent (no request in flight, no connect pending, no read/write stream still owning an SMB file handle, no buffered incoming bytes), and the source client becomes permanently detached after a successful transfer instead of silently reconnecting. Tests extend the mock server with per-connection signing state, an SMB 2.1 mode, request-signature verification, and message-id recording, covering: signed 2.1/3.1.1 sessions continuing with the next message id after a `structuredClone`d transfer (with response-signature verification still enforced), busy/streaming/connect-pending rejection, config-only transfer for never-connected clients or non-transferable transports, malformed/version-mismatched/duplicate/expired state handling, claimed-resource cleanup on failed adoption, and reconnect fallback; the opt-in Samba integration suite gained a live transfer round-trip. A browser two-tab transfer test is deferred: it needs a reachable SMB server inside the worker's tailnet, which the browser CI environment does not provide (the same limitation applies to the existing DAV/FTP transfer work).
- **Agent label documentation**: `gpt-5.6-sol` updated `AGENTS.md` to require exact `Agent/<model-slug>` labels and coordinating-model attribution for orchestrated work.
- **`@webnet/utils` — shared binary reader/writer (issue #79)**: GPT-5.6 Luna implemented a dependency-free, endian-aware binary codec primitive with bounds-checked reads, growable writes, offset-correct `DataView` handling, alignment, skipping, random access, patching, and explicit finish-copy semantics. SSH and SMB2 now delegate their cursor mechanics to it while retaining protocol-specific encodings, with coverage for both endiannesses, growth, subarray offsets, overruns, alignment, patching, and zero-length operations.
- **`@webnet/state-transfer` — generic state-transfer contract extraction**: Codex (GPT-5.6 Luna) extracted `StateTransferable` and `isStateTransferable` from `@webnet/transport` into a dependency-free workspace package, migrated current DAV/FTP/SMB2/worker consumers, removed the unused transport re-exports, and added guard tests and package metadata.
- **`@webnet/state-transfer` — review follow-up**: Codex (GPT-5.6 Luna) restored the original object-only type-guard semantics, excluded typetests from the production build, added package coverage metadata, and validated the initialized worker and full workspace with the root build, tests, typechecks, and typetests.
- **Root Node/npm engine declaration**: `gpt-5.6-sol` declared Node 24 and npm 11 as the supported root workspace toolchain and synchronized the lockfile metadata.
- **`@webnet/tailshare` - original implementation review**: Initial implementation (PR #28) hand-written, but reviewed by multiple agents: Claude Sonnet 4.6 during writing and Claude Opus 5 and GPT 5.6 Sol before merge.
+572 -2968
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@webnet/tailshare",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "webpack serve --mode development",
"build": "NODE_ENV=production webpack --mode production"
},
"dependencies": {
"@mantine/core": "^9.3.1",
"@mantine/hooks": "^9.3.1",
"@mantine/notifications": "^9.3.1",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.11.2",
"@webnet/react": "*",
"@webnet/tsconnect": "*",
"@webnet/tsconnect-react": "*",
"@webnet/tsconnect-redux": "*",
"@webnet/utils": "*",
"clsx": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-redux": "^9.2.0",
"wouter": "^3.10.0"
},
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.4",
"babel-loader": "^9.2.1",
"css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.3",
"postcss": "^8.5.15",
"postcss-loader": "^8.2.1",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"react-refresh": "^0.14.2",
"sass": "^1.89.0",
"sass-loader": "^16.0.5",
"style-loader": "^4.0.0",
"typescript": "^6.0.2",
"webpack": "^5.99.9",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.2.1"
}
}
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
plugins: {
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
"mantine-breakpoint-xs": "36em",
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em",
},
},
},
}
@@ -0,0 +1,4 @@
.icon {
width: 22px;
height: 22px;
}
@@ -0,0 +1,24 @@
import { ActionIcon, Tooltip, useComputedColorScheme, useMantineColorScheme } from "@mantine/core"
import { SunIcon, MoonIcon } from "@phosphor-icons/react"
import cx from "clsx"
import styles from "./ColorSchemeButton.scss"
import { hiddenDark, hiddenLight } from "../styles/light-dark"
export function ColorSchemeButton() {
const { setColorScheme } = useMantineColorScheme()
const computedColorScheme = useComputedColorScheme("light", { getInitialValueInEffect: true })
return (
<Tooltip label="Toggle color scheme">
<ActionIcon
onClick={() => setColorScheme(computedColorScheme === "light" ? "dark" : "light")}
variant="default"
size="xl"
aria-label="Toggle color scheme"
>
<SunIcon className={cx(styles.icon, hiddenLight)} />
<MoonIcon className={cx(styles.icon, hiddenDark)} />
</ActionIcon>
</Tooltip>
)
}
@@ -0,0 +1,243 @@
import {
ActionIcon,
Box,
Button,
Code,
CopyButton,
Loader,
NativeSelect,
Popover,
Stack,
Text,
Tooltip,
useMantineTheme,
} from "@mantine/core"
import { NetworkXIcon, NetworkIcon, UserIcon } from "@phosphor-icons/react"
import { IpnContext, useIpnSelector } from "../contexts/IpnContext"
import {
getExitNode,
getIpnSelfNode,
getIpnState,
getLoginUrl,
getPeers,
getWaitingFileCount,
} from "@webnet/tsconnect-redux"
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store"
import {
getIpnPrepareAlreadyRunning,
getIpnPrepareWillBuild,
triggerIpnBuild,
} from "../store/slices/ipnPrepare"
import { use, type ReactNode } from "react"
import { Link } from "wouter"
import { useDisclosure } from "@mantine/hooks"
import clsx from "clsx"
import { hidden, pointer } from "../styles/helpers"
import { setTailnetDrawerOpen } from "../store/slices/states"
function TailscaleQuickSettings({ onClose }: { onClose: () => void }) {
const exitNode = useIpnSelector(getExitNode)
const peers = useIpnSelector(getPeers)
const ipnSelfNode = useIpnSelector(getIpnSelfNode)
const waitingFileCount = useIpnSelector(getWaitingFileCount)
const dispatch = useTailshareDispatch()
const ipn = use(IpnContext)
const theme = useMantineTheme()
if (!ipn)
return (
<Button<typeof Link> mt="md" href="/tailscale" component={Link}>
Tailscale settings
</Button>
)
return (
<>
{ipnSelfNode && (
<Box mb="sm">
<Text>
This node:{" "}
<Tooltip label={ipnSelfNode.name}>
<Code>{ipnSelfNode.name.split(".")[0]}</Code>
</Tooltip>
</Text>
<Text>Tailnet: {Object.keys(peers).length} peers</Text>
{ipn.fileOps && (
<Text>
Taildrop:{" "}
{waitingFileCount ? (
<Text component="span" c={theme.primaryColor}>
{waitingFileCount} file{waitingFileCount > 1 && <>s</>} received
</Text>
) : (
<>available</>
)}
</Text>
)}
{ipnSelfNode.addresses.map((addr) => (
<CopyButton key={addr} value={addr}>
{({ copy, copied }) => (
<Text>
{addr.match(/^\d+\.\d+\.\d+\.\d+$/) ? <>IPv4</> : <>IPv6</>}:{" "}
<Code className={pointer} onClick={copy}>
{addr}
</Code>
<Text ml="sm" className={clsx(copied || hidden)} unstyled component="span">
Copied!
</Text>
</Text>
)}
</CopyButton>
))}
</Box>
)}
<NativeSelect
label="Exit node"
value={exitNode ?? ""}
onChange={(e) => ipn?.setExitNode(e.target.value)}
data={[
{ label: "No exit node", value: "" },
...Object.values(peers)
.filter((x) => x.exitNodeOption)
.map((peer) => ({ label: peer.name.split(".")[0], value: peer.stableNodeID })),
]}
/>
<Stack mt="md" gap="xs">
<Button
onClick={() => {
dispatch(setTailnetDrawerOpen(true))
onClose()
}}
>
Tailnet
</Button>
<Button<typeof Link> href="/tailscale" onClick={onClose} component={Link}>
Tailscale settings
</Button>
</Stack>
</>
)
}
function TailscaleAlreadyRunningStatus() {
return (
<>
Tailscale cannot start
<br />
Typically, this is caused by another tab running Tailshare
</>
)
}
function TailscaleRunningStatus() {
const ipnSelfNode = useIpnSelector(getIpnSelfNode)
const exitNode = useIpnSelector(getExitNode)
const peers = useIpnSelector(getPeers)
const waitingFileCount = useIpnSelector(getWaitingFileCount)
const exitNodePeer = exitNode
? Object.values(peers).find((x) => x.stableNodeID === exitNode)
: undefined
return (
<>
{ipnSelfNode && (
<>
This node: {ipnSelfNode.name.split(".")[0]} - {ipnSelfNode.addresses[0]}
<br />
</>
)}
Tailnet: {Object.keys(peers).length} peers
<br />
{exitNode ? (
<>
Exit node:{" "}
{exitNodePeer ? (
<>
{exitNodePeer.name.split(".")[0]} - {exitNodePeer.addresses[0]}
</>
) : (
<>(unknown peer)</>
)}
</>
) : (
<>No exit node</>
)}
{!!waitingFileCount && (
<>
<br />
Taildrop: {waitingFileCount} file{waitingFileCount > 1 && <>s</>} received
</>
)}
</>
)
}
export function TailscaleStateButton() {
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
const alreadyRunning = useTailshareSelector(getIpnPrepareAlreadyRunning)
const store = useTailshareStore()
const ipnState = useIpnSelector(getIpnState)
const ipnLoginUrl = useIpnSelector(getLoginUrl)
const [opened, { open, close }] = useDisclosure()
const running = !alreadyRunning && ipnState === "Running"
const needsLogin = !running && ipnState === "NeedsLogin" && !!ipnLoginUrl
const building = !needsLogin && ipnState === "NoState" && !!willBuild
const notStarting = !building && !willBuild
let label: string
let tooltip: ReactNode
let icon: ReactNode
if (alreadyRunning) {
label = "Tailscale cannot start: open status"
tooltip = <TailscaleAlreadyRunningStatus />
icon = <NetworkXIcon />
} else if (running) {
label = "Tailscale running: open status"
tooltip = <TailscaleRunningStatus />
icon = <NetworkIcon />
} else if (needsLogin) {
label = "Login to Tailscale"
tooltip = "Login to Tailscale"
icon = <UserIcon />
} else if (building) {
label = "Tailscale loading..."
tooltip = "Tailscale loading..."
icon = <Loader />
} else if (notStarting) {
label = "Start Tailscale"
tooltip = "Start Tailscale"
icon = <NetworkIcon />
} else {
label = "Tailscale not running: open status"
tooltip = "Tailscale status (not running)"
icon = <NetworkXIcon />
}
return (
<Popover onDismiss={close} opened={opened}>
<Tooltip label={tooltip} disabled={opened}>
<Popover.Target>
<ActionIcon
onClick={() => {
if (needsLogin) window.open(ipnLoginUrl)
else if (notStarting) triggerIpnBuild(store)
else open()
}}
variant={running || needsLogin || alreadyRunning ? "filled" : "default"}
color={needsLogin || alreadyRunning ? "red" : undefined}
size="xl"
aria-label={label}
>
{icon}
</ActionIcon>
</Popover.Target>
</Tooltip>
<Popover.Dropdown>
<TailscaleQuickSettings onClose={close} />
</Popover.Dropdown>
</Popover>
)
}
@@ -0,0 +1,26 @@
import { Burger, Group, Space } from "@mantine/core"
import { ColorSchemeButton } from "../ColorSchemeButton"
import { TailscaleStateButton } from "../TailscaleStateButton"
export function Header({
mobileOpened,
desktopOpened,
toggleMobile,
toggleDesktop,
}: {
mobileOpened: boolean
desktopOpened: boolean
toggleMobile: () => void
toggleDesktop: () => void
}) {
return (
<Group h="100%" px="md">
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
Tailshare
<Space flex={1} />
<TailscaleStateButton />
<ColorSchemeButton />
</Group>
)
}
@@ -0,0 +1,14 @@
.navbar {
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
padding: var(--mantine-spacing-md);
padding-bottom: 0;
display: flex;
flex-direction: column;
border-right: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.links {
flex: 1;
margin-left: calc(var(--mantine-spacing-md) * -1);
margin-right: calc(var(--mantine-spacing-md) * -1);
}
@@ -0,0 +1,21 @@
import { AppShell, ScrollArea } from "@mantine/core"
import styles from "./Navbar.scss"
import { NavbarLinksGroup } from "./NavbarLinksGroup"
import { SunglassesIcon } from "@phosphor-icons/react"
export function Navbar() {
return (
<AppShell.Navbar p="md" className={styles.navbar}>
<ScrollArea className={styles.links}>
<NavbarLinksGroup
label="Hewwo"
icon={SunglassesIcon}
links={[
{ link: "/", label: "Home" },
{ link: "/files", label: "Files" },
]}
/>
</ScrollArea>
</AppShell.Navbar>
)
}
@@ -0,0 +1,34 @@
.control {
font-weight: 500;
display: block;
width: 100%;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
color: var(--mantine-color-text);
font-size: var(--mantine-font-size-sm);
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
}
}
.linkwrap {
text-decoration: none;
}
.link {
font-weight: 500;
display: block;
text-decoration: none;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
padding-left: var(--mantine-spacing-md);
margin-left: var(--mantine-spacing-xl);
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
}
}
@@ -0,0 +1,42 @@
import { useState } from "react"
import { Box, Collapse, Group, Text, ThemeIcon, UnstyledButton } from "@mantine/core"
import styles from "./NavbarLinksGroup.module.css"
import { Link } from "wouter"
import { ListIcon, XIcon } from "@phosphor-icons/react"
interface LinksGroupProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: React.FC<any>
label: string
initiallyOpened?: boolean
links?: { label: string; link: string }[]
}
export function NavbarLinksGroup({ icon: Icon, label, initiallyOpened, links }: LinksGroupProps) {
const hasLinks = Array.isArray(links)
const [opened, setOpened] = useState(initiallyOpened || false)
const items = (hasLinks ? links : []).map((link) => (
<Link key={link.link} href={link.link} className={styles.linkwrap}>
<Text className={styles.link} key={link.label}>
{link.label}
</Text>
</Link>
))
return (
<>
<UnstyledButton onClick={() => setOpened((o) => !o)} className={styles.control}>
<Group justify="space-between" gap={0}>
<Box style={{ display: "flex", alignItems: "center" }}>
<ThemeIcon variant="light" size={30}>
<Icon size={18} />
</ThemeIcon>
<Box ml="md">{label}</Box>
</Box>
{hasLinks && <>{opened ? <XIcon size={18} /> : <ListIcon size={18} />}</>}
</Group>
</UnstyledButton>
{hasLinks ? <Collapse expanded={opened}>{items}</Collapse> : null}
</>
)
}
@@ -0,0 +1,193 @@
import {
getIncomingFiles,
getOutgoingFiles,
getPeers,
getWaitingFileCount,
} from "@webnet/tsconnect-redux"
import { useIpnSelector } from "../../contexts/IpnContext"
import { useEffect, useEffectEvent } from "react"
import { notifications } from "@mantine/notifications"
import { Button, Group, Progress, Text, useMantineTheme } from "@mantine/core"
import { Link } from "wouter"
import type { IPNIncomingFile, IPNNetMapPeerNode, IPNOutgoingFile } from "@webnet/tsconnect"
import { fmtSize } from "@webnet/utils"
function IncomingFileNotifier({ file }: { file: Omit<IPNIncomingFile, "done"> }) {
const id = `ipn.incomingFiles.${file.name}`
const show = useEffectEvent(() => {
notifications.show({
id,
autoClose: false,
allowClose: false,
loading: true,
title: (
<>
Incoming Taildrop file{file.declaredSize !== -1 && <> ({fmtSize(file.declaredSize)})</>}
</>
),
message: (
<>
<Text>{file.name}</Text>
{file.declaredSize !== -1 && (
<Progress animated value={(file.received / file.declaredSize) * 100} />
)}
</>
),
})
})
const hide = useEffectEvent(() => {
notifications.hide(id)
})
useEffect(() => {
show()
return hide
}, [])
useEffect(() => {
notifications.update({
id,
autoClose: false,
allowClose: false,
loading: true,
title: (
<>
Incoming Taildrop file{file.declaredSize !== -1 && <> ({fmtSize(file.declaredSize)})</>}
</>
),
message: (
<>
<Text>{file.name}</Text>
{file.declaredSize !== -1 && (
<Progress animated value={(file.received / file.declaredSize) * 100} />
)}
</>
),
})
}, [file, id])
return null
}
function OutgoingFileNotifier({
file,
peers,
}: {
file: IPNOutgoingFile
peers: Record<string, IPNNetMapPeerNode>
}) {
const theme = useMantineTheme()
const id = `ipn.outgoingFiles.${file.id}`
const show = useEffectEvent(() => {
notifications.show({
id,
autoClose: file.succeeded,
allowClose: file.finished,
loading: !file.finished,
color: file.finished ? (file.succeeded ? "green" : "red") : undefined,
title: (
<>
Outgoing Taildrop file{file.declaredSize !== -1 && <> ({fmtSize(file.declaredSize)})</>}
</>
),
message: (
<>
<Text>
{file.finished ? <>Sent</> : <>Sending</>} to:{" "}
<Text unstyled component="span" c={theme.primaryColor}>
{peers[file.peerID]?.name.split(".")[0] ?? "[unknown peer]"}
</Text>
</Text>
<Text>{file.name}</Text>
{file.declaredSize !== -1 && !file.finished && (
<Progress animated value={(file.sent / file.declaredSize) * 100} />
)}
</>
),
})
})
const hide = useEffectEvent(() => {
notifications.hide(id)
})
useEffect(() => {
show()
return hide
}, [])
useEffect(() => {
notifications.update({
id,
autoClose: file.succeeded,
allowClose: file.finished,
loading: !file.finished,
color: file.finished ? (file.succeeded ? "green" : "red") : undefined,
title: (
<>
Outgoing Taildrop file{file.declaredSize !== -1 && <> ({fmtSize(file.declaredSize)})</>}
</>
),
message: (
<>
<Text>
{file.finished ? <>Sent</> : <>Sending</>} to:{" "}
<Text unstyled component="span" c={theme.primaryColor}>
{peers[file.peerID]?.name.split(".")[0] ?? "[unknown peer]"}
</Text>
</Text>
<Text>{file.name}</Text>
{file.declaredSize !== -1 && !file.finished && (
<Progress animated value={(file.sent / file.declaredSize) * 100} />
)}
</>
),
})
}, [file, id])
return null
}
export function TaildropNotifier() {
const waitingFileCount = useIpnSelector(getWaitingFileCount)
const incomingFiles = useIpnSelector(getIncomingFiles)
const outgoingFiles = useIpnSelector(getOutgoingFiles)
const peers = useIpnSelector(getPeers)
useEffect(() => {
if (waitingFileCount) {
const message = (
<Group justify="space-between">
<Text>
Taildrop: {waitingFileCount} file{waitingFileCount > 1 && <>s</>} waiting
</Text>
<Button<typeof Link> href="/tailscale" component={Link}>
Go see
</Button>
</Group>
)
notifications.show({
id: "ipn.waitingFiles",
autoClose: false,
message,
})
notifications.update({
id: "ipn.waitingFiles",
autoClose: false,
message,
})
} else {
notifications.hide("ipn.waitingFiles")
}
}, [waitingFileCount])
return (
<>
{Object.values(incomingFiles).map((x) => (
<IncomingFileNotifier key={x.name} file={x} />
))}
{Object.values(outgoingFiles).map((x) => (
<OutgoingFileNotifier key={x.id} file={x} peers={peers} />
))}
</>
)
}
@@ -0,0 +1,22 @@
import { Drawer, Text } from "@mantine/core"
import { lazy, Suspense } from "react"
import { useTailshareDispatch, useTailshareSelector } from "../../store/store.ts"
import { isTailnetDrawerOpen, setTailnetDrawerOpen } from "../../store/slices/states.ts"
const TailnetDrawerContents = lazy(
() =>
import(/* webpackChunkName: 'component-TailnetDrawerContent' */ "./TailnetDrawerContents.tsx"),
)
export function TailnetDrawer() {
const opened = useTailshareSelector(isTailnetDrawerOpen)
const dispatch = useTailshareDispatch()
return (
<Drawer opened={opened} onClose={() => dispatch(setTailnetDrawerOpen(false))} title="Tailnet">
<Suspense fallback={<Text>Tailnet view loading...</Text>}>
<TailnetDrawerContents />
</Suspense>
</Drawer>
)
}
@@ -0,0 +1,67 @@
import { ActionIcon, Box, Code, CopyButton, Group, Paper, Stack, Text, Title } from "@mantine/core"
import { getIpnFileTargets, getPeers } from "@webnet/tsconnect-redux"
import { IpnContext, useIpnSelector } from "../../contexts/IpnContext"
import { hidden, pointer } from "../../styles/helpers"
import clsx from "clsx"
import { use } from "react"
import { UploadIcon } from "@phosphor-icons/react"
import { upload } from "@webnet/utils"
export default function TailnetDrawerContents_() {
const peers = useIpnSelector(getPeers)
const targets = useIpnSelector(getIpnFileTargets)
const ipn = use(IpnContext)
const targetSet = new Set(targets)
if (!ipn) return <Text c="red">Tailscale not started</Text>
return (
<Stack>
{Object.values(peers)
.sort((a, b) => {
if (a.online && !b.online) return -1
if (!a.online && b.online) return 1
return a.name < b.name ? -1 : 1
})
.map((peer) => (
<Paper key={peer.nodeKey} shadow="xs" p="sm" bg={peer.online ? "green" : "red"}>
<Box>
<Group justify="space-between">
<Title order={3}>{peer.name.split(".")[0]}</Title>
<Group>
{targetSet.has(peer.stableNodeID) && !!peer.online && (
<ActionIcon
variant="default"
onClick={async () => {
const file = await upload()
if (!file) return
ipn.sendFile(peer.stableNodeID, file.name, file.stream(), file.size)
}}
>
<UploadIcon />
</ActionIcon>
)}
</Group>
</Group>
{peer.addresses.map((addr) => (
<CopyButton key={addr} value={addr}>
{({ copy, copied }) => (
<Text>
{addr.match(/^\d+\.\d+\.\d+\.\d+$/) ? <>IPv4</> : <>IPv6</>}:{" "}
<Code className={pointer} onClick={copy}>
{addr}
</Code>
<Text ml="sm" className={clsx(copied || hidden)} unstyled component="span">
Copied!
</Text>
</Text>
)}
</CopyButton>
))}
</Box>
</Paper>
))}
</Stack>
)
}
@@ -0,0 +1,19 @@
import { createContext, type ReactNode, useEffect, useState } from "react"
import certUrl from "@webnet/tsconnect/cacert.pem"
import { useTailshareSelector } from "../store/store"
import { getIpnPrepareWillBuild } from "../store/slices/ipnPrepare"
export const CaCertContext = createContext<string | null>(null)
export function CaCertProvider({ children }: { children: ReactNode }) {
const [caCerts, setCaCerts] = useState<string | null>(null)
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
useEffect(() => {
if (!willBuild) return
fetch(certUrl)
.then((x) => x.text())
.then(setCaCerts)
}, [willBuild])
return <CaCertContext value={caCerts}>{children}</CaCertContext>
}
@@ -0,0 +1,191 @@
import {
createIpnContext,
createIpnStoreContext,
createIpnStoreProvider,
createUseIpnDispatch,
createUseIpnSelector,
createUseIpnStore,
useBuildIpn,
} from "@webnet/tsconnect-react"
import {
buildIpnStore,
getExitNode,
getIpnState,
getPeers,
type IpnStore,
} from "@webnet/tsconnect-redux"
import { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"
import { initIPN, InMemoryFileOps, IPN, WebStorageState } from "@webnet/tsconnect"
import { useClient, useLocalStorage } from "@webnet/react"
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store"
import {
getIpnPrepareWillBuild,
selectIpnPrepareSlice,
setIpnPrepareAuthKey,
setIpnPrepareControlURL,
setIpnPrepareHostname,
triggerIpnBuild,
type IpnPrepare,
} from "../store/slices/ipnPrepare"
import wasmUrl from "@webnet/tsconnect/main.wasm"
export const IpnStoreContext = createIpnStoreContext()
export const IpnContext = createIpnContext()
export const IpnStoreProvider = createIpnStoreProvider(IpnStoreContext)
export const useIpnStore = createUseIpnStore(IpnStoreContext)
export const useIpnSelector = createUseIpnSelector(IpnStoreContext)
export const useIpnDispatch = createUseIpnDispatch(IpnStoreContext)
type IpnBuilder = Awaited<ReturnType<typeof initIPN>>
export function IpnProvider({ children }: { children: ReactNode }) {
const storeRef = useRef<IpnStore | null>(null)
if (!storeRef.current) {
storeRef.current = buildIpnStore()
}
const store = storeRef.current
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
const [ipnBuilder, setIpnBuilder] = useState<IpnBuilder | null>(null)
useEffect(() => {
if (!willBuild) return
initIPN(wasmUrl).then((ipnBuilder) => setIpnBuilder(() => ipnBuilder))
}, [willBuild])
const client = useClient()
const builderParams = useMemo(() => {
const builderParams: Parameters<IpnBuilder>[0] = {}
if (!willBuild) return builderParams
builderParams.fileOps = new InMemoryFileOps()
if (client && localStorage) {
builderParams.stateStorage = new WebStorageState(localStorage, "ipn:")
}
if (willBuild.hostname) builderParams.hostname = willBuild.hostname
if (willBuild.controlURL) builderParams.controlURL = willBuild.controlURL
if (willBuild.authKey) builderParams.authKey = willBuild.authKey
return builderParams
}, [willBuild, client])
const ipn = useBuildIpn(store, client && willBuild ? ipnBuilder : null, builderParams)
if (typeof window !== "undefined") Object.assign(window, { ipn })
return (
<IpnStoreProvider store={store}>
{ipn && (
<>
<AutoLogin ipn={ipn} />
<AutoExitNode ipn={ipn} />
</>
)}
{client && (
<>
<AutoInit />
<AutoConfig />
</>
)}
<IpnContext value={ipn}>{children}</IpnContext>
</IpnStoreProvider>
)
}
function AutoLogin({ ipn }: { ipn: IPN }) {
const state = useIpnSelector(getIpnState)
useEffect(() => {
if (state === "NeedsLogin") ipn.login()
}, [ipn, state])
return null
}
function AutoExitNode({ ipn }: { ipn: IPN }) {
const currentExitNode = useIpnSelector(getExitNode)
const peers = useIpnSelector(getPeers)
const state = useIpnSelector(getIpnState)
const [savedExitNode, setSavedExitNode, clearSavedExitNode] = useLocalStorage("ipn#exitNode")
const setExitNode = useEffectEvent(() => {
if (!savedExitNode) return
if (!Object.values(peers).some((x) => x.stableNodeID === savedExitNode && x.exitNodeOption))
return
ipn.setExitNode(savedExitNode)
})
useEffect(() => {
if (state !== "Running") return
setExitNode()
}, [state])
const updateSavedExitNode = useEffectEvent((exitNode: string | null) => {
if (exitNode) setSavedExitNode(exitNode)
else clearSavedExitNode()
})
const exitNodeChangedRef = useRef(false)
useEffect(() => {
if (state !== "Running") return
if (currentExitNode) exitNodeChangedRef.current = true
if (!exitNodeChangedRef.current) return
updateSavedExitNode(currentExitNode)
}, [state, currentExitNode])
return null
}
function AutoInit() {
const state = useIpnSelector(getIpnState)
const store = useTailshareStore()
const [autostart, setAutostart] = useLocalStorage("ipn#auto")
useEffect(() => {
if (state === "Running") {
setAutostart((prev) => (prev === "0" ? "0" : "1"))
}
}, [state, setAutostart])
useEffect(() => {
if (autostart !== "1") return
const timeout = window.setTimeout(() => triggerIpnBuild(store), 1000)
return () => {
window.clearTimeout(timeout)
}
}, [store, autostart])
return null
}
function AutoConfig() {
const ipnPrepare = useTailshareSelector(selectIpnPrepareSlice)
const dispatch = useTailshareDispatch()
const [hostname, setHostname, clearHostname] = useLocalStorage("ipn#hostname")
const [controlURL, setControlURL, clearControlURL] = useLocalStorage("ipn#controlURL")
const [authKey, setAuthKey, clearAuthKey] = useLocalStorage("ipn#authKey")
const [hasPrepared, setHasPrepared] = useState(false)
const updateLocalStorage = useEffectEvent((ipnPrepare: IpnPrepare) => {
if (ipnPrepare.hostname) setHostname(ipnPrepare.hostname)
else clearHostname()
if (ipnPrepare.controlURL) setControlURL(ipnPrepare.controlURL)
else clearControlURL()
if (ipnPrepare.authKey) setAuthKey(ipnPrepare.authKey)
else clearAuthKey()
})
useEffect(() => {
if (!hasPrepared) return
updateLocalStorage(ipnPrepare)
}, [ipnPrepare, hasPrepared])
const setPrepare = useEffectEvent(() => {
if (hostname) dispatch(setIpnPrepareHostname(hostname))
if (controlURL) dispatch(setIpnPrepareControlURL(controlURL))
if (authKey) dispatch(setIpnPrepareAuthKey(authKey))
setHasPrepared(true)
})
useEffect(() => {
setPrepare()
}, [])
return null
}
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tailshare</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
import "@mantine/core/styles.css"
import "@mantine/notifications/styles.css"
import { createRoot } from "react-dom/client"
import { AppShell, MantineProvider } from "@mantine/core"
import { useDisclosure } from "@mantine/hooks"
import { Header } from "./components/shell/Header"
import { Navbar } from "./components/shell/Navbar"
import { CaCertProvider } from "./contexts/CaCertContext"
import { IpnProvider } from "./contexts/IpnContext"
import { Provider } from "react-redux"
import { createTailshareStore, type TailshareStore } from "./store/store"
import { Route, Switch } from "wouter"
import { pages } from "./pages"
import { theme } from "./theme"
import { Notifications } from "@mantine/notifications"
import { TaildropNotifier } from "./components/shell/TaildropNotifier"
import { TailnetDrawer } from "./components/tailnetDrawer/TailnetDrawer"
function AppStack({ store }: { store: TailshareStore }) {
return (
<Provider store={store}>
<CaCertProvider>
<IpnProvider>
<MantineProvider defaultColorScheme="auto" theme={theme}>
<App />
<Notifications />
</MantineProvider>
</IpnProvider>
</CaCertProvider>
</Provider>
)
}
function App() {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true)
return (
<AppShell
padding="md"
header={{ height: 60 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
>
<AppShell.Header>
<Header
mobileOpened={mobileOpened}
desktopOpened={desktopOpened}
toggleMobile={toggleMobile}
toggleDesktop={toggleDesktop}
/>
</AppShell.Header>
<Navbar />
<AppShell.Main>
<Switch>
{pages.map((page) => (
<Route key={page.path} path={page.path}>
<page.Component />
</Route>
))}
</Switch>
</AppShell.Main>
<TaildropNotifier />
<TailnetDrawer />
</AppShell>
)
}
const store = createTailshareStore()
if (typeof window !== "undefined") {
Object.assign(window, { store })
Object.defineProperty(window, "state", { get: () => store.getState() })
}
createRoot(document.querySelector("#root")!).render(<AppStack store={store} />)
+18
View File
@@ -0,0 +1,18 @@
declare module "*.module.css" {
const styles: Record<string, string>
export default styles
}
declare module "*.scss" {
const styles: Record<string, string>
export default styles
}
declare module "*.css" {}
declare module "*.pem" {
const url: string
export default url
}
declare module "*.wasm" {
const url: string
export default url
}
+5
View File
@@ -0,0 +1,5 @@
import { Title } from "@mantine/core"
export default function FilesRoute() {
return <Title>Files</Title>
}
+18
View File
@@ -0,0 +1,18 @@
import { lazy, type ComponentType } from "react"
const pagesRaw: { path: string; import: () => Promise<{ default: ComponentType }> }[] = [
{
path: "/",
import: () => import(/* webpackChunkName: 'page-root' */ "./root.tsx"),
},
{
path: "/files",
import: () => import(/* webpackChunkName: 'page-files' */ "./files.tsx"),
},
{
path: "/tailscale",
import: () => import(/* webpackChunkName: 'page-tailscale' */ "./tailscale.tsx"),
},
]
export const pages = pagesRaw.map((page) => ({ ...page, Component: lazy(page.import) }))
+5
View File
@@ -0,0 +1,5 @@
import { Title } from "@mantine/core"
export default function RootRoute() {
return <Title>Root route</Title>
}
@@ -0,0 +1,22 @@
.loginWrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.addressWrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.hidden {
visibility: hidden;
pointer-events: none;
}
.restartLine {
display: flex;
align-items: center;
justify-content: space-between;
}
+337
View File
@@ -0,0 +1,337 @@
import {
ActionIcon,
Box,
Button,
Checkbox,
Code,
CopyButton,
Divider,
Group,
List,
Loader,
Modal,
NativeSelect,
Space,
Stack,
Text,
TextInput,
Title,
Tooltip,
useMantineTheme,
} from "@mantine/core"
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store"
import {
getIpnPrepareAlreadyRunning,
getIpnPrepareWillBuild,
selectIpnPrepareSlice,
setIpnPrepareAuthKey,
setIpnPrepareControlURL,
setIpnPrepareHostname,
triggerIpnBuild,
} from "../store/slices/ipnPrepare"
import {
getExitNode,
getIpnSelfNode,
getIpnState,
getLoginUrl,
getPeers,
getWaitingFileCount,
getWaitingFiles,
} from "@webnet/tsconnect-redux"
import { IpnContext, useIpnSelector } from "../contexts/IpnContext"
import { use } from "react"
import styles from "./tailscale.scss"
import { CopyIcon, DownloadIcon, TrashIcon } from "@phosphor-icons/react"
import { useDisclosure } from "@mantine/hooks"
import clsx from "clsx"
import { useLocalStorage } from "@webnet/react"
import type { IpnClient, IPNWaitingFile } from "@webnet/tsconnect"
import { fmtSize, download } from "@webnet/utils"
import { setTailnetDrawerOpen } from "../store/slices/states"
function ConnectTailscale() {
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
const alreadyRunning = useTailshareSelector(getIpnPrepareAlreadyRunning)
const store = useTailshareStore()
return (
<Group>
<Button disabled={!!willBuild || alreadyRunning} onClick={() => triggerIpnBuild(store)}>
Enable Tailscale
</Button>
{!!willBuild && <Loader />}
{alreadyRunning && (
<Text>
Cannot start Tailscale, this is typically caused by another tab running Tailshare
</Text>
)}
</Group>
)
}
function TailscaleState() {
const state = useIpnSelector(getIpnState)
const loginUrl = useIpnSelector(getLoginUrl)
const theme = useMantineTheme()
if (state === "NeedsLogin" && loginUrl) {
return (
<Box className={styles.loginWrapper}>
<Button<"a"> component="a" href={loginUrl} target="_blank">
Login to Tailscale
</Button>
<Text ml="sm" c="dimmed">
This will open the Tailscale authorization flow in a new tab
</Text>
</Box>
)
}
return (
<>
<Text>
Tailscale state: <Code c={theme.primaryColor}>{state}</Code>
</Text>
{state === "Running" && (
<>
<TailscaleSelfNodeStatus />
</>
)}
</>
)
}
function TailscaleConfig() {
const [opened, { open, close }] = useDisclosure()
const exitNode = useIpnSelector(getExitNode)
const peers = useIpnSelector(getPeers)
const ipnPrepare = useTailshareSelector(selectIpnPrepareSlice)
const dispatch = useTailshareDispatch()
const ipn = use(IpnContext)
const [autostart, setAutostart] = useLocalStorage("ipn#auto", "0")
const requireRestart =
ipnPrepare.willBuild &&
(ipnPrepare.authKey !== ipnPrepare.willBuild.authKey ||
ipnPrepare.controlURL !== ipnPrepare.willBuild.controlURL ||
ipnPrepare.hostname !== ipnPrepare.willBuild.hostname)
return (
<>
<Group>
<Button onClick={open}>Tailscale config</Button>
<Button onClick={() => dispatch(setTailnetDrawerOpen(true))}>Tailnet</Button>
</Group>
<Modal centered opened={opened} onClose={close} title="Tailscale config">
{ipn && (
<NativeSelect
label="Exit node"
value={exitNode ?? ""}
onChange={(e) => ipn?.setExitNode(e.target.value)}
data={[
{ label: "No exit node", value: "" },
...Object.values(peers)
.filter((x) => x.exitNodeOption)
.map((peer) => ({ label: peer.name.split(".")[0], value: peer.stableNodeID })),
]}
/>
)}
<TextInput
mt="xs"
withAsterisk
label={<>Hostname</>}
placeholder="(Autogen on startup)"
value={ipnPrepare.hostname ?? ""}
onChange={(e) => dispatch(setIpnPrepareHostname(e.target.value))}
/>
<TextInput
mt="xs"
withAsterisk
label={<>Pregenerated authkey</>}
description={
// TODO: make this more clear that this has security implications
<>
This is a soft credential that will be stored in the browser - only set this if you
must, logging in manually is more secure
</>
}
placeholder="(Manual login flow)"
value={ipnPrepare.authKey ?? ""}
onChange={(e) => dispatch(setIpnPrepareAuthKey(e.target.value))}
/>
<TextInput
mt="xs"
withAsterisk
label={<>Control plane URL</>}
placeholder="(Default Tailscale control plane)"
value={ipnPrepare.controlURL ?? ""}
onChange={(e) => dispatch(setIpnPrepareControlURL(e.target.value))}
/>
<Checkbox
mt="md"
label="Automatically start Tailscale with the app"
checked={autostart === "1"}
onChange={(e) => setAutostart("" + +e.target.checked)}
/>
<Box className={clsx(styles.restartLine, requireRestart || styles.hidden)}>
<Text mt="xs" c="dimmed">
Restart required to apply the changes.
</Text>
<Button onClick={() => location.reload()}>Restart</Button>
</Box>
</Modal>
</>
)
}
function TailscaleSelfNodeStatus() {
const ipnSelfNode = useIpnSelector(getIpnSelfNode)
const exitNode = useIpnSelector(getExitNode)
const peers = useIpnSelector(getPeers)
const waitingFileCount = useIpnSelector(getWaitingFileCount)
const theme = useMantineTheme()
const ipn = use(IpnContext)
if (!ipnSelfNode) return null
const exitNodePeer = exitNode
? Object.values(peers).find((x) => x.stableNodeID === exitNode)
: undefined
return (
<Box>
<Text>
This node:{" "}
<Tooltip label={ipnSelfNode.name}>
<Code>{ipnSelfNode.name.split(".")[0]}</Code>
</Tooltip>
</Text>
<Text>
Taildrop:{" "}
{ipn?.fileOps ? (
waitingFileCount ? (
<Text component="span" c={theme.primaryColor}>
{waitingFileCount} file{waitingFileCount > 1 && <>s</>} received
</Text>
) : (
<Text component="span" c="green">
active
</Text>
)
) : (
<Text component="span" c="red">
inactive
</Text>
)}
</Text>
<Text>
{exitNode ? (
<>
Exit node:{" "}
{exitNodePeer ? (
<>
<Tooltip
label={
<>
{exitNodePeer.name}
<br />
{exitNodePeer.addresses[0]}
</>
}
>
<Code c={theme.primaryColor}>{exitNodePeer.name.split(".")[0]}</Code>
</Tooltip>
</>
) : (
<>(unknown peer)</>
)}
</>
) : (
<>No exit node</>
)}
</Text>
<Text>Adresses:</Text>
<List ml="xs">
{ipnSelfNode.addresses.map((address) => (
<List.Item mt="xs" key={address} classNames={{ itemLabel: styles.addressWrapper }}>
<Code mr="sm">{address}</Code>
<CopyButton value={address}>
{({ copied, copy }) => (
<>
<ActionIcon onClick={copy}>
<CopyIcon />
</ActionIcon>
{copied && <Text ml="sm">Copied!</Text>}
</>
)}
</CopyButton>
</List.Item>
))}
</List>
</Box>
)
}
function TailscaleWaitingFiles({
waitingFiles,
ipn,
}: {
waitingFiles: Record<string, IPNWaitingFile>
ipn: IpnClient
}) {
return (
<>
<Title order={2}>Taildrop received files</Title>
<Stack>
{Object.values(waitingFiles).map((file) => (
<Group key={file.name}>
<Text>{file.name}</Text>
<Space flex={1} />
<Text>{fmtSize(file.size)}</Text>
<ActionIcon
onClick={() => {
ipn.openWaitingFile(file.name).then((data) => {
download(data, file.name)
})
}}
>
<DownloadIcon />
</ActionIcon>
<ActionIcon bg="red" onClick={() => ipn.deleteWaitingFile(file.name)}>
<TrashIcon />
</ActionIcon>
</Group>
))}
</Stack>
</>
)
}
export default function TailscaleRoute() {
const waitingFiles = useIpnSelector(getWaitingFiles)
const ipn = use(IpnContext)
return (
<>
<Title>Tailscale netstack</Title>
<Text>
Enabling Tailscale on this app enables you to host your own or to access any file on your
tailnet.
</Text>
<Text>
To access any WebDAV server on the internet (even without CORS support), enable Tailscale
and an exit node.
</Text>
<Divider m={"md"} />
{!ipn ? <ConnectTailscale /> : <TailscaleState />}
<Divider m={"md"} />
<TailscaleConfig />
{!!Object.keys(waitingFiles).length && !!ipn && (
<>
<Divider m={"md"} />
<TailscaleWaitingFiles waitingFiles={waitingFiles} ipn={ipn} />
</>
)}
</>
)
}
@@ -0,0 +1,74 @@
import { createSlice, type PayloadAction, type Store } from "@reduxjs/toolkit"
export type IpnPrepare = {
hostname: string | null
controlURL: string | null
authKey: string | null
willBuild: null | Omit<IpnPrepare, "willBuild">
alreadyRunning: boolean
}
const initialState: IpnPrepare = {
hostname: null,
controlURL: null,
authKey: null,
willBuild: null,
alreadyRunning: false,
}
export const ipnPrepareSlice = createSlice({
name: "ipnPrepare",
initialState,
reducers: {
setIpnPrepareHostname: (state, action: PayloadAction<string | null>) => {
state.hostname = action.payload || null
},
setIpnPrepareControlURL: (state, action: PayloadAction<string | null>) => {
state.controlURL = action.payload || null
},
setIpnPrepareAuthKey: (state, action: PayloadAction<string | null>) => {
state.authKey = action.payload || null
},
triggerIpnPrepareBuild: (state, _: PayloadAction<void>) => {
state.willBuild = { ...state }
},
setIpnAlreadyRunning: (state, _: PayloadAction<void>) => {
state.alreadyRunning = true
},
},
selectors: {
getIpnPrepareHostname: (state) => state.hostname,
getIpnPrepareControlURL: (state) => state.controlURL,
getIpnPrepareAuthKey: (state) => state.authKey,
getIpnPrepareWillBuild: (state) => state.willBuild,
getIpnPrepareAlreadyRunning: (state) => state.alreadyRunning,
},
})
export const {
actions: { setIpnPrepareAuthKey, setIpnPrepareControlURL, setIpnPrepareHostname },
selectors: {
getIpnPrepareControlURL,
getIpnPrepareAuthKey,
getIpnPrepareHostname,
getIpnPrepareWillBuild,
getIpnPrepareAlreadyRunning,
},
selectSlice: selectIpnPrepareSlice,
} = ipnPrepareSlice
const {
actions: { triggerIpnPrepareBuild, setIpnAlreadyRunning },
} = ipnPrepareSlice
export function triggerIpnBuild(store: Store<{ ipnPrepare: IpnPrepare }>) {
const state = store.getState()
if (state.ipnPrepare.willBuild) return
navigator.locks.request("ipn.triggerBuild", { ifAvailable: true }, (lock) => {
if (lock) {
store.dispatch(triggerIpnPrepareBuild())
return new Promise(() => void 0)
} else {
store.dispatch(setIpnAlreadyRunning())
}
})
}
@@ -0,0 +1,27 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
export type States = {
tailnetDrawerOpen: boolean
}
const initialState: States = {
tailnetDrawerOpen: false,
}
export const statesSlice = createSlice({
name: "states",
initialState,
reducers: {
setTailnetDrawerOpen: (state, action: PayloadAction<boolean>) => {
state.tailnetDrawerOpen = action.payload
},
},
selectors: {
isTailnetDrawerOpen: (state) => state.tailnetDrawerOpen,
},
})
export const {
actions: { setTailnetDrawerOpen },
selectors: { isTailnetDrawerOpen },
selectSlice: selectStatesSlice,
} = statesSlice
+23
View File
@@ -0,0 +1,23 @@
import { combineSlices, configureStore } from "@reduxjs/toolkit"
import { ipnPrepareSlice } from "./slices/ipnPrepare"
import { createDispatchHook, createSelectorHook, createStoreHook } from "react-redux"
import { statesSlice } from "./slices/states"
const tailshareReducer = combineSlices(ipnPrepareSlice, statesSlice)
export function createTailshareStore(preloadedState?: TailshareState) {
return configureStore({
reducer: tailshareReducer,
devTools: { name: "tailshare" },
preloadedState,
})
}
export type TailshareState = ReturnType<typeof tailshareReducer>
export type TailshareStore = ReturnType<typeof createTailshareStore>
export type TailshareDispatch = TailshareStore["dispatch"]
export type TailshareAction = Parameters<TailshareDispatch>[0]
export const useTailshareStore = createStoreHook().withTypes<TailshareStore>()
export const useTailshareDispatch = createDispatchHook().withTypes<TailshareDispatch>()
export const useTailshareSelector = createSelectorHook().withTypes<TailshareState>()
@@ -0,0 +1,7 @@
.hidden {
visibility: hidden;
}
.pointer {
cursor: pointer;
}
+4
View File
@@ -0,0 +1,4 @@
import styles from "./helpers.scss"
export const hidden = styles.hidden
export const pointer = styles.pointer
@@ -0,0 +1,11 @@
.hiddendark {
@mixin dark {
display: none;
}
}
.hiddenlight {
@mixin light {
display: none;
}
}
@@ -0,0 +1,3 @@
import styles from "./light-dark.module.css"
export const hiddenDark = styles.hiddendark
export const hiddenLight = styles.hiddenlight
+6
View File
@@ -0,0 +1,6 @@
import { createTheme } from "@mantine/core"
export const theme = createTheme({
primaryColor: "grape",
autoContrast: true,
})
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true
},
"include": ["src", "../tsconnect/src/redux/store.ts"]
}
+96
View File
@@ -0,0 +1,96 @@
// @ts-check
const path = require("path")
const HtmlWebpackPlugin = require("html-webpack-plugin")
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin")
const isDev = process.env.NODE_ENV !== "production"
/** @type {import('webpack').Configuration} */
module.exports = {
mode: isDev ? "development" : "production",
devtool: isDev ? "eval-source-map" : "source-map",
entry: "./src/index.tsx",
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].[contenthash].js",
clean: true,
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: [
["@babel/preset-env", { targets: "defaults" }],
["@babel/preset-react", { runtime: "automatic" }],
"@babel/preset-typescript",
],
plugins: isDev ? ["react-refresh/babel"] : [],
},
},
},
{
test: /\.scss$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
modules: {
namedExport: false,
localIdentName: isDev ? "[name]__[local]--[hash:base64:5]" : "[hash:base64]",
},
},
},
"sass-loader",
],
},
{
test: /\.module.css$/,
include: [__dirname + "/src"],
use: [
"style-loader",
{
loader: "css-loader",
options: {
modules: {
namedExport: false,
localIdentName: isDev ? "[name]__[local]--[hash:base64:5]" : "[hash:base64]",
},
},
},
"postcss-loader",
],
},
{
test: /.css$/,
include: /node_modules/,
use: ["style-loader", "css-loader", "postcss-loader"],
},
{
test: /\.(wasm|pem)$/,
type: "asset",
},
],
},
plugins: [
new HtmlWebpackPlugin({ template: "./src/index.html" }),
...(isDev ? [new ReactRefreshWebpackPlugin()] : []),
],
devServer: {
hot: true,
port: 3000,
host: "0.0.0.0",
allowedHosts: "all",
historyApiFallback: true,
client: {
webSocketURL: "auto://0.0.0.0:0/ws",
},
},
}