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
7 changes: 6 additions & 1 deletion apps/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface CliArgs {
host: string;
forceHttp: boolean;
assumeYes: boolean;
lan: boolean;
showHelp: boolean;
}

Expand All @@ -21,6 +22,7 @@ Options:
-p, --port <number> Port (default: ${DEFAULT_NETWORK_PORT}, or PORT env, or ~/.workbench/config.json)
--host <hostname> Local hostname (default: ${DEFAULT_NETWORK_HOST}, or WORKBENCH_HOST env)
--http, --insecure Serve HTTP on localhost only (no mkcert)
--lan, --expose Bind on all interfaces and show LAN URLs
-y, --yes Install mkcert without prompting if missing
-h, --help Show this help

Expand All @@ -36,6 +38,7 @@ export function parseCliArgs(argv: string[]): CliArgs {
let host = process.env.WORKBENCH_HOST?.trim() || fileConfig.host;
let forceHttp = false;
let assumeYes = false;
let lan = false;
let showHelp = false;

for (let i = 0; i < argv.length; i++) {
Expand All @@ -46,6 +49,8 @@ export function parseCliArgs(argv: string[]): CliArgs {
assumeYes = true;
} else if (arg === "--http" || arg === "--insecure") {
forceHttp = true;
} else if (arg === "--lan" || arg === "--expose") {
lan = true;
} else if (arg === "--port" || arg === "-p") {
const next = argv[++i];
if (!next) throw new Error("Missing value for --port");
Expand All @@ -60,7 +65,7 @@ export function parseCliArgs(argv: string[]): CliArgs {
}
}

return { port, host, forceHttp, assumeYes, showHelp };
return { port, host, forceHttp, assumeYes, lan, showHelp };
}

export function printCliHelp(): void {
Expand Down
9 changes: 6 additions & 3 deletions apps/frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { apiClient } from "@/lib/api-client";
import { ensureOk } from "@/lib/api-error";

/** Auto-authenticate when the UI is served from the same machine as the server. */
export async function ensureLocalAuth(): Promise<void> {
const res = await apiClient.auth.local.$post();
/** Auto-authenticate when the UI is served from the same machine as the server.
* If an invite token is provided (from QR), it will be sent for validation.
*/
export async function ensureLocalAuth(token?: string): Promise<void> {
const body = token ? { token } : {};
const res = await apiClient.auth.local.$post({ json: body });
await ensureOk<{ ok: true }>(res);
}
102 changes: 101 additions & 1 deletion apps/frontend/src/modules/settings/pages/NetworkSettings.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onUnmounted, ref, watch } from "vue";
import QRCode from "qrcode";
import { toast } from "vue-sonner";
import { Input } from "@/components/ui/input";
import { useQueryClient } from "@tanstack/vue-query";
import { settingsKeys } from "@/modules/settings/queries/settings";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
Expand Down Expand Up @@ -37,9 +40,64 @@ const tlsSetupPrompt = computed(() =>
: "",
);

const lanMode = computed(() => networkData.value?.lanMode ?? false);
const lanUrls = computed(() => networkData.value?.lanUrls ?? []);
const lanIps = computed(() => networkData.value?.lanIps ?? []);
const inviteUrl = computed(() => networkData.value?.inviteUrl ?? "");
const primaryLanUrl = computed(() => lanUrls.value[0] ?? "");
const qrDataUrl = ref<string>("");

watch(
inviteUrl,
async (url) => {
if (url) {
try {
// Use the invite URL (contains rotating single-use token) for the QR
qrDataUrl.value = await QRCode.toDataURL(url, { margin: 1, width: 160 });
} catch {
qrDataUrl.value = "";
}
} else if (lanUrls.value.length > 0) {
// fallback to plain LAN URL if no invite token
try {
qrDataUrl.value = await QRCode.toDataURL(lanUrls.value[0], { margin: 1, width: 160 });
} catch {
qrDataUrl.value = "";
}
} else {
qrDataUrl.value = "";
}
},
{ immediate: true },
);

const hostInput = ref("");
const portInput = ref("");

const queryClient = useQueryClient();
let inviteRefresh: ReturnType<typeof setInterval> | undefined;

watch(
lanMode,
(active) => {
if (inviteRefresh) {
clearInterval(inviteRefresh);
inviteRefresh = undefined;
}
if (active) {
// Poll for fresh rotating invite token / QR (token changes every ~30s)
inviteRefresh = setInterval(() => {
queryClient.invalidateQueries({ queryKey: settingsKeys.network() });
}, 7000);
}
},
{ immediate: true },
);

onUnmounted(() => {
if (inviteRefresh) clearInterval(inviteRefresh);
});

watch(
networkData,
(n) => {
Expand Down Expand Up @@ -110,6 +168,15 @@ async function copyTlsSetupPrompt() {
toast.error("Could not copy to clipboard.");
}
}

async function copyLanUrl(url: string) {
try {
await navigator.clipboard.writeText(url);
toast.success("Copied LAN URL to clipboard.");
} catch {
toast.error("Could not copy to clipboard.");
}
}
</script>

<template>
Expand Down Expand Up @@ -231,6 +298,39 @@ async function copyTlsSetupPrompt() {
</div>
</div>

<div v-if="lanUrls.length" class="mt-3 border-t pt-3">
<div class="mb-2">
<div class="font-medium text-sm">LAN access</div>
<div class="text-xs text-muted-foreground">
Base LAN URLs. For secure access from other devices use the rotating invite link below (token changes every 30s and is single-use).
</div>
</div>

<!-- Plain LAN URLs (for reference) -->
<div class="flex flex-col gap-2 mb-3">
<div v-for="u in lanUrls" :key="u" class="flex items-center gap-2">
<code class="flex-1 truncate rounded bg-muted px-2 py-1 text-xs font-mono">{{ u }}</code>
<Button variant="outline" size="sm" :disabled="loading" @click="copyLanUrl(u)">Copy</Button>
</div>
</div>

<!-- Invite link + QR (recommended for phone) -->
<div v-if="inviteUrl" class="space-y-2">
<div class="text-xs font-medium">Invite link (rotates every 30s, single-use)</div>
<div class="flex items-center gap-2">
<code class="flex-1 truncate rounded bg-muted px-2 py-1 text-xs font-mono">{{ inviteUrl }}</code>
<Button variant="outline" size="sm" :disabled="loading" @click="copyLanUrl(inviteUrl)">Copy</Button>
</div>
<div v-if="qrDataUrl" class="mt-2 flex items-start gap-3">
<img :src="qrDataUrl" alt="Scan to open on another device" class="rounded border bg-white p-1" />
<div class="text-[10px] text-muted-foreground pt-1 leading-tight">
Scan with phone camera.<br />
Token auto-invalidates after use or 30s.
</div>
</div>
</div>
</div>

<div class="flex justify-end pt-2">
<Button :disabled="loading || !networkDirty" @click="saveNetwork">
Save
Expand Down
28 changes: 15 additions & 13 deletions apps/frontend/src/modules/terminal/layout/TerminalWorkspace.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, provide, ref, watch, type ComponentPublicInstance } from "vue";
import { useDebounceFn } from "@vueuse/core";
import { useDebounceFn, useMediaQuery } from "@vueuse/core";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import {
FolderTreeIcon,
Expand Down Expand Up @@ -76,6 +76,8 @@ const router = useRouter();
const queryClient = useQueryClient();
const { effectiveTheme } = useAppTheme();
const sessions = useTerminalSessions();
const isMobile = useMediaQuery('(max-width: 768px)');
const effectiveLayoutMode = computed(() => (isMobile.value ? 'page' : layoutMode.value) as 'split' | 'page');

/** Remount only on theme change; terminal switches reuse the instance (see below). */
const routerViewKey = computed(() => {
Expand Down Expand Up @@ -117,7 +119,7 @@ const gitItemIdsRef = ref<string[]>([]);
provide(contextQueueGitItemIdsKey, gitItemIdsRef);

const effectiveRouteName = computed(() => {
if (layoutMode.value !== "split") return route.name;
if (effectiveLayoutMode.value !== "split") return route.name;
if (splitAuxPanel.value === "explorer") return "explorer";
if (splitAuxPanel.value === "git") return "git";
return route.name;
Expand Down Expand Up @@ -158,13 +160,13 @@ const terminalTabItems = computed(() =>
);

const isGitVisible = computed(() =>
layoutMode.value === "split"
effectiveLayoutMode.value === "split"
? splitAuxPanel.value === "git"
: route.name === "git",
);

const isExplorerVisible = computed(() =>
layoutMode.value === "split"
effectiveLayoutMode.value === "split"
? splitAuxPanel.value === "explorer"
: route.name === "explorer",
);
Expand Down Expand Up @@ -201,7 +203,7 @@ function onSplitLayout(sizes: number[]) {
}

const hasPanelContent = computed(() => {
if (layoutMode.value === "split") {
if (effectiveLayoutMode.value === "split") {
return terminalTabItems.value.length > 0 || splitAuxPanel.value !== null;
}
return (
Expand All @@ -212,7 +214,7 @@ const hasPanelContent = computed(() => {
});

const activeId = computed(() => {
if (layoutMode.value === "split") {
if (effectiveLayoutMode.value === "split") {
return activeTerminalId.value;
}
if (route.name === "terminal") return route.params.terminalId as string;
Expand Down Expand Up @@ -359,7 +361,7 @@ async function addTerminal(choice: AddTerminalChoice = { kind: "shell" }) {

function openAuxPanel(type: "git" | "explorer") {
panelsState.value = activateWorktreeAuxPanel(panelsState.value, type);
if (layoutMode.value === "page") {
if (effectiveLayoutMode.value === "page") {
router.push({
name: type,
params: { worktreeId: props.worktreeId },
Expand Down Expand Up @@ -416,7 +418,7 @@ function toggleAuxPanel(type: "git" | "explorer") {
} else {
panelsState.value = { ...panelsState.value, explorer: false };
}
if (layoutMode.value === "page") {
if (effectiveLayoutMode.value === "page") {
navigateToFirstTerminal();
}
return;
Expand Down Expand Up @@ -480,7 +482,7 @@ function equalizeSplitPanes() {

<template>
<div class="flex min-h-0 flex-1 flex-col">
<header v-if="layoutMode === 'page'" class="flex shrink-0 items-stretch bg-sidebar">
<header v-if="effectiveLayoutMode === 'page'" class="flex shrink-0 items-stretch bg-sidebar">
<div
class="flex aspect-square shrink-0 items-stretch border-e border-border/60"
>
Expand Down Expand Up @@ -587,7 +589,7 @@ function equalizeSplitPanes() {
</div>
</div>
<RouterView
v-else-if="layoutMode === 'page'"
v-else-if="effectiveLayoutMode === 'page'"
:key="routerViewKey"
class="absolute inset-0 border-t"
/>
Expand Down Expand Up @@ -681,12 +683,12 @@ function equalizeSplitPanes() {
/>
</ResizablePanel>
<ResizableHandle
v-if="activeTerminalId && splitAuxPanel"
v-if="!isMobile && activeTerminalId && splitAuxPanel"
with-handle
@dblclick.prevent="equalizeSplitPanes"
/>
<ResizablePanel
v-if="splitAuxPanel === 'git'"
v-if="!isMobile && splitAuxPanel === 'git'"
id="split-git"
:min-size="SPLIT_AUX_MIN_SIZE"
:default-size="splitAuxDefaultSize"
Expand All @@ -695,7 +697,7 @@ function equalizeSplitPanes() {
<GitPanel :worktree-id="worktreeId" class="min-h-0 flex-1" />
</ResizablePanel>
<ResizablePanel
v-else-if="splitAuxPanel === 'explorer'"
v-else-if="!isMobile && splitAuxPanel === 'explorer'"
id="split-explorer"
:min-size="SPLIT_AUX_MIN_SIZE"
:default-size="splitAuxDefaultSize"
Expand Down
10 changes: 6 additions & 4 deletions apps/frontend/src/modules/workspace/layout/WorkspaceLayout.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, provide } from "vue";
import { useDebounceFn, useLocalStorage } from "@vueuse/core";
import { useDebounceFn, useLocalStorage, useMediaQuery } from "@vueuse/core";
import {
ResizableHandle,
ResizablePanel,
Expand Down Expand Up @@ -38,7 +38,9 @@ provide(workspaceSidebarKey, workspaceSidebar);
const terminalSessions = createTerminalSessionsStore();
provide(terminalSessionsKey, terminalSessions);

const sidebarDefaultSize = computed(() => clampWidth(sidebarWidth.value));
const isMobile = useMediaQuery('(max-width: 768px)');

const sidebarDefaultSize = computed(() => (isMobile.value ? 0 : clampWidth(sidebarWidth.value)));

const persistSidebarWidth = useDebounceFn((width: number) => {
sidebarWidth.value = clampWidth(width);
Expand All @@ -54,7 +56,7 @@ function onLayout(sizes: number[]) {
</script>

<template>
<div class="flex h-screen flex-col">
<div class="flex h-dvh min-h-dvh flex-col">
<ResizablePanelGroup
direction="horizontal"
class="min-h-0 flex-1"
Expand All @@ -77,7 +79,7 @@ function onLayout(sizes: number[]) {
/>
<WorkspaceSidebar :active-worktree-id="activeWorktreeId" />
</ResizablePanel>
<ResizableHandle v-show="!workspaceSidebar.isCollapsed.value" with-handle />
<ResizableHandle v-show="!isMobile && !workspaceSidebar.isCollapsed.value" with-handle />
<ResizablePanel :min-size="30" class="flex min-h-0 flex-col">
<div class="flex h-full min-h-0 flex-1 flex-col">
<slot />
Expand Down
13 changes: 12 additions & 1 deletion apps/frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,20 @@ router.beforeEach(async (to, from) => {
rememberSettingsReturnRoute(from.fullPath);
}

const search = new URLSearchParams(window.location.search);
const inviteToken = search.get("invite") || search.get("token") || undefined;

try {
await ensureLocalAuth();
await ensureLocalAuth(inviteToken);
queryClient.prefetchQuery(networkSettingsQueryOptions());

// Clean the token from the URL after successful validation (one-time use)
if (inviteToken) {
const url = new URL(window.location.href);
url.searchParams.delete("invite");
url.searchParams.delete("token");
window.history.replaceState({}, "", url.pathname + url.search + url.hash);
}
} catch {
// Auth failed (e.g. API down, or dev UI origin not allowed on Go). Skip prefetch.
}
Expand Down
2 changes: 1 addition & 1 deletion apps/server-go/internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ func RegisterRoutes(r *chi.Mux, version string, state *appstate.AppState, cookie
})

r.Route("/auth", func(r chi.Router) {
auth.RegisterRoutes(r, state.Session, cookieSecure)
auth.RegisterRoutes(r, state.Session, cookieSecure, state.Lan.ValidateAndConsumeInviteToken)
})

r.Route("/settings", func(r chi.Router) {
Expand Down
4 changes: 2 additions & 2 deletions apps/server-go/internal/appstate/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type AppState struct {
WorktreeWatcher *watcher.WorktreeWatcher
}

func New(port int, host string, forceHTTP bool) (*AppState, error) {
func New(port int, host string, forceHTTP bool, lanMode bool) (*AppState, error) {
storeFile := filepath.Join(config.DataDir(), "settings.json")
database, err := db.Open(config.DbPath())
if err != nil {
Expand All @@ -33,7 +33,7 @@ func New(port int, host string, forceHTTP bool) (*AppState, error) {
bus := events.NewBus()
return &AppState{
Session: auth.CreateSession(),
Lan: lan.New(port, host, forceHTTP),
Lan: lan.New(port, host, forceHTTP, lanMode),
SettingsStore: settings.NewFileStore(storeFile),
DB: database,
EventBus: bus,
Expand Down
Loading
Loading