tapback 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/proxy.js ADDED
@@ -0,0 +1,99 @@
1
+ const http = require('http');
2
+ const httpProxy = require('http-proxy');
3
+
4
+ function isTextContent(ct) {
5
+ const l = ct.toLowerCase();
6
+ return (
7
+ l.includes('text/') ||
8
+ l.includes('application/javascript') ||
9
+ l.includes('application/json') ||
10
+ l.includes('application/xml') ||
11
+ l.includes('application/xhtml') ||
12
+ l.includes('+json') ||
13
+ l.includes('+xml')
14
+ );
15
+ }
16
+
17
+ function rewriteLocalhost(text, macIP, allProxyPorts) {
18
+ let result = text;
19
+ for (const [targetPort, externalPort] of Object.entries(allProxyPorts)) {
20
+ const replacements = [
21
+ [`http://localhost:${targetPort}`, `http://${macIP}:${externalPort}`],
22
+ [`https://localhost:${targetPort}`, `https://${macIP}:${externalPort}`],
23
+ [`http://127.0.0.1:${targetPort}`, `http://${macIP}:${externalPort}`],
24
+ [`https://127.0.0.1:${targetPort}`, `https://${macIP}:${externalPort}`],
25
+ [`//localhost:${targetPort}`, `//${macIP}:${externalPort}`],
26
+ [`//127.0.0.1:${targetPort}`, `//${macIP}:${externalPort}`],
27
+ [`'localhost:${targetPort}`, `'${macIP}:${externalPort}`],
28
+ [`"localhost:${targetPort}`, `"${macIP}:${externalPort}`],
29
+ [`'127.0.0.1:${targetPort}`, `'${macIP}:${externalPort}`],
30
+ [`"127.0.0.1:${targetPort}`, `"${macIP}:${externalPort}`],
31
+ [`\`localhost:${targetPort}`, `\`${macIP}:${externalPort}`],
32
+ [`\`127.0.0.1:${targetPort}`, `\`${macIP}:${externalPort}`],
33
+ ];
34
+ for (const [from, to] of replacements) {
35
+ result = result.split(from).join(to);
36
+ }
37
+ }
38
+ return result;
39
+ }
40
+
41
+ function createProxyServer(targetPort, externalPort, macIP, allProxyPorts) {
42
+ const proxy = httpProxy.createProxyServer({
43
+ target: `http://127.0.0.1:${targetPort}`,
44
+ selfHandleResponse: true,
45
+ });
46
+
47
+ proxy.on('proxyRes', (proxyRes, req, res) => {
48
+ const ct = proxyRes.headers['content-type'] || '';
49
+ const chunks = [];
50
+ proxyRes.on('data', (chunk) => chunks.push(chunk));
51
+ proxyRes.on('end', () => {
52
+ let body = Buffer.concat(chunks);
53
+ if (isTextContent(ct)) {
54
+ let text = body.toString('utf8');
55
+ text = rewriteLocalhost(text, macIP, allProxyPorts);
56
+ body = Buffer.from(text, 'utf8');
57
+ }
58
+ // Copy headers, skip problematic ones
59
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
60
+ const lk = key.toLowerCase();
61
+ if (!['transfer-encoding', 'connection', 'keep-alive', 'content-encoding'].includes(lk)) {
62
+ res.setHeader(key, value);
63
+ }
64
+ }
65
+ res.setHeader('content-length', body.length);
66
+ res.writeHead(proxyRes.statusCode);
67
+ res.end(body);
68
+ });
69
+ });
70
+
71
+ proxy.on('error', (err, req, res) => {
72
+ const errorHTML = `<!DOCTYPE html>
73
+ <html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
74
+ <title>Proxy Error</title>
75
+ <style>body{font-family:sans-serif;background:#0d1117;color:#c9d1d9;min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center;padding:20px}
76
+ .e{color:#f85149;font-size:1.2rem;margin-bottom:1rem}.m{color:#8b949e;font-size:0.9rem}</style></head>
77
+ <body><div><div class="e">localhost:${targetPort} に接続できません</div><div class="m">${err.message}</div></div></body></html>`;
78
+ res.writeHead(502, { 'Content-Type': 'text/html' });
79
+ res.end(errorHTML);
80
+ });
81
+
82
+ const server = http.createServer((req, res) => {
83
+ // Rewrite headers for localhost target
84
+ req.headers.host = `localhost:${targetPort}`;
85
+ req.headers.origin = `http://localhost:${targetPort}`;
86
+ req.headers.referer = `http://localhost:${targetPort}/`;
87
+ req.headers['x-forwarded-host'] = macIP;
88
+ req.headers['x-forwarded-proto'] = 'http';
89
+ proxy.web(req, res);
90
+ });
91
+
92
+ server.listen(externalPort, '0.0.0.0', () => {
93
+ console.log(`[Tapback] Proxy: port ${externalPort} -> localhost:${targetPort}`);
94
+ });
95
+
96
+ return server;
97
+ }
98
+
99
+ module.exports = { createProxyServer };
package/src/server.js ADDED
@@ -0,0 +1,214 @@
1
+ const http = require('http');
2
+ const express = require('express');
3
+ const { WebSocketServer } = require('ws');
4
+ const cookieParser = require('cookie-parser');
5
+ const crypto = require('crypto');
6
+ const os = require('os');
7
+ const tmux = require('./tmux');
8
+ const html = require('./html');
9
+ const { ClaudeStatusStore } = require('./claudeStatus');
10
+ const config = require('./config');
11
+
12
+ function getLocalIP() {
13
+ const interfaces = os.networkInterfaces();
14
+ const en0 = interfaces.en0;
15
+ if (en0) {
16
+ for (const info of en0) {
17
+ if (info.family === 'IPv4' && !info.internal) return info.address;
18
+ }
19
+ }
20
+ // Fallback: first non-internal IPv4
21
+ for (const iface of Object.values(interfaces)) {
22
+ for (const info of iface) {
23
+ if (info.family === 'IPv4' && !info.internal) return info.address;
24
+ }
25
+ }
26
+ return '127.0.0.1';
27
+ }
28
+
29
+ function escapeJson(str) {
30
+ return str
31
+ .replace(/\\/g, '\\\\')
32
+ .replace(/"/g, '\\"')
33
+ .replace(/\n/g, '\\n')
34
+ .replace(/\r/g, '\\r')
35
+ .replace(/\t/g, '\\t');
36
+ }
37
+
38
+ function createServer({ port = 9876, pinEnabled = true, quickButtons = [], appURL = null } = {}) {
39
+ const pin = String(Math.floor(Math.random() * 10000)).padStart(4, '0');
40
+ const settingsPin = String(Math.floor(Math.random() * 10000)).padStart(4, '0');
41
+ const authToken = crypto.randomUUID();
42
+ const settingsAuthToken = crypto.randomUUID();
43
+ const macIP = getLocalIP();
44
+ const statusStore = new ClaudeStatusStore();
45
+ const wsClients = new Set();
46
+
47
+ const app = express();
48
+ app.use(cookieParser());
49
+ app.use(express.json());
50
+ app.use(express.urlencoded({ extended: true }));
51
+
52
+ // Auth middleware for main page
53
+ function requireAuth(req, res, next) {
54
+ if (!config.load().pinEnabled) return next();
55
+ if (req.cookies.tapback_auth === authToken) return next();
56
+ return res.redirect('/auth');
57
+ }
58
+
59
+ // Auth middleware for settings (always required, separate PIN)
60
+ function requireSettingsAuth(req, res, next) {
61
+ if (req.cookies.tapback_settings_auth === settingsAuthToken) return next();
62
+ return res.redirect('/settings/auth');
63
+ }
64
+
65
+ app.get('/', requireAuth, (req, res) => {
66
+ res.type('html').send(html.mainPage(appURL, quickButtons));
67
+ });
68
+
69
+ app.get('/auth', (req, res) => {
70
+ if (!config.load().pinEnabled) return res.redirect('/');
71
+ if (req.cookies.tapback_auth === authToken) return res.redirect('/');
72
+ res.type('html').send(html.pinPage(null, '/auth'));
73
+ });
74
+
75
+ app.post('/auth', (req, res) => {
76
+ if (!config.load().pinEnabled) return res.redirect('/');
77
+ if (req.body.pin === pin) {
78
+ res.cookie('tapback_auth', authToken, { maxAge: 86400000, httpOnly: true });
79
+ return res.redirect('/');
80
+ }
81
+ res.status(401).type('html').send(html.pinPage('Invalid PIN', '/auth'));
82
+ });
83
+
84
+ app.get('/settings/auth', (req, res) => {
85
+ if (req.cookies.tapback_settings_auth === settingsAuthToken) return res.redirect('/settings');
86
+ res.type('html').send(html.pinPage(null, '/settings/auth'));
87
+ });
88
+
89
+ app.post('/settings/auth', (req, res) => {
90
+ if (req.body.pin === settingsPin) {
91
+ res.cookie('tapback_settings_auth', settingsAuthToken, { maxAge: 86400000, httpOnly: true });
92
+ return res.redirect('/settings');
93
+ }
94
+ res.status(401).type('html').send(html.pinPage('Invalid PIN', '/settings/auth'));
95
+ });
96
+
97
+ app.get('/api/sessions', async (req, res) => {
98
+ const sessions = await tmux.listSessions();
99
+ res.json(sessions.map((name) => ({ name })));
100
+ });
101
+
102
+ app.post('/api/claude-status', (req, res) => {
103
+ const { session_id, status, project_dir, model } = req.body;
104
+ if (!session_id) return res.status(400).json({ error: 'missing session_id' });
105
+
106
+ const statusObj = {
107
+ session_id,
108
+ status,
109
+ project_dir,
110
+ model,
111
+ timestamp: new Date().toISOString(),
112
+ };
113
+ statusStore.update(statusObj);
114
+
115
+ // Broadcast to all WebSocket clients
116
+ const msg = JSON.stringify({ t: 'status', d: statusObj });
117
+ for (const ws of wsClients) {
118
+ if (ws.readyState === 1) ws.send(msg);
119
+ }
120
+
121
+ res.json({ ok: true });
122
+ });
123
+
124
+ app.get('/api/claude-status', (req, res) => {
125
+ res.json(statusStore.getAll());
126
+ });
127
+
128
+ // Settings page and API (separate PIN auth)
129
+ app.get('/settings', requireSettingsAuth, (req, res) => {
130
+ res.type('html').send(html.settingsPage(config.load()));
131
+ });
132
+
133
+ app.get('/api/settings', requireSettingsAuth, (req, res) => {
134
+ res.json({ ...config.load(), pin });
135
+ });
136
+
137
+ app.put('/api/settings', requireSettingsAuth, (req, res) => {
138
+ const cfg = config.load();
139
+ const body = req.body;
140
+
141
+ if (typeof body.pinEnabled === 'boolean') {
142
+ cfg.pinEnabled = body.pinEnabled;
143
+ console.log(`[Tapback] PIN ${body.pinEnabled ? 'enabled' : 'disabled'} (PIN: ${pin})`);
144
+ }
145
+ if (body.addProxy) {
146
+ cfg.proxyPorts[String(body.addProxy.target)] = body.addProxy.external;
147
+ }
148
+ if (typeof body.delProxy === 'number') {
149
+ delete cfg.proxyPorts[String(body.delProxy)];
150
+ }
151
+ if (body.addButton && body.addButton.label && body.addButton.command) {
152
+ cfg.quickButtons.push({ label: body.addButton.label, command: body.addButton.command });
153
+ }
154
+ if (
155
+ typeof body.delButton === 'number' &&
156
+ body.delButton >= 0 &&
157
+ body.delButton < cfg.quickButtons.length
158
+ ) {
159
+ cfg.quickButtons.splice(body.delButton, 1);
160
+ }
161
+
162
+ config.save(cfg);
163
+ res.json({ ok: true });
164
+ });
165
+
166
+ const server = http.createServer(app);
167
+ const wss = new WebSocketServer({ server, path: '/ws' });
168
+
169
+ wss.on('connection', async (ws) => {
170
+ wsClients.add(ws);
171
+
172
+ // Send initial output for all tmux sessions
173
+ const sessions = await tmux.listSessions();
174
+ for (const name of sessions) {
175
+ const [output, cpath] = await Promise.all([tmux.capture(name), tmux.getCurrentPath(name)]);
176
+ ws.send(JSON.stringify({ t: 'o', id: name, c: output, path: cpath || '' }));
177
+ }
178
+
179
+ // Send initial Claude statuses
180
+ for (const s of statusStore.getAll()) {
181
+ ws.send(JSON.stringify({ t: 'status', d: s }));
182
+ }
183
+
184
+ ws.on('message', async (data) => {
185
+ try {
186
+ const msg = JSON.parse(data);
187
+ if (msg.t === 'i' && msg.id) {
188
+ await tmux.sendKeys(msg.id, msg.c || '');
189
+ }
190
+ } catch {}
191
+ });
192
+
193
+ ws.on('close', () => wsClients.delete(ws));
194
+ });
195
+
196
+ // Periodic tmux output broadcast
197
+ setInterval(async () => {
198
+ if (wsClients.size === 0) return;
199
+ const sessions = await tmux.listSessions();
200
+ for (const name of sessions) {
201
+ const [output, cpath] = await Promise.all([tmux.capture(name), tmux.getCurrentPath(name)]);
202
+ const msg = JSON.stringify({ t: 'o', id: name, c: output, path: cpath || '' });
203
+ for (const ws of wsClients) {
204
+ if (ws.readyState === 1) ws.send(msg);
205
+ }
206
+ }
207
+ }, 1000);
208
+
209
+ server.listen(port, '0.0.0.0');
210
+
211
+ return { server, pin, settingsPin, macIP, port };
212
+ }
213
+
214
+ module.exports = { createServer, getLocalIP };
package/src/tmux.js ADDED
@@ -0,0 +1,31 @@
1
+ const { execFile } = require('child_process');
2
+
3
+ const ENV = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH}` };
4
+
5
+ function run(args) {
6
+ return new Promise((resolve) => {
7
+ execFile('tmux', args, { env: ENV, timeout: 5000 }, (err, stdout) => {
8
+ resolve(err ? '' : stdout);
9
+ });
10
+ });
11
+ }
12
+
13
+ exports.listSessions = async () => {
14
+ const out = await run(['list-sessions', '-F', '#{session_name}']);
15
+ return out.split('\n').filter(Boolean);
16
+ };
17
+
18
+ exports.capture = (session) => run(['capture-pane', '-t', `${session}:0.0`, '-p', '-S', '-300']);
19
+
20
+ exports.sendKeys = async (session, text) => {
21
+ if (text) {
22
+ await run(['send-keys', '-t', `${session}:0.0`, '-l', text]);
23
+ }
24
+ await run(['send-keys', '-t', `${session}:0.0`, 'Enter']);
25
+ };
26
+
27
+ exports.getCurrentPath = async (session) => {
28
+ const out = await run(['display-message', '-t', `${session}:0.0`, '-p', '#{pane_current_path}']);
29
+ const trimmed = out.trim();
30
+ return trimmed || null;
31
+ };