Skip to content
Open
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
147 changes: 140 additions & 7 deletions src/backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ interface DataContext {
controlContext?: ControlContext;
}

interface ReconnectState {
baseSession?: string;
paneId?: string;
zoomed?: boolean;
updatedAt: number;
}

export interface ServerDependencies {
tmux: TmuxGateway;
ptyFactory: PtyFactory;
Expand Down Expand Up @@ -78,6 +85,17 @@ const isManagedMobileSession = (name: string): boolean => name.startsWith(MOBILE

const buildMobileSessionName = (clientId: string): string => `${MOBILE_SESSION_PREFIX}${clientId}`;

const normalizeClientId = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed || trimmed.length > 128) {
return undefined;
}
return trimmed;
};
Comment on lines +88 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "buildMobileSessionName" --type=ts -C2
echo "---"
rg -n "MOBILE_SESSION_PREFIX" --type=ts -C2

Repository: DagsHub/tmux-mobile

Length of output: 1426


🏁 Script executed:

rg -n "clientId" --type=ts -C2 | head -80

Repository: DagsHub/tmux-mobile

Length of output: 5617


🏁 Script executed:

rg -n "normalizeClientId\|context\.clientId\s*=" --type=ts -C3

Repository: DagsHub/tmux-mobile

Length of output: 45


🏁 Script executed:

rg -n "normalizeClientId" --type=ts -C3
echo "---"
rg -n "context\.clientId\s*=" --type=ts -C3

Repository: DagsHub/tmux-mobile

Length of output: 1861


🏁 Script executed:

rg -n "randomToken" --type=ts -C5

Repository: DagsHub/tmux-mobile

Length of output: 4259


🏁 Script executed:

rg -n "mobileSession\|buildMobileSessionName" --type=ts -C5 | head -100

Repository: DagsHub/tmux-mobile

Length of output: 45


🏁 Script executed:

rg -n "const mobileSession\|mobileSession\s*=" --type=ts -C5

Repository: DagsHub/tmux-mobile

Length of output: 45


🏁 Script executed:

rg -n "mobileSession" --type=ts -C3

Repository: DagsHub/tmux-mobile

Length of output: 1651


🏁 Script executed:

cat -n src/backend/server.ts | sed -n '440,475p'

Repository: DagsHub/tmux-mobile

Length of output: 1839


🏁 Script executed:

cat -n src/backend/server.ts | sed -n '515,545p'
echo "---"
rg -n "createGroupedSession\|killSession\|attachToSession" --type=ts -C2

Repository: DagsHub/tmux-mobile

Length of output: 1295


🏁 Script executed:

fd "tmux" --type=f --extension=ts | grep -E "(tmux|gateway)" | head -20

Repository: DagsHub/tmux-mobile

Length of output: 92


🏁 Script executed:

find . -name "*.ts" -type f | xargs grep -l "createGroupedSession\|killSession" | head -10

Repository: DagsHub/tmux-mobile

Length of output: 244


🏁 Script executed:

cat -n src/backend/tmux/types.ts | head -50
echo "---"
rg -n "createGroupedSession" src/backend/tmux/cli-executor.ts -C5

Repository: DagsHub/tmux-mobile

Length of output: 2530


🏁 Script executed:

web_search
tmux session name character restrictions limitations

Repository: DagsHub/tmux-mobile

Length of output: 156


🌐 Web query:

tmux session name character restrictions . : separators

💡 Result:

In tmux, : and . are not “just characters” in targets—they’re syntax separators:

  • A target is parsed as session:window.pane (colon separates session/window, period separates window/pane). [1]
  • The man page describes the same parsing: target-window is session:window, and target-pane may add .pane. [2]

Practical impact on session-name characters

  • Avoid : in session names (and . in window names) if you want to reliably address them by name, because tmux will treat those characters as structural separators when you use -t. [1][2]
  • If you need arbitrary characters in names (including separators), the robust workaround is to target by IDs instead of names (session IDs look like $1, window IDs @1, pane IDs %1). [1][2]
  • Spaces are allowed in names, but then you must quote the target in your shell (otherwise it splits into multiple arguments). (This is shell parsing, not tmux.) [2]

Recommendation

Use session names like proj_api or proj-api (no :), and rely on session:window.pane only for addressing, not naming. [1][2]

Sources: [1] tmux wiki “Advanced Use / Command targets”; [2] tmux(1) manual (target syntax).


normalizeClientId allows characters that are invalid in tmux session targets.

The clientId is used in buildMobileSessionName to form a tmux session name (tmux-mobile-client-<clientId>), which is then passed to tmux commands via the -t (target) flag. Tmux interprets : and . as syntax separators in targets (session:window.pane), so if a clientId contains these characters, tmux will misparse the target, causing commands to fail or target the wrong session/window/pane.

While the server generates clientIds using base64url (safe characters), clients can provide their own clientId in the auth message. Restrict normalizeClientId to [a-zA-Z0-9_-] to prevent unsafe characters.


export const createTmuxMobileServer = (
config: RuntimeConfig,
deps: ServerDependencies
Expand Down Expand Up @@ -115,21 +133,67 @@ export const createTmuxMobileServer = (
const terminalWss = new WebSocketServer({ noServer: true });
const controlClients = new Set<ControlContext>();
const terminalClients = new Set<DataContext>();
const reconnectStateByClientId = new Map<string, ReconnectState>();

let monitor: TmuxStateMonitor | undefined;
let started = false;
let stopPromise: Promise<void> | null = null;

const rememberReconnectState = (
context: ControlContext,
patch: Partial<Omit<ReconnectState, "updatedAt">>
): void => {
if (!context.clientId) {
return;
}

const existing = reconnectStateByClientId.get(context.clientId);
reconnectStateByClientId.set(context.clientId, {
baseSession: patch.baseSession ?? existing?.baseSession,
paneId: patch.paneId ?? existing?.paneId,
zoomed: patch.zoomed ?? existing?.zoomed,
updatedAt: Date.now()
});
};

const updateReconnectStateFromSnapshot = (
context: ControlContext,
state: TmuxStateSnapshot
): void => {
if (!context.authed || !context.attachedSession) {
return;
}

const attachedState = state.sessions.find((session) => session.name === context.attachedSession);
if (!attachedState) {
return;
}
const activeWindow =
attachedState.windowStates.find((windowState) => windowState.active) ?? attachedState.windowStates[0];
const activePane = activeWindow?.panes.find((pane) => pane.active) ?? activeWindow?.panes[0];
if (!activeWindow || !activePane) {
return;
}

rememberReconnectState(context, {
baseSession: context.baseSession,
paneId: activePane.id,
zoomed: activeWindow.zoomed
});
};

const broadcastState = (state: TmuxStateSnapshot): void => {
for (const client of controlClients) {
if (client.authed) {
sendJson(client.socket, { type: "tmux_state", state });
if (!client.authed) {
continue;
}
sendJson(client.socket, { type: "tmux_state", state });
updateReconnectStateFromSnapshot(client, state);
}
};

const getControlContext = (clientId: string): ControlContext | undefined =>
Array.from(controlClients).find((candidate) => candidate.clientId === clientId);
Array.from(controlClients).find((candidate) => candidate.authed && candidate.clientId === clientId);

const getOrCreateRuntime = (context: ControlContext): TerminalRuntime => {
if (context.runtime) {
Expand All @@ -152,6 +216,38 @@ export const createTmuxMobileServer = (
return runtime;
};

const tryRestoreClientView = async (context: ControlContext): Promise<void> => {
if (!context.attachedSession) {
return;
}

const reconnectState = reconnectStateByClientId.get(context.clientId);
if (!reconnectState?.paneId) {
return;
}

try {
await deps.tmux.selectPane(reconnectState.paneId);
} catch (error) {
logger.log("restore pane skipped", context.clientId, reconnectState.paneId, error);
return;
}

if (typeof reconnectState.zoomed !== "boolean") {
return;
}

try {
const windows = await deps.tmux.listWindows(context.attachedSession);
const activeWindow = windows.find((windowState) => windowState.active) ?? windows[0];
if (activeWindow && activeWindow.zoomed !== reconnectState.zoomed) {
await deps.tmux.zoomPane(reconnectState.paneId);
}
} catch (error) {
logger.log("restore zoom skipped", context.clientId, reconnectState.paneId, error);
}
};

const attachControlToBaseSession = async (
context: ControlContext,
baseSession: string
Expand All @@ -171,7 +267,9 @@ export const createTmuxMobileServer = (

context.baseSession = baseSession;
context.attachedSession = mobileSession;
rememberReconnectState(context, { baseSession });
runtime.attachToSession(mobileSession);
await tryRestoreClientView(context);
sendJson(context.socket, { type: "attached", session: mobileSession });
};

Expand All @@ -192,6 +290,16 @@ export const createTmuxMobileServer = (
"sessions discovered",
sessions.map((session) => `${session.name}:${session.attached ? "attached" : "detached"}`).join(",")
);

const preferredSession = context.baseSession
? sessions.find((session) => session.name === context.baseSession)
: undefined;
if (preferredSession) {
logger.log("reattach preferred session", preferredSession.name);
await attachControlToBaseSession(context, preferredSession.name);
return;
}

if (sessions.length === 0) {
await deps.tmux.createSession(config.defaultSession);
logger.log("created default session", config.defaultSession);
Expand Down Expand Up @@ -242,6 +350,7 @@ export const createTmuxMobileServer = (
return;
case "select_pane":
await deps.tmux.selectPane(message.paneId);
rememberReconnectState(context, { paneId: message.paneId });
return;
case "split_pane":
await deps.tmux.splitWindow(message.paneId, message.orientation);
Expand All @@ -251,6 +360,10 @@ export const createTmuxMobileServer = (
return;
case "zoom_pane":
await deps.tmux.zoomPane(message.paneId);
rememberReconnectState(context, {
paneId: message.paneId,
zoomed: !(reconnectStateByClientId.get(context.clientId)?.zoomed ?? false)
});
return;
case "capture_scrollback": {
const lines = message.lines ?? config.scrollbackLines;
Expand Down Expand Up @@ -298,11 +411,11 @@ export const createTmuxMobileServer = (
const context: ControlContext = {
socket,
authed: false,
clientId: randomToken(12),
clientId: "",
terminalClients: new Set<DataContext>()
};
controlClients.add(context);
logger.log("control ws connected", context.clientId);
logger.log("control ws connected");

socket.on("message", async (rawData) => {
const message = parseClientMessage(rawData.toString("utf8"));
Expand All @@ -324,14 +437,33 @@ export const createTmuxMobileServer = (
password: message.password
});
if (!authResult.ok) {
logger.log("control ws auth failed", context.clientId, authResult.reason ?? "unknown");
logger.log("control ws auth failed", authResult.reason ?? "unknown");
sendJson(socket, {
type: "auth_error",
reason: authResult.reason ?? "unauthorized"
});
return;
}

const requestedClientId = normalizeClientId(message.clientId);
if (requestedClientId) {
const existingContext = Array.from(controlClients).find(
(candidate) => candidate !== context && candidate.authed && candidate.clientId === requestedClientId
);
if (existingContext) {
controlClients.delete(existingContext);
await shutdownControlContext(existingContext);
if (
existingContext.socket.readyState === existingContext.socket.OPEN ||
existingContext.socket.readyState === existingContext.socket.CONNECTING
) {
existingContext.socket.close(4000, "reconnected");
}
}
}

context.clientId = requestedClientId ?? randomToken(12);
context.baseSession = reconnectStateByClientId.get(context.clientId)?.baseSession;
context.authed = true;
logger.log("control ws auth ok", context.clientId);
sendJson(socket, {
Expand Down Expand Up @@ -367,6 +499,7 @@ export const createTmuxMobileServer = (
});

socket.on("close", () => {
rememberReconnectState(context, { baseSession: context.baseSession });
controlClients.delete(context);
void shutdownControlContext(context);
logger.log("control ws closed", context.clientId);
Expand All @@ -390,7 +523,7 @@ export const createTmuxMobileServer = (
socket.close(4001, "auth required");
return;
}
const clientId = authMessage.clientId;
const clientId = normalizeClientId(authMessage.clientId);
if (!clientId) {
socket.close(4001, "unauthorized");
return;
Expand Down
3 changes: 2 additions & 1 deletion src/backend/tmux/cli-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { withoutTmuxEnv } from "../util/env.js";
const execFileAsync = promisify(execFile);

const SESSION_FMT = "#{session_name}\t#{session_attached}\t#{session_windows}";
const WINDOW_FMT = "#{window_index}\t#{window_name}\t#{window_active}\t#{window_panes}";
const WINDOW_FMT =
"#{window_index}\t#{window_name}\t#{window_active}\t#{window_zoomed_flag}\t#{window_panes}";
const PANE_FMT = "#{pane_index}\t#{pane_id}\t#{pane_current_command}\t#{pane_active}\t#{pane_width}x#{pane_height}";

interface TmuxCliExecutorOptions {
Expand Down
3 changes: 2 additions & 1 deletion src/backend/tmux/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ export const parseWindows = (raw: string): Omit<TmuxWindowState, "panes">[] =>
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [index, name, active, panes] = splitLine(line);
const [index, name, active, zoomed, panes] = splitLine(line);
return {
index: Number.parseInt(index, 10),
name,
active: active === "1",
zoomed: zoomed === "1",
paneCount: Number.parseInt(panes, 10)
};
});
Expand Down
1 change: 1 addition & 0 deletions src/backend/types/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface TmuxWindowState {
index: number;
name: string;
active: boolean;
zoomed: boolean;
paneCount: number;
panes: TmuxPaneState[];
}
Expand Down
13 changes: 12 additions & 1 deletion src/frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type ModifierMode = "off" | "sticky" | "locked";

const query = new URLSearchParams(window.location.search);
const token = query.get("token") ?? "";
const clientIdStorageKey = "tmux-mobile-client-id";

const wsOrigin = (() => {
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
Expand All @@ -46,6 +47,7 @@ export const App = () => {
const fitAddonRef = useRef<FitAddon | null>(null);
const controlSocketRef = useRef<WebSocket | null>(null);
const terminalSocketRef = useRef<WebSocket | null>(null);
const persistedClientIdRef = useRef(localStorage.getItem(clientIdStorageKey) ?? "");

const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [errorMessage, setErrorMessage] = useState<string>("");
Expand Down Expand Up @@ -263,7 +265,14 @@ export const App = () => {
const socket = new WebSocket(`${wsOrigin}/ws/control`);

socket.onopen = () => {
socket.send(JSON.stringify({ type: "auth", token, password: passwordValue || undefined }));
socket.send(
JSON.stringify({
type: "auth",
token,
password: passwordValue || undefined,
clientId: persistedClientIdRef.current || undefined
})
);
};

socket.onmessage = (event) => {
Expand All @@ -278,6 +287,8 @@ export const App = () => {
setPasswordErrorMessage("");
setAuthReady(true);
setNeedsPasswordInput(false);
persistedClientIdRef.current = message.clientId;
localStorage.setItem(clientIdStorageKey, message.clientId);
if (message.requiresPassword && passwordValue) {
localStorage.setItem("tmux-mobile-password", passwordValue);
} else {
Expand Down
1 change: 1 addition & 0 deletions src/frontend/types/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface TmuxWindowState {
index: number;
name: string;
active: boolean;
zoomed: boolean;
paneCount: number;
panes: TmuxPaneState[];
}
Expand Down
4 changes: 2 additions & 2 deletions tests/backend/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ describe("tmux parser", () => {
});

test("parses windows and panes", () => {
const windows = parseWindows("0\tbash\t1\t2");
expect(windows[0]).toEqual({ index: 0, name: "bash", active: true, paneCount: 2 });
const windows = parseWindows("0\tbash\t1\t0\t2");
expect(windows[0]).toEqual({ index: 0, name: "bash", active: true, zoomed: false, paneCount: 2 });

const panes = parsePanes("0\t%1\tbash\t1\t120x30");
expect(panes[0]).toEqual({
Expand Down
7 changes: 7 additions & 0 deletions tests/harness/fakeTmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface WindowNode {
index: number;
name: string;
active: boolean;
zoomed: boolean;
panes: PaneNode[];
}

Expand Down Expand Up @@ -42,6 +43,7 @@ const buildDefaultSession = (name: string): SessionNode => ({
index: 0,
name: "shell",
active: true,
zoomed: false,
panes: [
{
index: 0,
Expand Down Expand Up @@ -88,6 +90,7 @@ export class FakeTmuxGateway implements TmuxGateway {
index: window.index,
name: window.name,
active: window.active,
zoomed: window.zoomed,
paneCount: window.panes.length
}))
);
Expand Down Expand Up @@ -129,6 +132,7 @@ export class FakeTmuxGateway implements TmuxGateway {
index: window.index,
name: window.name,
active: window.active,
zoomed: window.zoomed,
panes: window.panes.map((pane) => ({ ...pane }))
}))
});
Expand Down Expand Up @@ -158,6 +162,7 @@ export class FakeTmuxGateway implements TmuxGateway {
index: nextIndex + 1,
name: `win-${nextIndex + 1}`,
active: true,
zoomed: false,
panes: [
{
index: 0,
Expand Down Expand Up @@ -226,6 +231,8 @@ export class FakeTmuxGateway implements TmuxGateway {

public async zoomPane(paneId: string): Promise<void> {
this.calls.push(`zoomPane:${paneId}`);
const { window } = this.findByPane(paneId);
window.zoomed = !window.zoomed;
}

public async capturePane(paneId: string, lines: number): Promise<string> {
Expand Down
Loading