feat(tailshare): block multiple instances across tabs

This commit is contained in:
2026-06-17 22:20:38 +00:00
parent 3637dbd44d
commit 5806cabeeb
4 changed files with 80 additions and 26 deletions
@@ -21,8 +21,12 @@ import {
getPeers,
getWaitingFileCount,
} from "@webnet/tsconnect-redux"
import { useTailshareDispatch, useTailshareSelector } from "../store/store"
import { getIpnPrepareWillBuild, triggerIpnPrepareBuild } from "../store/slices/ipnPrepare"
import { 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"
@@ -37,7 +41,12 @@ function TailscaleQuickSettings() {
const ipn = use(IpnContext)
const theme = useMantineTheme()
if (!ipn) return null
if (!ipn)
return (
<Button<typeof Link> mt="md" href="/tailscale" component={Link}>
Tailscale settings
</Button>
)
return (
<>
@@ -97,6 +106,16 @@ function TailscaleQuickSettings() {
)
}
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)
@@ -142,22 +161,27 @@ function TailscaleRunningStatus() {
}
export function TailscaleStateButton() {
const dispatch = useTailshareDispatch()
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 = ipnState === "Running"
const needsLogin = ipnState === "NeedsLogin" && !!ipnLoginUrl
const building = ipnState === "NoState" && !!willBuild
const notStarting = !willBuild
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 (running) {
if (alreadyRunning) {
label = "Tailscale cannot start: open status"
tooltip = <TailscaleAlreadyRunningStatus />
icon = <NetworkXIcon />
} else if (running) {
label = "Tailscale running: open status"
tooltip = <TailscaleRunningStatus />
icon = <NetworkIcon />
@@ -186,11 +210,11 @@ export function TailscaleStateButton() {
<ActionIcon
onClick={() => {
if (needsLogin) window.open(ipnLoginUrl)
else if (notStarting) dispatch(triggerIpnPrepareBuild())
else if (notStarting) triggerIpnBuild(store)
else open()
}}
variant={running || needsLogin ? "filled" : "default"}
color={needsLogin ? "red" : undefined}
variant={running || needsLogin || alreadyRunning ? "filled" : "default"}
color={needsLogin || alreadyRunning ? "red" : undefined}
size="xl"
aria-label={label}
>
@@ -17,7 +17,7 @@ import {
import { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"
import { initIPN, InMemoryFileOps, IPN, WebStorageState } from "@webnet/tsconnect"
import { useClient } from "../hooks/useClient"
import { useTailshareDispatch, useTailshareSelector } from "../store/store"
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store"
import {
getIpnPrepareWillBuild,
selectIpnPrepareSlice,
@@ -25,7 +25,7 @@ import {
setIpnPrepareControlURL,
setIpnPrepareHostname,
setIpnPrepareTaildrop,
triggerIpnPrepareBuild,
triggerIpnBuild,
type IpnPrepare,
} from "../store/slices/ipnPrepare"
import wasmUrl from "@webnet/tsconnect/main.wasm"
@@ -136,7 +136,7 @@ function AutoExitNode({ ipn }: { ipn: IPN }) {
function AutoInit() {
const state = useIpnSelector(getIpnState)
const dispatch = useTailshareDispatch()
const store = useTailshareStore()
const [autostart, setAutostart] = useLocalStorage("ipn#auto")
useEffect(() => {
@@ -147,11 +147,11 @@ function AutoInit() {
useEffect(() => {
if (autostart !== "1") return
const timeout = window.setTimeout(() => dispatch(triggerIpnPrepareBuild()), 1000)
const timeout = window.setTimeout(() => triggerIpnBuild(store), 1000)
return () => {
window.clearTimeout(timeout)
}
}, [dispatch, autostart])
}, [store, autostart])
return null
}
+15 -7
View File
@@ -6,6 +6,7 @@ import {
Code,
CopyButton,
Divider,
Group,
List,
Loader,
Modal,
@@ -16,15 +17,16 @@ import {
Tooltip,
useMantineTheme,
} from "@mantine/core"
import { useTailshareDispatch, useTailshareSelector } from "../store/store"
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store"
import {
getIpnPrepareAlreadyRunning,
getIpnPrepareWillBuild,
selectIpnPrepareSlice,
setIpnPrepareAuthKey,
setIpnPrepareControlURL,
setIpnPrepareHostname,
setIpnPrepareTaildrop,
triggerIpnPrepareBuild,
triggerIpnBuild,
} from "../store/slices/ipnPrepare"
import {
getExitNode,
@@ -43,16 +45,22 @@ import clsx from "clsx"
import { useLocalStorage } from "../hooks/useLocalStorage"
function ConnectTailscale() {
const dispatch = useTailshareDispatch()
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
const alreadyRunning = useTailshareSelector(getIpnPrepareAlreadyRunning)
const store = useTailshareStore()
return (
<Box display={"flex"}>
<Button disabled={!!willBuild} onClick={() => dispatch(triggerIpnPrepareBuild())}>
<Group>
<Button disabled={!!willBuild || alreadyRunning} onClick={() => triggerIpnBuild(store)}>
Enable Tailscale
</Button>
{!!willBuild && <Loader ml={"sm"} />}
</Box>
{!!willBuild && <Loader />}
{alreadyRunning && (
<Text>
Cannot start Tailscale, this is typically caused by another tab running Tailshare
</Text>
)}
</Group>
)
}
@@ -1,4 +1,4 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
import { createSlice, type PayloadAction, type Store } from "@reduxjs/toolkit"
export type StateOptions = "disable" | "localStorage" | "sessionStorage"
export type TaildropOptions = "disable" | "memory"
@@ -14,6 +14,7 @@ export type IpnPrepare = {
// actually build the IPN
willBuild: null | Omit<IpnPrepare, "willBuild">
alreadyRunning: boolean
}
const initialState: IpnPrepare = {
taildrop: "memory",
@@ -21,6 +22,7 @@ const initialState: IpnPrepare = {
controlURL: null,
authKey: null,
willBuild: null,
alreadyRunning: false,
}
export const ipnPrepareSlice = createSlice({
@@ -42,6 +44,9 @@ export const ipnPrepareSlice = createSlice({
triggerIpnPrepareBuild: (state, _: PayloadAction<void>) => {
state.willBuild = { ...state }
},
setIpnAlreadyRunning: (state, _: PayloadAction<void>) => {
state.alreadyRunning = true
},
},
selectors: {
getIpnPrepareTaildrop: (state) => state.taildrop,
@@ -49,6 +54,7 @@ export const ipnPrepareSlice = createSlice({
getIpnPreapreControlURL: (state) => state.controlURL,
getIpnPrepareAuthKey: (state) => state.authKey,
getIpnPrepareWillBuild: (state) => state.willBuild,
getIpnPrepareAlreadyRunning: (state) => state.alreadyRunning,
},
})
@@ -58,7 +64,6 @@ export const {
setIpnPrepareControlURL,
setIpnPrepareHostname,
setIpnPrepareTaildrop,
triggerIpnPrepareBuild,
},
selectors: {
getIpnPreapreControlURL,
@@ -66,6 +71,23 @@ export const {
getIpnPrepareHostname,
getIpnPrepareTaildrop,
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())
}
})
}