Merge branch 'main' into fix/tailshare-css-module
CI / install (pull_request) Successful in 4m54s
CI / typetest (pull_request) Successful in 1m27s
CI / node-tests (pull_request) Successful in 1m40s
CI / format (pull_request) Successful in 1m36s
CI / typecheck (pull_request) Successful in 1m42s
CI / lint (pull_request) Successful in 1m36s
CI / browser-tests (pull_request) Successful in 2m58s

This commit is contained in:
2026-07-26 02:35:49 +02:00
16 changed files with 384 additions and 281 deletions
+2
View File
@@ -2,6 +2,7 @@
This file records all work in this repository that was assisted or authored by an AI agent. It is append-only; merge conflicts are resolved by keeping all lines (see `.gitattributes`).
- **`@webnet/tsconnect-worker` — deterministic async test cleanup**: `gpt-5.6-sol` fixed a flaky Go/WASM test teardown by awaiting the runtime shutdown started by `disconnect()`, replaced nearby fixed-delay MessagePort waits with direct call assertions and an ordered delivery barrier, and converted TTL tests to mocked timers so they wait for every relevant expiration rather than whichever equal-duration timer happens to fire first.
- **`@webnet/sftp` — stable keyless server identity**: GPT-5.6 Luna fixed `SFTPServer` to lazily generate and cache one ephemeral host key per server instance, and added a regression test covering sequential connections and host-key fingerprints.
- **`@webnet/vfs` — canonical path helpers**: Codex (GPT-5.6 Luna) added public POSIX VFS path utilities for normalization, resolution, parents, basenames, and joining. The helpers clamp traversal at the VFS root, preserve backslashes and Unicode names, and are covered by focused edge-case tests. MemoryVFS, FsaVFS, FTP, and SFTP now reuse the shared implementation; NodeVFS retains its separate traversal-rejection guard.
@@ -118,3 +119,4 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
- **`@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.
- **`@webnet/tailshare` — SharedWorker support, type fixes, config consolidation, and fileOps UI**: Claude Code (Claude Sonnet 4.6) rewired the IPN connection in `tailshare` to use `@webnet/tsconnect-worker` via `useBuildIpnWorker`, enabling multiple browser tabs to share a single IPN instance through a SharedWorker (with transparent fallback to main-thread mode when SharedWorker is unavailable). The `IPN` concrete class was replaced with the `IpnClient` interface throughout, and `IpnClientHandle` is used as the return type from the worker hook. The `ipnPrepare` Redux slice was removed entirely and replaced with an `IpnPrepareContext` (React context) that reads/writes a single `tailshare:config` localStorage key as a JSON object — consolidating `ipn#hostname`, `ipn#controlURL`, `ipn#authKey`, `ipn#exitNode`, `ipn#auto` and the new `useWorker` and `fileOps` settings into one key, with explicit defaults (`useWorker: true`, `fileOps: "memory"`) set in `parseConfig` so call sites need no inline fallbacks. The config modal (`TailscaleConfig`) gained a file-storage select (`memory` / `opfs`, with OPFS disabled when SharedWorker is unavailable) and a SharedWorker toggle; the mode indicator shows whether the active connection is `worker` or `main-thread`. In worker mode, selecting OPFS enables `fileOps: true` in the WorkerConfig so received files survive page reloads; in main-thread fallback the storage is always in-memory and a warning is shown if OPFS is selected without a worker. `autostart` remains a tri-state: `undefined` latches to `true` on the first `Running` event (so subsequent page loads auto-connect), `false` permanently disables autostart, and `true` triggers it immediately on load. `TailscaleRoute` shows a spinner rather than a disabled "Enable Tailscale" button during the worker connection window. `@webnet/react` gained a `useSharedWorkerAvailable()` hook (SSR-safe via `useSyncExternalStore`) replacing the inline pattern in `IpnContext.tsx`. `useBuildIpnWorker` in `@webnet/tsconnect-react` was extended to accept `(() => SharedWorker) | null` in its first overload (implementation already handled it). `@webnet/react/tsconfig.json` and `@webnet/tsconnect-react/tsconfig.json` both received `skipLibCheck: true` to resolve a type conflict between `@types/eslint-scope` and `eslint`'s built-in types.
+74
View File
@@ -10525,11 +10525,15 @@
"@mantine/notifications": "^9.3.1",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.11.2",
"@webnet/drive": "*",
"@webnet/http": "*",
"@webnet/react": "*",
"@webnet/tsconnect": "*",
"@webnet/tsconnect-react": "*",
"@webnet/tsconnect-redux": "*",
"@webnet/tsconnect-worker": "*",
"@webnet/utils": "*",
"@webnet/websocket": "*",
"clsx": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -10834,6 +10838,76 @@
"engines": {
"node": ">=14.17"
}
},
"node_modules/@turbo/darwin-64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.9.6.tgz",
"integrity": "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@turbo/darwin-arm64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.9.6.tgz",
"integrity": "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@turbo/linux-arm64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.9.6.tgz",
"integrity": "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@turbo/windows-64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.9.6.tgz",
"integrity": "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@turbo/windows-arm64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.9.6.tgz",
"integrity": "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
}
}
}
+1
View File
@@ -1,2 +1,3 @@
export { useClient } from "./useClient.js"
export { useLocalStorage } from "./useLocalStorage.js"
export { useSharedWorkerAvailable } from "./useSharedWorkerAvailable.js"
@@ -0,0 +1,9 @@
import { useSyncExternalStore } from "react"
const sub = () => () => void 0
const getClient = () => typeof SharedWorker !== "undefined"
const getServer = () => false
export function useSharedWorkerAvailable(): boolean {
return useSyncExternalStore(sub, getClient, getServer)
}
+4
View File
@@ -12,11 +12,15 @@
"@mantine/notifications": "^9.3.1",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.11.2",
"@webnet/drive": "*",
"@webnet/http": "*",
"@webnet/react": "*",
"@webnet/tsconnect": "*",
"@webnet/tsconnect-react": "*",
"@webnet/tsconnect-redux": "*",
"@webnet/tsconnect-worker": "*",
"@webnet/utils": "*",
"@webnet/websocket": "*",
"clsx": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -13,7 +13,7 @@ import {
useMantineTheme,
} from "@mantine/core"
import { NetworkXIcon, NetworkIcon, UserIcon } from "@phosphor-icons/react"
import { IpnContext, useIpnSelector } from "../contexts/IpnContext"
import { IpnContext, IpnPrepareContext, useIpnSelector } from "../contexts/IpnContext"
import {
getExitNode,
getIpnSelfNode,
@@ -22,12 +22,7 @@ import {
getPeers,
getWaitingFileCount,
} from "@webnet/tsconnect-redux"
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store"
import {
getIpnPrepareAlreadyRunning,
getIpnPrepareWillBuild,
triggerIpnBuild,
} from "../store/slices/ipnPrepare"
import { useTailshareDispatch } from "../store/store"
import { use, type ReactNode } from "react"
import { Link } from "wouter"
import { useDisclosure } from "@mantine/hooks"
@@ -119,16 +114,6 @@ function TailscaleQuickSettings({ onClose }: { onClose: () => void }) {
)
}
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)
@@ -174,27 +159,21 @@ function TailscaleRunningStatus() {
}
export function TailscaleStateButton() {
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
const alreadyRunning = useTailshareSelector(getIpnPrepareAlreadyRunning)
const store = useTailshareStore()
const prepare = use(IpnPrepareContext)!
const ipnState = useIpnSelector(getIpnState)
const ipnLoginUrl = useIpnSelector(getLoginUrl)
const [opened, { open, close }] = useDisclosure()
const running = !alreadyRunning && ipnState === "Running"
const running = ipnState === "Running"
const needsLogin = !running && ipnState === "NeedsLogin" && !!ipnLoginUrl
const building = !needsLogin && ipnState === "NoState" && !!willBuild
const notStarting = !building && !willBuild
const building = !needsLogin && !running && prepare.willBuild
const notStarting = !building && !prepare.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) {
if (running) {
label = "Tailscale running: open status"
tooltip = <TailscaleRunningStatus />
icon = <NetworkIcon />
@@ -223,11 +202,11 @@ export function TailscaleStateButton() {
<ActionIcon
onClick={() => {
if (needsLogin) window.open(ipnLoginUrl)
else if (notStarting) triggerIpnBuild(store)
else if (notStarting) prepare.build()
else open()
}}
variant={running || needsLogin || alreadyRunning ? "filled" : "default"}
color={needsLogin || alreadyRunning ? "red" : undefined}
variant={running || needsLogin ? "filled" : "default"}
color={needsLogin ? "red" : undefined}
size="xl"
aria-label={label}
>
@@ -1,19 +1,15 @@
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>
}
+175 -105
View File
@@ -6,6 +6,7 @@ import {
createUseIpnSelector,
createUseIpnStore,
useBuildIpn,
useBuildIpnWorker,
} from "@webnet/tsconnect-react"
import {
buildIpnStore,
@@ -14,20 +15,22 @@ import {
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"
type ContextType,
createContext,
type ReactNode,
useCallback,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from "react"
import { initIPN, InMemoryFileOps, InMemoryState } from "@webnet/tsconnect"
import type { IpnClient } from "@webnet/tsconnect"
import { useLocalStorage, useSharedWorkerAvailable } from "@webnet/react"
import wasmUrl from "@webnet/tsconnect/main.wasm"
import type { WorkerConfig } from "@webnet/tsconnect-worker"
export const IpnStoreContext = createIpnStoreContext()
export const IpnContext = createIpnContext()
@@ -37,63 +40,154 @@ export const useIpnStore = createUseIpnStore(IpnStoreContext)
export const useIpnSelector = createUseIpnSelector(IpnStoreContext)
export const useIpnDispatch = createUseIpnDispatch(IpnStoreContext)
type IpnBuilder = Awaited<ReturnType<typeof initIPN>>
export type TailshareConfig = {
hostname?: string
controlURL?: string
authKey?: string
exitNode?: string
autostart?: boolean
useWorker?: boolean
fileOps?: "memory" | "opfs"
}
function parseConfig(raw: string): TailshareConfig {
const cfg: TailshareConfig = { useWorker: true, fileOps: "memory" }
try {
const parsed = JSON.parse(raw)
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return cfg
if (typeof parsed.hostname === "string") cfg.hostname = parsed.hostname
if (typeof parsed.controlURL === "string") cfg.controlURL = parsed.controlURL
if (typeof parsed.authKey === "string") cfg.authKey = parsed.authKey
if (typeof parsed.exitNode === "string") cfg.exitNode = parsed.exitNode
if (typeof parsed.autostart === "boolean") cfg.autostart = parsed.autostart
if (typeof parsed.useWorker === "boolean") cfg.useWorker = parsed.useWorker
if (parsed.fileOps === "memory" || parsed.fileOps === "opfs") cfg.fileOps = parsed.fileOps
} catch {
// return defaults
}
return cfg
}
export const IpnPrepareContext = createContext<{
config: TailshareConfig
configAtBuild: TailshareConfig | null
setConfig: (patch: Partial<TailshareConfig>) => void
willBuild: boolean
build: () => void
workerAvailable: boolean
connectionMode: "worker" | "main-thread" | null
} | null>(null)
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 localStore = storeRef.current
const [ipnBuilder, setIpnBuilder] = useState<IpnBuilder | null>(null)
const workerAvailable = useSharedWorkerAvailable()
const [configRaw, setConfigRaw] = useLocalStorage("tailshare:config")
const config = useMemo(() => parseConfig(configRaw), [configRaw])
const setConfig = useCallback(
(patch: Partial<TailshareConfig>) => {
setConfigRaw((prev) => JSON.stringify({ ...parseConfig(prev), ...patch }))
},
[setConfigRaw],
)
const [willBuild, setWillBuild] = useState(false)
const build = useCallback(() => setWillBuild(true), [])
// Snapshot config at the moment willBuild becomes true; all connection decisions use this.
const configAtBuildRef = useRef<TailshareConfig | null>(null)
if (willBuild && !configAtBuildRef.current) configAtBuildRef.current = config
const configAtBuild = configAtBuildRef.current
const buildConfig = configAtBuild ?? config
const useWorker = !!buildConfig.useWorker && workerAvailable
// ── Worker path ───────────────────────────────────────────────────────────────
const buildWorker = useCallback(
() =>
new SharedWorker(
/* webpackChunkName: 'tsconnect-worker' */ new URL(
"@webnet/tsconnect-worker/worker",
import.meta.url,
),
),
[],
)
const workerConfig = useMemo<WorkerConfig>(
() => ({
wasmUrl: wasmUrl as string,
hostname: buildConfig.hostname || undefined,
controlURL: buildConfig.controlURL || undefined,
authKey: buildConfig.authKey || undefined,
stateStorage: "indexeddb",
fileOps: buildConfig.fileOps === "opfs",
fileOpsDir: "taildrop",
}),
[buildConfig.hostname, buildConfig.controlURL, buildConfig.authKey, buildConfig.fileOps],
)
const workerClient = useBuildIpnWorker(useWorker && willBuild ? buildWorker : null, workerConfig)
// ── Main-thread path ──────────────────────────────────────────────────────────
const [ipnBuilder, setIpnBuilder] = useState<Awaited<ReturnType<typeof initIPN>> | null>(null)
useEffect(() => {
if (!willBuild) return
initIPN(wasmUrl).then((ipnBuilder) => setIpnBuilder(() => ipnBuilder))
}, [willBuild])
if (!willBuild || useWorker) return
initIPN(wasmUrl).then((b) => setIpnBuilder(() => b))
}, [willBuild, useWorker])
const client = useClient()
const builderParams = useMemo(() => {
const builderParams: Parameters<IpnBuilder>[0] = {}
if (!willBuild) return builderParams
const params: Parameters<Exclude<typeof ipnBuilder, null>>[0] = {}
params.fileOps = new InMemoryFileOps()
params.stateStorage = new InMemoryState()
if (buildConfig.hostname) params.hostname = buildConfig.hostname
if (buildConfig.controlURL) params.controlURL = buildConfig.controlURL
if (buildConfig.authKey) params.authKey = buildConfig.authKey
return params
}, [buildConfig.hostname, buildConfig.controlURL, buildConfig.authKey])
builderParams.fileOps = new InMemoryFileOps()
const mainIpn = useBuildIpn(
localStore,
!useWorker && willBuild ? ipnBuilder : null,
builderParams,
)
if (client && localStorage) {
builderParams.stateStorage = new WebStorageState(localStorage, "ipn:")
}
// ── Active handle + store ─────────────────────────────────────────────────────
const ipn: IpnClient | null = useWorker ? workerClient : mainIpn
const activeStore = useWorker && workerClient ? workerClient.store : localStore
if (willBuild.hostname) builderParams.hostname = willBuild.hostname
if (willBuild.controlURL) builderParams.controlURL = willBuild.controlURL
if (willBuild.authKey) builderParams.authKey = willBuild.authKey
const connectionMode: "worker" | "main-thread" | null =
workerClient?.connectionMode ?? (mainIpn ? "main-thread" : null)
return builderParams
}, [willBuild, client])
const ipn = useBuildIpn(store, client && willBuild ? ipnBuilder : null, builderParams)
if (typeof window !== "undefined") Object.assign(window, { ipn })
const prepare = useMemo<NonNullable<ContextType<typeof IpnPrepareContext>>>(
() => ({ config, configAtBuild, setConfig, willBuild, build, workerAvailable, connectionMode }),
[config, configAtBuild, setConfig, willBuild, build, workerAvailable, connectionMode],
)
return (
<IpnStoreProvider store={store}>
<IpnStoreProvider store={activeStore}>
{ipn && (
<>
<AutoLogin ipn={ipn} />
<AutoExitNode ipn={ipn} />
<AutoExitNode ipn={ipn} config={config} setConfig={setConfig} />
</>
)}
{client && (
<>
<AutoInit />
<AutoConfig />
</>
)}
<IpnContext value={ipn}>{children}</IpnContext>
<AutoInit build={build} config={config} setConfig={setConfig} />
<IpnContext value={ipn}>
<IpnPrepareContext value={prepare}>{children}</IpnPrepareContext>
</IpnContext>
</IpnStoreProvider>
)
}
function AutoLogin({ ipn }: { ipn: IPN }) {
function AutoLogin({ ipn }: { ipn: IpnClient }) {
const state = useIpnSelector(getIpnState)
useEffect(() => {
if (state === "NeedsLogin") ipn.login()
@@ -101,91 +195,67 @@ function AutoLogin({ ipn }: { ipn: IPN }) {
return null
}
function AutoExitNode({ ipn }: { ipn: IPN }) {
function AutoExitNode({
ipn,
config,
setConfig,
}: {
ipn: IpnClient
config: TailshareConfig
setConfig: (patch: Partial<TailshareConfig>) => void
}) {
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))
const restoreExitNode = useEffectEvent(() => {
if (!config.exitNode) return
if (!Object.values(peers).some((x) => x.stableNodeID === config.exitNode && x.exitNodeOption))
return
ipn.setExitNode(savedExitNode)
ipn.setExitNode(config.exitNode)
})
useEffect(() => {
if (state !== "Running") return
setExitNode()
restoreExitNode()
}, [state])
const updateSavedExitNode = useEffectEvent((exitNode: string | null) => {
if (exitNode) setSavedExitNode(exitNode)
else clearSavedExitNode()
})
const exitNodeChangedRef = useRef(false)
const saveExitNode = useEffectEvent((exitNode: string | null) => {
setConfig({ exitNode: exitNode ?? undefined })
})
useEffect(() => {
if (state !== "Running") return
if (currentExitNode) exitNodeChangedRef.current = true
if (!exitNodeChangedRef.current) return
updateSavedExitNode(currentExitNode)
saveExitNode(currentExitNode)
}, [state, currentExitNode])
return null
}
function AutoInit() {
function AutoInit({
build,
config,
setConfig,
}: {
build: () => void
config: TailshareConfig
setConfig: (patch: Partial<TailshareConfig>) => void
}) {
const state = useIpnSelector(getIpnState)
const store = useTailshareStore()
const [autostart, setAutostart] = useLocalStorage("ipn#auto")
const markAutostart = useEffectEvent(() => {
if (config.autostart !== false) setConfig({ autostart: true })
})
useEffect(() => {
if (state === "Running") markAutostart()
}, [state])
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()
}, [])
if (!config.autostart) return
const timeout = window.setTimeout(build, 1000)
return () => window.clearTimeout(timeout)
}, [build, config.autostart])
return null
}
+75 -40
View File
@@ -19,16 +19,7 @@ import {
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 { useTailshareDispatch } from "../store/store"
import {
getExitNode,
getIpnSelfNode,
@@ -38,33 +29,25 @@ import {
getWaitingFileCount,
getWaitingFiles,
} from "@webnet/tsconnect-redux"
import { IpnContext, useIpnSelector } from "../contexts/IpnContext"
import { IpnContext, IpnPrepareContext, 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()
const prepare = use(IpnPrepareContext)!
return (
<Group>
<Button disabled={!!willBuild || alreadyRunning} onClick={() => triggerIpnBuild(store)}>
<Button disabled={prepare.willBuild} onClick={prepare.build}>
Enable Tailscale
</Button>
{!!willBuild && <Loader />}
{alreadyRunning && (
<Text>
Cannot start Tailscale, this is typically caused by another tab running Tailshare
</Text>
)}
{prepare.willBuild && <Loader />}
</Group>
)
}
@@ -105,22 +88,30 @@ 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 prepare = use(IpnPrepareContext)!
const { config, configAtBuild: built, setConfig, workerAvailable, connectionMode } = prepare
const requireRestart =
ipnPrepare.willBuild &&
(ipnPrepare.authKey !== ipnPrepare.willBuild.authKey ||
ipnPrepare.controlURL !== ipnPrepare.willBuild.controlURL ||
ipnPrepare.hostname !== ipnPrepare.willBuild.hostname)
built &&
(config.hostname !== built.hostname ||
config.controlURL !== built.controlURL ||
config.authKey !== built.authKey ||
!!config.useWorker !== !!built.useWorker ||
config.fileOps !== built.fileOps)
const opfsWithoutWorker = config.fileOps === "opfs" && (!workerAvailable || !config.useWorker)
return (
<>
<Group>
<Button onClick={open}>Tailscale config</Button>
<Button onClick={() => dispatch(setTailnetDrawerOpen(true))}>Tailnet</Button>
{connectionMode && (
<Text c="dimmed" size="sm">
Mode: <Code>{connectionMode}</Code>
</Text>
)}
</Group>
<Modal centered opened={opened} onClose={close} title="Tailscale config">
{ipn && (
@@ -141,8 +132,8 @@ function TailscaleConfig() {
withAsterisk
label={<>Hostname</>}
placeholder="(Autogen on startup)"
value={ipnPrepare.hostname ?? ""}
onChange={(e) => dispatch(setIpnPrepareHostname(e.target.value))}
value={config.hostname ?? ""}
onChange={(e) => setConfig({ hostname: e.target.value || undefined })}
/>
<TextInput
mt="xs"
@@ -156,26 +147,61 @@ function TailscaleConfig() {
</>
}
placeholder="(Manual login flow)"
value={ipnPrepare.authKey ?? ""}
onChange={(e) => dispatch(setIpnPrepareAuthKey(e.target.value))}
value={config.authKey ?? ""}
onChange={(e) => setConfig({ authKey: e.target.value || undefined })}
/>
<TextInput
mt="xs"
withAsterisk
label={<>Control plane URL</>}
placeholder="(Default Tailscale control plane)"
value={ipnPrepare.controlURL ?? ""}
onChange={(e) => dispatch(setIpnPrepareControlURL(e.target.value))}
value={config.controlURL ?? ""}
onChange={(e) => setConfig({ controlURL: e.target.value || undefined })}
/>
<NativeSelect
mt="xs"
label="File storage (taildrop)"
description={
config.fileOps === "opfs"
? "OPFS: files persist across reloads. Requires SharedWorker mode."
: "Memory: files are lost on reload."
}
value={config.fileOps}
onChange={(e) => setConfig({ fileOps: e.target.value as "memory" | "opfs" })}
data={[
{ label: "Memory (volatile)", value: "memory" },
{
label: "OPFS (persistent, requires SharedWorker)",
value: "opfs",
disabled: !workerAvailable,
},
]}
/>
{opfsWithoutWorker && (
<Text mt="xs" c="orange" size="sm">
OPFS requires SharedWorker mode. Taildrop will be disabled until SharedWorker is enabled
and the app restarted.
</Text>
)}
{workerAvailable && (
<Checkbox
mt="md"
label="Use SharedWorker (enables multi-tab support)"
checked={!!config.useWorker}
onChange={(e) => setConfig({ useWorker: e.target.checked })}
/>
)}
<Checkbox
mt="md"
label="Automatically start Tailscale with the app"
checked={autostart === "1"}
onChange={(e) => setAutostart("" + +e.target.checked)}
checked={!!config.autostart}
onChange={(e) => setConfig({ autostart: e.target.checked || undefined })}
/>
<Box className={clsx(styles.restartLine, requireRestart || styles.hidden)}>
<Text mt="xs" c="dimmed">
Restart required to apply the changes.
{connectionMode === "worker"
? "Close all Tailshare tabs, then reload to apply changes to the shared worker."
: "Restart required to apply the changes."}
</Text>
<Button onClick={() => location.reload()}>Restart</Button>
</Box>
@@ -250,7 +276,7 @@ function TailscaleSelfNodeStatus() {
<>No exit node</>
)}
</Text>
<Text>Adresses:</Text>
<Text>Addresses:</Text>
<List ml="xs">
{ipnSelfNode.addresses.map((address) => (
<List.Item mt="xs" key={address} classNames={{ itemLabel: styles.addressWrapper }}>
@@ -310,6 +336,7 @@ function TailscaleWaitingFiles({
export default function TailscaleRoute() {
const waitingFiles = useIpnSelector(getWaitingFiles)
const ipn = use(IpnContext)
const prepare = use(IpnPrepareContext)!
return (
<>
@@ -323,7 +350,15 @@ export default function TailscaleRoute() {
and an exit node.
</Text>
<Divider m={"md"} />
{!ipn ? <ConnectTailscale /> : <TailscaleState />}
{!ipn && !prepare.willBuild ? (
<ConnectTailscale />
) : !ipn ? (
<Group>
<Loader />
</Group>
) : (
<TailscaleState />
)}
<Divider m={"md"} />
<TailscaleConfig />
{!!Object.keys(waitingFiles).length && !!ipn && (
@@ -1,74 +0,0 @@
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())
}
})
}
+1 -2
View File
@@ -1,9 +1,8 @@
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)
const tailshareReducer = combineSlices(statesSlice)
export function createTailshareStore(preloadedState?: TailshareState) {
return configureStore({
+1 -1
View File
@@ -36,7 +36,7 @@ export function useBuildIpn(
}
export function useBuildIpnWorker(
worker: () => SharedWorker,
worker: (() => SharedWorker) | null,
config?: WorkerConfig | null,
runParams?: Parameters<IPN["run"]>[0] | null,
): IpnClientHandle | null
+1
View File
@@ -4,6 +4,7 @@
"module": "ES2020",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
@@ -88,5 +88,6 @@ if (WASM_BUILT) {
h.disconnect()
h.disconnect()
assert.equal(h.running, false)
await h.shutdown()
})
}
+17 -10
View File
@@ -102,12 +102,14 @@ suite("claim", () => {
assert.throws(() => registry.claim(transferId), /transfer already claimed or expired/)
})
test("claim after TTL expiry throws and the resource was closed", async () => {
test("claim after TTL expiry throws and the resource was closed", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] })
const registry = createTransferRegistry({ ttl: 10 })
const { res, obj } = connResource()
const { transferId } = registry.detach(entryWith(res), 0)
await waitFor(() => obj.closed)
t.mock.timers.tick(10)
assert.equal(obj.closed, true)
assert.throws(() => registry.claim(transferId), /transfer already claimed or expired/)
})
})
@@ -140,7 +142,8 @@ suite("send", () => {
assert.deepEqual(flushed, ["chan"])
})
test("queue TTL expiry drops the envelope and closes referenced pending resources", async () => {
test("queue TTL expiry drops the envelope and closes referenced pending resources", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] })
const registry = createTransferRegistry({ ttl: 10 })
const { res, obj } = connResource()
const { transferId } = registry.detach(entryWith(res), 0)
@@ -154,7 +157,8 @@ suite("send", () => {
throw new Error("should not deliver")
})
await waitFor(() => obj.closed)
t.mock.timers.tick(10)
assert.equal(obj.closed, true)
assert.equal(registry.hasTransfer(transferId), false)
const flushed: string[] = []
@@ -175,13 +179,14 @@ suite("onEmpty", () => {
assert.equal(empties, 1)
})
test("fires when the last pending transfer expires", async () => {
test("fires when the last pending transfer expires", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] })
let empties = 0
const registry = createTransferRegistry({ ttl: 10, onEmpty: () => empties++ })
const { res, obj } = connResource()
const { res } = connResource()
registry.detach(entryWith(res), 0)
await waitFor(() => obj.closed)
await waitFor(() => empties === 1)
t.mock.timers.tick(10)
assert.equal(empties, 1)
})
})
@@ -299,7 +304,8 @@ suite("backlog lifecycle", () => {
assert.equal(chan.gen, 1)
})
test("TTL expiry closes backlogged accepted conns", async () => {
test("TTL expiry closes backlogged accepted conns", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] })
const registry = createTransferRegistry({ ttl: 10 })
const { res, obj, chan } = listenerResource()
const stray = {
@@ -310,7 +316,8 @@ suite("backlog lifecycle", () => {
}
registry.detach(entryWith(res), 0)
chan.backlog.push(stray)
await waitFor(() => obj.closed)
t.mock.timers.tick(10)
assert.equal(obj.closed, true)
assert.equal(stray.closed, true)
})
+12 -13
View File
@@ -748,11 +748,9 @@ suite("WorkerConn state transfer", () => {
port2.close()
})
test("transferState detaches via host and posts nothing on the port", async () => {
test("transferState detaches via host and posts nothing on the port", async (t) => {
const { port1, port2 } = new MessageChannel()
const sent: unknown[] = []
port2.onmessage = (e: MessageEvent) => sent.push(e.data)
port2.start()
const postMessage = t.mock.method(port1, "postMessage")
let detachedArg = -1
const host: TransferHost = {
@@ -771,24 +769,20 @@ suite("WorkerConn state transfer", () => {
await assert.rejects(conn.read(), /detached/)
await assert.rejects(conn.write(new Uint8Array([1])), /detached/)
await new Promise((r) => setTimeout(r, 20))
assert.equal(sent.length, 0, "transferState must not post on the conn port")
assert.equal(postMessage.mock.callCount(), 0, "transferState must not post on the conn port")
port2.close()
})
test("detach posts no close message and rejects further ops", async () => {
test("detach posts no close message and rejects further ops", async (t) => {
const { port1, port2 } = new MessageChannel()
const sent: unknown[] = []
port2.onmessage = (e: MessageEvent) => sent.push(e.data)
port2.start()
const postMessage = t.mock.method(port1, "postMessage")
const host: TransferHost = { detachResource: async () => connToken }
const conn = new WorkerConn(port1, "a", "b", false, 1, host)
conn.detach()
assert.equal(conn.closed, true)
await new Promise((r) => setTimeout(r, 20))
assert.equal(sent.length, 0, "detach must not post a close message")
assert.equal(postMessage.mock.callCount(), 0, "detach must not post a close message")
await assert.rejects(conn.read(), /detached/)
port2.close()
})
@@ -875,8 +869,13 @@ suite("IpnWorkerClient state transfer", () => {
const { client, workerPort } = await fakeConnect((msg, port) => {
if (msg.type === "hello") port.postMessage({ type: "ready" })
})
let barrierReached = false
client.onTransfer("barrier", () => {
barrierReached = true
})
workerPort.postMessage({ type: "transfer", channel: "ch", state: { hi: 1 }, tokens: [] })
await new Promise((r) => setTimeout(r, 20))
workerPort.postMessage({ type: "transfer", channel: "barrier", state: {}, tokens: [] })
await waitFor(() => barrierReached)
let got: { state: unknown } | null = null
client.onTransfer("ch", (t) => {