Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Last updated: 2026-04-06
| Machine key re-authentication | Yes | No | Extension shows `NeedsMachineAuth` state but cannot trigger re-auth; user must use admin console |
| Custom control server URL | Yes | No | Host `PrefsView` includes `controlURL` but the extension UI does not expose it; hardcoded to default Tailscale control plane |
| Auto-start on boot | Yes | N/A | Extension activates when browser launches; native host is started on demand by the browser |
| Auto-connect on start | Yes | No | After `init`, the extension requests `get-status` and `list-profiles` but does not send `up`; the node resumes whatever state it was last in. The user must toggle manually if the node was previously stopped. Last exit node is restored if the node is already running. |
| Auto-connect on start | Yes | Yes | Opt-in **Auto-connect on start** toggle in quick settings (off by default). When on, the extension sends `up` once per browser session if the first status after `init` reports `Stopped`/`NoState`; skipped for `NeedsLogin`/`NeedsMachineAuth`. A manual disconnect within the same session is respected even if the service worker restarts. Last exit node is restored separately when the node reaches `Running`. |


---
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/__test__/chrome-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ const chromeMock = {
set: (_items: Record<string, unknown>) => Promise.resolve(),
remove: (_key: string) => Promise.resolve(),
},
session: {
get: (_key: string) => Promise.resolve({} as Record<string, unknown>),
set: (_items: Record<string, unknown>) => Promise.resolve(),
remove: (_key: string) => Promise.resolve(),
},
onChanged: {
addListener: (fn: (changes: Record<string, chrome.storage.StorageChange>, area: string) => void) => {
storageChangedListeners.push(fn);
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/__test__/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function baseState(overrides: Partial<TailscaleState> = {}): TailscaleSta
supportsPingPeer: false,
supportsLogin: false,
reconnecting: false,
autoConnectOnStart: false,
...overrides,
};
}
Expand Down
128 changes: 128 additions & 0 deletions packages/shared/src/background/auto-connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { BackendState } from "../types";
import {
AUTO_CONNECT_PREF_KEY,
isAutoConnectHandled,
markAutoConnectHandled,
readAutoConnectPref,
shouldAutoConnect,
writeAutoConnectPref,
} from "./auto-connect";

type GetFn = (key: string) => Promise<Record<string, unknown>>;
type SetFn = (items: Record<string, unknown>) => Promise<void>;

// Treat `chrome.storage.session` as optional so the tests can swap it for a
// stub or remove it entirely (to exercise the missing-API fallback path).
type StorageWithSession = Omit<typeof chrome.storage, "session"> & {
session?: { get: GetFn; set: SetFn };
};
const storage = chrome.storage as unknown as StorageWithSession;

describe("shouldAutoConnect", () => {
const cases: Array<[BackendState, boolean]> = [
["NoState", true],
["Stopped", true],
["Starting", false],
["Running", false],
["NeedsLogin", false],
["NeedsMachineAuth", false],
["InUseOtherUser", false],
];

for (const [state, expected] of cases) {
it(`returns ${expected} for ${state}`, () => {
expect(shouldAutoConnect(state)).toBe(expected);
});
}
});

describe("autoConnectOnStart preference (storage.local)", () => {
beforeEach(() => {
(chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>) = vi.fn(
() => Promise.resolve({}),
);
(chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>) = vi.fn(
() => Promise.resolve(),
);
});

it("defaults to false when nothing is stored", async () => {
expect(await readAutoConnectPref()).toBe(false);
});

it("returns true when storage has the flag set", async () => {
(chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>) = vi.fn(
() => Promise.resolve({ [AUTO_CONNECT_PREF_KEY]: true }),
);
expect(await readAutoConnectPref()).toBe(true);
});

it("returns false for non-boolean stored values", async () => {
(chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>) = vi.fn(
() => Promise.resolve({ [AUTO_CONNECT_PREF_KEY]: "yes" }),
);
expect(await readAutoConnectPref()).toBe(false);
});

it("persists writes to storage.local", async () => {
const setSpy = vi.fn(() => Promise.resolve());
(chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>) = setSpy;
await writeAutoConnectPref(true);
expect(setSpy).toHaveBeenCalledWith({ [AUTO_CONNECT_PREF_KEY]: true });
await writeAutoConnectPref(false);
expect(setSpy).toHaveBeenCalledWith({ [AUTO_CONNECT_PREF_KEY]: false });
});
});

describe("auto-connect session flag (storage.session)", () => {
let getSpy: ReturnType<typeof vi.fn>;
let setSpy: ReturnType<typeof vi.fn>;
let originalSession: { get: GetFn; set: SetFn } | undefined;

beforeEach(() => {
originalSession = storage.session;
getSpy = vi.fn(((_key: string) => Promise.resolve({})) as GetFn);
setSpy = vi.fn(((_items: Record<string, unknown>) => Promise.resolve()) as SetFn);
storage.session = { get: getSpy as unknown as GetFn, set: setSpy as unknown as SetFn };
});

afterEach(() => {
storage.session = originalSession;
});

it("isAutoConnectHandled returns false when session storage is empty", async () => {
expect(await isAutoConnectHandled()).toBe(false);
});

it("isAutoConnectHandled returns true when the flag is set", async () => {
getSpy.mockResolvedValueOnce({ autoConnectHandled: true });
expect(await isAutoConnectHandled()).toBe(true);
});

it("markAutoConnectHandled writes the flag to session storage", async () => {
await markAutoConnectHandled();
expect(setSpy).toHaveBeenCalledWith({ autoConnectHandled: true });
});
});

describe("auto-connect session flag fallback (no chrome.storage.session)", () => {
let originalSession: { get: GetFn; set: SetFn } | undefined;

beforeEach(() => {
originalSession = storage.session;
storage.session = undefined;
});

afterEach(() => {
storage.session = originalSession;
});

it("isAutoConnectHandled returns false when session API is unavailable", async () => {
expect(await isAutoConnectHandled()).toBe(false);
});

it("markAutoConnectHandled is a no-op when session API is unavailable", async () => {
await expect(markAutoConnectHandled()).resolves.toBeUndefined();
});
});
39 changes: 39 additions & 0 deletions packages/shared/src/background/auto-connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { BackendState } from "../types";

export const AUTO_CONNECT_PREF_KEY = "autoConnectOnStart";
const SESSION_KEY = "autoConnectHandled";

// `chrome.storage.session` is MV3-only and may be missing on older builds.
// Resolve lazily so the module loads even if the API is absent.
function sessionArea(): chrome.storage.StorageArea | undefined {
return chrome.storage?.session;
}

export async function readAutoConnectPref(): Promise<boolean> {
const result = await chrome.storage.local.get(AUTO_CONNECT_PREF_KEY);
return result[AUTO_CONNECT_PREF_KEY] === true;
}

export async function writeAutoConnectPref(value: boolean): Promise<void> {
await chrome.storage.local.set({ [AUTO_CONNECT_PREF_KEY]: value });
}

export async function isAutoConnectHandled(): Promise<boolean> {
const area = sessionArea();
if (!area) return false;
const result = await area.get(SESSION_KEY);
return result[SESSION_KEY] === true;
}

export async function markAutoConnectHandled(): Promise<void> {
const area = sessionArea();
if (!area) return;
await area.set({ [SESSION_KEY]: true });
}

// Backend states where sending `up` is the right action to bring the node online.
// `NeedsLogin` / `NeedsMachineAuth` require user-driven flows; `Starting` / `Running`
// are already moving toward connected; `InUseOtherUser` is not actionable here.
export function shouldAutoConnect(state: BackendState): boolean {
return state === "Stopped" || state === "NoState";
}
Loading
Loading