Compare commits
27
Commits
c39e5c2177
...
84437ed033
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84437ed033 | ||
|
|
a291b371f6 | ||
|
|
120baafa82 | ||
|
|
dd9055276c | ||
|
|
21e1b4b666 | ||
|
|
5232539476 | ||
|
|
0a79ffdc2a | ||
|
|
1caa24239b | ||
|
|
f885ec50cd | ||
|
|
9101ee93bd | ||
|
|
52dd50c4b8 | ||
|
|
3dbd09a2c4 | ||
|
|
f0ef99c635 | ||
|
|
77b6686e0d | ||
|
|
2f5290797e | ||
|
|
5c968c281e | ||
|
|
8d3aa16e23 | ||
|
|
5a94656194 | ||
|
|
80ec3460f1 | ||
|
|
f09ca3285d | ||
|
|
49580e3f48 | ||
|
|
167387f81e | ||
|
|
af264ff3fd | ||
|
|
e14b168d88 | ||
|
|
a9d588d7fa | ||
|
|
8dfb67c25d | ||
|
|
0ef4529afe |
+4
-3
@@ -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.
|
||||
|
||||
Generated
+572
-2968
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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} />)
|
||||
Vendored
+18
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Title } from "@mantine/core"
|
||||
|
||||
export default function FilesRoute() {
|
||||
return <Title>Files</Title>
|
||||
}
|
||||
@@ -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) }))
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createTheme } from "@mantine/core"
|
||||
|
||||
export const theme = createTheme({
|
||||
primaryColor: "grape",
|
||||
autoContrast: true,
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user