feat: add QR code and password URL for Cloudflare tunnel (#112)
* feat: add QR code and password URL parameter for Cloudflare tunnel Add --tunnel-qr flag to generate scannable QR code for tunnel URL in terminal. Add --tunnel-password-url flag to include password as URL parameter for auto-login. Frontend now automatically detects and submits password token from URL. * docs: update README with tunnel QR and password URL features Add dev script for concurrent server, web, and UI watch mode.
This commit is contained in:
@@ -61,6 +61,8 @@ The whole project was built entirely with AI coding agents under my supervision.
|
||||
- Self-serve web updates (no CLI required)
|
||||
- Update and restart keeps previous server settings (port/password)
|
||||
- Cloudflare Quick Tunnel support for easy remote access (`--try-cf-tunnel`)
|
||||
- QR code generation for quick mobile access (`--tunnel-qr`)
|
||||
- Auto-login URL with embedded password (`--tunnel-password-url`)
|
||||
|
||||
### Desktop (macOS)
|
||||
|
||||
@@ -94,6 +96,8 @@ openchamber --port 8080 # Custom port
|
||||
openchamber --daemon # Background mode
|
||||
openchamber --ui-password secret # Password-protect UI
|
||||
openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access
|
||||
openchamber --try-cf-tunnel --tunnel-qr # Show QR code for easy mobile access
|
||||
openchamber --try-cf-tunnel --tunnel-password-url # Include password in URL for auto-login
|
||||
openchamber stop # Stop server
|
||||
openchamber update # Update to latest version
|
||||
```
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -2122,6 +2123,8 @@
|
||||
|
||||
"pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
|
||||
|
||||
"qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
|
||||
|
||||
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"dev:web": "bun run --cwd packages/web build:watch",
|
||||
"dev:web:server": "bun run --cwd packages/web dev:server:watch",
|
||||
"dev:web:full": "concurrently -n \"api,build\" -c \"cyan,magenta\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\"",
|
||||
"dev": "concurrently -n \"server,web,ui\" -c \"cyan,magenta,yellow\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\" \"bun run --cwd packages/ui dev\"",
|
||||
"start:web": "bun run --cwd packages/web start",
|
||||
"pack:web": "bun pm pack --cwd packages/web",
|
||||
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
|
||||
|
||||
@@ -84,6 +84,25 @@ interface SessionAuthGateProps {
|
||||
|
||||
type GateState = 'pending' | 'authenticated' | 'locked' | 'error';
|
||||
|
||||
const getTokenFromUrl = (): string | null => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('token');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearTokenFromUrl = () => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('token');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
||||
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
|
||||
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
@@ -94,6 +113,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
const [errorMessage, setErrorMessage] = React.useState('');
|
||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const hasResyncedRef = React.useRef(skipAuth);
|
||||
const hasTriedUrlTokenRef = React.useRef(false);
|
||||
|
||||
const checkStatus = React.useCallback(async () => {
|
||||
if (skipAuth) {
|
||||
@@ -140,6 +160,49 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Auto-login with URL token parameter
|
||||
React.useEffect(() => {
|
||||
if (skipAuth || state !== 'locked' || hasTriedUrlTokenRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlToken = getTokenFromUrl();
|
||||
if (!urlToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriedUrlTokenRef.current = true;
|
||||
clearTokenFromUrl();
|
||||
|
||||
// Auto-submit the password from URL
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage('');
|
||||
|
||||
submitPassword(urlToken)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
setPassword('');
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
setErrorMessage('URL token invalid. Please enter password manually.');
|
||||
setState('locked');
|
||||
return;
|
||||
}
|
||||
setErrorMessage('Unexpected response from server.');
|
||||
setState('error');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to submit URL token:', error);
|
||||
setErrorMessage('Network error. Check connection and retry.');
|
||||
setState('error');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
}, [skipAuth, state, isSubmitting]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) {
|
||||
return;
|
||||
|
||||
+44
-9
@@ -50,10 +50,30 @@ function generateRandomPassword(length = 16) {
|
||||
return password;
|
||||
}
|
||||
|
||||
async function displayTunnelQrCode(url) {
|
||||
try {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
console.log('\n📱 Scan this QR code to access the tunnel:\n');
|
||||
qrcode.default.generate(url, { small: true });
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not generate QR code:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTunnelUrl(baseUrl, password, includePassword) {
|
||||
if (!includePassword || !password) {
|
||||
return baseUrl;
|
||||
}
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set('token', password);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined;
|
||||
const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword, tryCfTunnel: false };
|
||||
const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword, tryCfTunnel: false, tunnelQr: false, tunnelPasswordUrl: false };
|
||||
let command = 'serve';
|
||||
|
||||
const consumeValue = (currentIndex, inlineValue) => {
|
||||
@@ -99,6 +119,12 @@ function parseArgs() {
|
||||
case 'try-cf-tunnel':
|
||||
options.tryCfTunnel = true;
|
||||
break;
|
||||
case 'tunnel-qr':
|
||||
options.tunnelQr = true;
|
||||
break;
|
||||
case 'tunnel-password-url':
|
||||
options.tunnelPasswordUrl = true;
|
||||
break;
|
||||
case 'ui-password': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
@@ -139,12 +165,14 @@ COMMANDS:
|
||||
update Check for and install updates
|
||||
|
||||
OPTIONS:
|
||||
-p, --port Web server port (default: ${DEFAULT_PORT})
|
||||
--ui-password Protect browser UI with single password
|
||||
--try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access
|
||||
-d, --daemon Run in background (serve command)
|
||||
-h, --help Show help
|
||||
-v, --version Show version
|
||||
-p, --port Web server port (default: ${DEFAULT_PORT})
|
||||
--ui-password Protect browser UI with single password
|
||||
--try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access
|
||||
--tunnel-qr Display QR code for tunnel URL (use with --try-cf-tunnel)
|
||||
--tunnel-password-url Include password in tunnel URL for auto-login
|
||||
-d, --daemon Run in background (serve command)
|
||||
-h, --help Show help
|
||||
-v, --version Show version
|
||||
|
||||
ENVIRONMENT:
|
||||
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
|
||||
@@ -490,8 +518,15 @@ const commands = {
|
||||
exitOnShutdown: true,
|
||||
uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null,
|
||||
tryCfTunnel: options.tryCfTunnel,
|
||||
onTunnelReady: (url) => {
|
||||
console.log(`\n🌐 Tunnel URL: \x1b[36m${url}\x1b[0m\n`);
|
||||
onTunnelReady: async (url) => {
|
||||
const displayUrl = buildTunnelUrl(url, effectiveUiPassword, options.tunnelPasswordUrl);
|
||||
console.log(`\n🌐 Tunnel URL: \x1b[36m${displayUrl}\x1b[0m\n`);
|
||||
if (options.tunnelPasswordUrl && effectiveUiPassword) {
|
||||
console.log('🔑 Password is embedded in URL for auto-login\n');
|
||||
}
|
||||
if (options.tunnelQr) {
|
||||
await displayTunnelQrCode(displayUrl);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"start": "node bin/cli.js serve"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
|
||||
Reference in New Issue
Block a user