beachviber 1.0.34

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/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 1.0.10
4
+
5
+ Initial open-source release.
6
+
7
+ ### Features
8
+
9
+ - **Remote Claude Code control** — send prompts and monitor sessions from your phone
10
+ - **End-to-end encryption** — NaCl public-key cryptography (Curve25519 + XSalsa20-Poly1305) between phone and desktop
11
+ - **QR code pairing** — scan to pair, verify with a code, reconnect automatically
12
+ - **Tool approval** — approve or deny Claude's file writes and shell commands from your phone
13
+ - **Multi-project support** — scan a directory tree for projects with `.git/` or `.claude/`
14
+ - **Session management** — create, resume, and browse Claude Code sessions remotely
15
+ - **Session history** — read Claude CLI transcripts with tool use details
16
+ - **Image attachments** — attach images from your phone to prompts
17
+ - **Auto-reconnect** — exponential backoff with automatic re-pairing on token revocation
18
+ - **Fail-closed security** — tool use denied if approval socket is unreachable or phone doesn't respond
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Krokosz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # BeachViber
2
+
3
+ Control [Claude Code](https://docs.anthropic.com/en/docs/claude-code) from your phone. Send prompts, approve tools, and monitor sessions remotely through an end-to-end encrypted connection.
4
+
5
+ ```
6
+ Phone App ←—(encrypted)—→ Relay Server ←—(encrypted)—→ Computer Agent ←—→ Claude Code
7
+ ```
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g beachviber
13
+ ```
14
+
15
+ Requires Node.js 20+ and [Claude Code](https://docs.anthropic.com/en/docs/claude-code) on your PATH.
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ beachviber
21
+ ```
22
+
23
+ On first run:
24
+
25
+ 1. A QR code appears — scan it with the [BeachViber App](https://app.beachviber.com)
26
+ 3. In your terminal, enter the verification code shown on your BeachViber App
27
+ 4. Done — you're paired and encrypted end-to-end
28
+
29
+ On subsequent runs, the agent reconnects automatically.
30
+
31
+ ## What You Can Do
32
+
33
+ From your phone:
34
+
35
+ - Browse projects on your machine
36
+ - Start Claude Code sessions and send prompts
37
+ - Approve or deny tool use (file writes, shell commands, etc.)
38
+ - View session history and transcripts
39
+ - Manage multiple desktops from one phone
40
+
41
+ ## Security
42
+
43
+ **End-to-end encrypted.** All sensitive messages (prompts, responses, tool approvals) are encrypted with X25519 + AES-256-GCM. The relay server cannot read message contents.
44
+
45
+ **Keys stay on your machine.** Private keys are stored in the OS keychain (macOS Keychain, Linux Secret Service, or Windows DPAPI fallback) as PKCS8 DER — never in plaintext config files.
46
+
47
+ **Tool approval.** A `PreToolUse` hook intercepts Claude Code tool calls. Tools already in your Claude allow list are auto-approved. Everything else is sent to your phone for approval. If the agent isn't running, the hook is a no-op.
48
+
49
+ ## Configuration
50
+
51
+ The agent stores config in `~/.beachviber/`.
52
+
53
+ ```
54
+ .beachviber/
55
+ ```
56
+
57
+ ## Uninstall
58
+
59
+ ```bash
60
+ npm uninstall -g beachviber
61
+ ```
62
+
63
+ This removes the `PreToolUse` hook from `~/.claude/settings.json` automatically.
64
+
65
+ ## Protocol
66
+
67
+ The agent communicates over WebSocket using JSON messages. Each message has a `type`, optional `sessionId`, `timestamp`, and `payload`. When paired, the `payload` is replaced with an `encrypted` field containing an AES-256-GCM envelope.
68
+
69
+ | Type | Direction | Description |
70
+ |------|-----------|-------------|
71
+ | `register` | Desktop → Relay | Register with device token and public key |
72
+ | `registered` | Relay → Desktop | Registration confirmed |
73
+ | `projects_request` | Phone → Desktop | List available projects |
74
+ | `projects_response` | Desktop → Phone | Project list with git info |
75
+ | `session_create` | Phone → Desktop | Start a Claude Code session |
76
+ | `session_created` | Desktop → Phone | Session started confirmation |
77
+ | `session_end` | Phone → Desktop | End a session |
78
+ | `prompt` | Phone → Desktop | Send a prompt to Claude |
79
+ | `stream_start` | Desktop → Phone | Claude started responding |
80
+ | `stream_delta` | Desktop → Phone | Streaming text/tool-use chunk |
81
+ | `stream_end` | Desktop → Phone | Claude finished responding |
82
+ | `tool_approval_request` | Desktop → Phone | Tool needs approval |
83
+ | `tool_approval_response` | Phone → Desktop | Approve/deny tool use |
84
+ | `sessions_request` | Phone → Desktop | List all sessions |
85
+ | `sessions_response` | Desktop → Phone | Session list with metadata |
86
+ | `session_history_request` | Phone → Desktop | Get session transcript |
87
+ | `session_history_response` | Desktop → Phone | Transcript messages |
88
+ | `verify_code` | Desktop → Phone | Pairing verification code |
89
+ | `verify_code_ack` | Phone → Desktop | Verification result + public key |
90
+ | `heartbeat` | Desktop → Relay | Keep-alive |
91
+
92
+ ## Learn More
93
+ https://www.beachviber.com
94
+
95
+ ## Contributing
96
+
97
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project structure, and guidelines.
98
+
99
+ ## License
100
+
101
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,28 @@
1
+ import { DEFAULT_API_URL } from "./config.js";
2
+ // API_URL is set after config is loaded; env var takes precedence
3
+ let API_URL = DEFAULT_API_URL;
4
+ export function setApiUrl(url) {
5
+ API_URL = url;
6
+ }
7
+ export function getApiUrl() {
8
+ return API_URL;
9
+ }
10
+ export async function requestPairing(publicKey, deviceId) {
11
+ const res = await fetch(`${API_URL}/v1/pairing/request`, {
12
+ method: "POST",
13
+ headers: { "Content-Type": "application/json" },
14
+ body: JSON.stringify({ publicKey, deviceId }),
15
+ signal: AbortSignal.timeout(15_000),
16
+ });
17
+ if (!res.ok) {
18
+ throw new Error(`Pairing request failed: ${res.status} ${res.statusText}`);
19
+ }
20
+ return res.json();
21
+ }
22
+ export async function pollPairingStatus(code, token) {
23
+ const res = await fetch(`${API_URL}/v1/pairing/status?code=${encodeURIComponent(code)}&token=${encodeURIComponent(token)}`, { signal: AbortSignal.timeout(10_000) });
24
+ if (!res.ok) {
25
+ throw new Error(`Pairing status check failed: ${res.status} ${res.statusText}`);
26
+ }
27
+ return res.json();
28
+ }
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook — asks phone for approval via desktop agent's IPC channel
3
+ // (Unix domain socket on macOS/Linux, named pipe on Windows)
4
+ // Respects Claude's own permission settings: tools the user has already
5
+ // allowed in their Claude settings are auto-approved without going to the phone.
6
+
7
+ import net from "net";
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import { homedir } from "os";
11
+
12
+ const SOCKET_PATH = process.env.BEACHVIBER_SOCKET_PATH;
13
+ const SESSION_ID = process.env.BEACHVIBER_SESSION_ID;
14
+ const AUTH_TOKEN = process.env.BEACHVIBER_APPROVAL_TOKEN;
15
+
16
+ // hookEventName is REQUIRED for Claude Code to recognize the output
17
+ const ALLOW = JSON.stringify({
18
+ hookSpecificOutput: {
19
+ hookEventName: "PreToolUse",
20
+ permissionDecision: "allow",
21
+ },
22
+ });
23
+
24
+ // If not running in BeachViber context, let Claude handle permissions normally
25
+ if (!SOCKET_PATH || !SESSION_ID || !AUTH_TOKEN) {
26
+ process.exit(0);
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Load Claude's permission settings from all config layers
31
+ // ---------------------------------------------------------------------------
32
+ function readJsonSafe(filePath) {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function loadPermissions(cwd) {
41
+ const allow = [];
42
+ const deny = [];
43
+
44
+ // User-level settings (lowest precedence for allow, but we collect all)
45
+ const userSettings = readJsonSafe(path.join(homedir(), ".claude", "settings.json"));
46
+ if (userSettings?.permissions?.allow) allow.push(...userSettings.permissions.allow);
47
+ if (userSettings?.permissions?.deny) deny.push(...userSettings.permissions.deny);
48
+
49
+ // Project-level settings
50
+ const projectSettings = readJsonSafe(path.join(cwd, ".claude", "settings.json"));
51
+ if (projectSettings?.permissions?.allow) allow.push(...projectSettings.permissions.allow);
52
+ if (projectSettings?.permissions?.deny) deny.push(...projectSettings.permissions.deny);
53
+
54
+ // Local project settings (gitignored, personal overrides)
55
+ const localSettings = readJsonSafe(path.join(cwd, ".claude", "settings.local.json"));
56
+ if (localSettings?.permissions?.allow) allow.push(...localSettings.permissions.allow);
57
+ if (localSettings?.permissions?.deny) deny.push(...localSettings.permissions.deny);
58
+
59
+ return { allow, deny };
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Pattern matching — mirrors Claude's permission pattern format
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Check if a tool call matches a permission pattern.
68
+ *
69
+ * Pattern formats:
70
+ * "Read" → matches all Read tool uses
71
+ * "Bash(npm test *)" → matches Bash where command starts with "npm test "
72
+ * "Bash(*)" → matches all Bash commands
73
+ * "Edit(/src/**)" → matches Edit on paths under /src/
74
+ */
75
+ function matchesPattern(pattern, toolName, toolInput) {
76
+ // Parse "ToolName(arg pattern)" or just "ToolName"
77
+ const parenIdx = pattern.indexOf("(");
78
+ if (parenIdx === -1) {
79
+ // Bare tool name — matches all uses of this tool
80
+ return pattern === toolName;
81
+ }
82
+
83
+ const patternTool = pattern.slice(0, parenIdx);
84
+ if (patternTool !== toolName) return false;
85
+
86
+ // Extract the argument pattern inside parentheses
87
+ const argPattern = pattern.slice(parenIdx + 1, -1); // strip ( and )
88
+
89
+ // Determine the relevant input value to match against
90
+ let inputValue = "";
91
+ if (toolName === "Bash" && toolInput?.command) {
92
+ inputValue = toolInput.command;
93
+ } else if (toolInput?.file_path) {
94
+ inputValue = toolInput.file_path;
95
+ } else if (toolInput?.pattern) {
96
+ inputValue = toolInput.pattern;
97
+ }
98
+
99
+ return globMatch(argPattern, inputValue);
100
+ }
101
+
102
+ /**
103
+ * Simple glob matching: * matches any sequence of characters within a segment.
104
+ * Handles patterns like "npm test *", "git *", "*.ts", etc.
105
+ */
106
+ function globMatch(pattern, str) {
107
+ // Convert glob to regex: escape regex chars, then replace * with .*
108
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
109
+ const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
110
+ try {
111
+ return new RegExp(regexStr).test(str);
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function isAllowedBySettings(toolName, toolInput, permissions) {
118
+ // Deny takes precedence — if explicitly denied, do NOT auto-allow
119
+ for (const pattern of permissions.deny) {
120
+ if (matchesPattern(pattern, toolName, toolInput)) {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ // Check allow list
126
+ for (const pattern of permissions.allow) {
127
+ if (matchesPattern(pattern, toolName, toolInput)) {
128
+ return true;
129
+ }
130
+ }
131
+
132
+ return false;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Main hook logic
137
+ // ---------------------------------------------------------------------------
138
+
139
+ // Read JSON from stdin
140
+ const chunks = [];
141
+ for await (const chunk of process.stdin) chunks.push(chunk);
142
+ const input = JSON.parse(Buffer.concat(chunks).toString());
143
+
144
+ // Load Claude's permission settings
145
+ const cwd = input.cwd || process.cwd();
146
+ const permissions = loadPermissions(cwd);
147
+
148
+ // If Claude's settings already allow this tool, auto-approve
149
+ if (isAllowedBySettings(input.tool_name, input.tool_input, permissions)) {
150
+ process.stdout.write(ALLOW);
151
+ process.exit(0);
152
+ }
153
+
154
+ // Connect to desktop agent via IPC (Unix socket or named pipe) for phone approval
155
+ try {
156
+ const response = await new Promise((resolve, reject) => {
157
+ const client = net.createConnection(SOCKET_PATH, () => {
158
+ client.write(JSON.stringify({
159
+ token: AUTH_TOKEN,
160
+ sessionId: SESSION_ID,
161
+ tool: input.tool_name,
162
+ toolInput: input.tool_input,
163
+ }));
164
+ client.end(); // half-close: done writing, still reading
165
+ });
166
+
167
+ let data = "";
168
+ client.on("data", (chunk) => (data += chunk));
169
+ client.on("end", () => {
170
+ try { resolve(JSON.parse(data)); }
171
+ catch { reject(new Error("bad response")); }
172
+ });
173
+ client.on("error", reject);
174
+ });
175
+
176
+ if (response.approved) {
177
+ // Explicitly grant permission
178
+ process.stdout.write(ALLOW);
179
+ process.exit(0);
180
+ }
181
+
182
+ // Denied by phone user
183
+ process.stdout.write(JSON.stringify({
184
+ hookSpecificOutput: {
185
+ hookEventName: "PreToolUse",
186
+ permissionDecision: "deny",
187
+ permissionDecisionReason: response.reason || "Denied by phone user",
188
+ },
189
+ }));
190
+ } catch (err) {
191
+ // Desktop agent not reachable — fall through to Claude's normal permissions
192
+ process.exit(0);
193
+ }
@@ -0,0 +1,186 @@
1
+ import net from "net";
2
+ import crypto from "crypto";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { getBeachViberDir, getProfileSuffix } from "./config.js";
6
+ import { createLogger } from "./logger.js";
7
+ const log = createLogger("approval");
8
+ const APPROVAL_TIMEOUT_MS = 300_000; // 5 minutes
9
+ const MAX_REQUEST_BYTES = 64 * 1024; // 64 KB
10
+ const CONN_TIMEOUT_MS = 30_000; // 30 seconds
11
+ const MAX_CONCURRENT_CONNS = 10;
12
+ const isWin = process.platform === "win32";
13
+ // Random token generated per desktop agent session — only processes
14
+ // spawned by the desktop agent (Claude CLI → hook) know this token
15
+ const AUTH_TOKEN = crypto.randomBytes(32).toString("hex");
16
+ let socketPath = "";
17
+ export function getSocketPath() {
18
+ return socketPath;
19
+ }
20
+ export function getAuthToken() {
21
+ return AUTH_TOKEN;
22
+ }
23
+ /** Build the IPC endpoint path — named pipe on Windows, Unix socket elsewhere */
24
+ function buildIpcPath() {
25
+ const suffix = getProfileSuffix();
26
+ if (isWin) {
27
+ // Named pipes are globally namespaced; include a hash of the user's
28
+ // BeachViber directory to avoid collisions between users on the same machine
29
+ const id = crypto
30
+ .createHash("sha256")
31
+ .update(getBeachViberDir())
32
+ .digest("hex")
33
+ .slice(0, 8);
34
+ return `\\\\.\\pipe\\beachviber-approval-${id}${suffix}`;
35
+ }
36
+ return path.join(getBeachViberDir(), `approval${suffix}.sock`);
37
+ }
38
+ export function startApprovalServer(sessionMgr, sendFn) {
39
+ socketPath = buildIpcPath();
40
+ if (!isWin) {
41
+ const bvDir = getBeachViberDir();
42
+ // Ensure directory exists with owner-only permissions
43
+ fs.mkdirSync(bvDir, { recursive: true, mode: 0o700 });
44
+ // Clean up stale socket from a previous crash
45
+ try {
46
+ fs.unlinkSync(socketPath);
47
+ }
48
+ catch { }
49
+ }
50
+ const activeConns = new Set();
51
+ const server = net.createServer({ allowHalfOpen: true }, (conn) => {
52
+ // Reject if too many concurrent connections
53
+ if (activeConns.size >= MAX_CONCURRENT_CONNS) {
54
+ conn.destroy();
55
+ return;
56
+ }
57
+ activeConns.add(conn);
58
+ conn.on("close", () => activeConns.delete(conn));
59
+ let data = "";
60
+ let totalBytes = 0;
61
+ // Connection timeout — destroy if client doesn't finish within limit
62
+ conn.setTimeout(CONN_TIMEOUT_MS, () => {
63
+ conn.destroy();
64
+ });
65
+ conn.on("data", (chunk) => {
66
+ totalBytes += chunk.length;
67
+ if (totalBytes > MAX_REQUEST_BYTES) {
68
+ conn.write(JSON.stringify({ approved: false, reason: "request too large" }));
69
+ conn.end();
70
+ return;
71
+ }
72
+ data += chunk.toString();
73
+ });
74
+ conn.on("end", () => {
75
+ // Full request received (hook called conn.end() after writing)
76
+ let request;
77
+ try {
78
+ request = JSON.parse(data);
79
+ }
80
+ catch {
81
+ conn.write(JSON.stringify({ approved: false, reason: "invalid request" }));
82
+ conn.end();
83
+ return;
84
+ }
85
+ // Validate auth token (constant-time comparison to prevent timing attacks)
86
+ const tokenBuf = Buffer.from(String(request.token || ""));
87
+ const expectedBuf = Buffer.from(AUTH_TOKEN);
88
+ if (tokenBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(tokenBuf, expectedBuf)) {
89
+ conn.write(JSON.stringify({ approved: false, reason: "unauthorized" }));
90
+ conn.end();
91
+ return;
92
+ }
93
+ const approvalId = `apr_${crypto.randomBytes(12).toString("hex")}`;
94
+ // Build human-readable description
95
+ let description = request.tool;
96
+ let command;
97
+ const input = request.toolInput || {};
98
+ if (request.tool === "Bash" && input.command) {
99
+ description = `Run: ${input.command}`;
100
+ command = input.command;
101
+ }
102
+ else if (input.file_path) {
103
+ description = `${request.tool}: ${input.file_path}`;
104
+ }
105
+ // Send tool_approval_request to phone via existing WSS connection
106
+ log.info(`Sending tool_approval_request: approvalId=${approvalId} session=${request.sessionId} tool=${request.tool}`);
107
+ const sent = sendFn({
108
+ type: "tool_approval_request",
109
+ sessionId: request.sessionId,
110
+ timestamp: Date.now(),
111
+ payload: { approvalId, tool: request.tool, description, command },
112
+ });
113
+ // Phone not connected — deny immediately instead of waiting 5 minutes
114
+ if (!sent) {
115
+ log.warn(`Phone not connected — auto-denying approvalId=${approvalId}`);
116
+ conn.write(JSON.stringify({ approved: false, reason: "phone not connected" }));
117
+ conn.end();
118
+ return;
119
+ }
120
+ // Timeout: deny if phone doesn't respond in 5 minutes
121
+ const timer = setTimeout(() => {
122
+ sessionMgr.resolveApproval(approvalId, false);
123
+ }, APPROVAL_TIMEOUT_MS);
124
+ // Register callback — fires when phone responds via WSS
125
+ // (handleMessage in index.ts receives tool_approval_response,
126
+ // calls sessionMgr.resolveApproval, which triggers this callback)
127
+ sessionMgr.registerApprovalCallback(approvalId, (approved) => {
128
+ clearTimeout(timer);
129
+ log.info(`Approval resolved: approvalId=${approvalId} approved=${approved}`);
130
+ conn.write(JSON.stringify({ approved }));
131
+ conn.end();
132
+ }, { approvalId, sessionId: request.sessionId, tool: request.tool, description, command });
133
+ });
134
+ conn.on("error", () => {
135
+ // Hook process died, nothing to do
136
+ });
137
+ });
138
+ if (isWin) {
139
+ // Windows named pipes don't use file permissions — just listen.
140
+ // Handle EADDRINUSE: a previous instance may still hold the pipe.
141
+ server.on("error", (err) => {
142
+ if (err.code !== "EADDRINUSE")
143
+ throw err;
144
+ // Probe the existing pipe to check if something is actually listening
145
+ const probe = net.createConnection(socketPath, () => {
146
+ // Connection succeeded — another instance is genuinely running
147
+ probe.destroy();
148
+ log.error("Another BeachViber instance is already running (pipe in use). " +
149
+ "Stop the other instance first, or restart your terminal.");
150
+ process.exit(1);
151
+ });
152
+ probe.on("error", () => {
153
+ // Pipe exists but nobody is listening — stale. Retry after brief delay.
154
+ log.info("Stale pipe detected, retrying...");
155
+ setTimeout(() => server.listen(socketPath), 500);
156
+ });
157
+ });
158
+ server.listen(socketPath);
159
+ }
160
+ else {
161
+ // Set restrictive umask before creating socket to eliminate TOCTOU race
162
+ const oldUmask = process.umask(0o177);
163
+ server.listen(socketPath, () => {
164
+ process.umask(oldUmask);
165
+ });
166
+ }
167
+ // Cleanup on process exit (Unix sockets need file removal; named pipes do not)
168
+ if (!isWin) {
169
+ const cleanup = () => {
170
+ try {
171
+ fs.unlinkSync(socketPath);
172
+ }
173
+ catch { }
174
+ };
175
+ process.on("exit", cleanup);
176
+ process.on("SIGINT", () => {
177
+ cleanup();
178
+ process.exit(0);
179
+ });
180
+ process.on("SIGTERM", () => {
181
+ cleanup();
182
+ process.exit(0);
183
+ });
184
+ }
185
+ return server;
186
+ }
package/dist/config.js ADDED
@@ -0,0 +1,60 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { randomBytes } from "crypto";
5
+ export const DEFAULT_RELAY_URL = "wss://relay.beachviber.com";
6
+ export const DEFAULT_API_URL = "https://api.beachviber.com";
7
+ // --- Profile support ---
8
+ const PROFILE_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,30}[a-zA-Z0-9])?$/;
9
+ let _profile = null;
10
+ export function setProfile(name) {
11
+ if (!PROFILE_RE.test(name)) {
12
+ throw new Error(`Invalid profile name "${name}": must be 1-32 alphanumeric/hyphen chars, no leading/trailing hyphen`);
13
+ }
14
+ _profile = name;
15
+ }
16
+ export function getProfile() {
17
+ return _profile;
18
+ }
19
+ export function getProfileSuffix() {
20
+ return _profile ? `-${_profile}` : "";
21
+ }
22
+ /** Test-only: reset profile state between tests */
23
+ export function _resetProfile() {
24
+ _profile = null;
25
+ }
26
+ export function getBeachViberDir() {
27
+ return join(homedir(), ".beachviber");
28
+ }
29
+ function getConfigFile() {
30
+ return join(getBeachViberDir(), `config${getProfileSuffix()}.json`);
31
+ }
32
+ export function loadConfig() {
33
+ const file = getConfigFile();
34
+ if (!existsSync(file))
35
+ return null;
36
+ try {
37
+ return JSON.parse(readFileSync(file, "utf-8"));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export function saveConfig(config) {
44
+ const dir = getBeachViberDir();
45
+ if (!existsSync(dir)) {
46
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
47
+ }
48
+ writeFileSync(getConfigFile(), JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
49
+ }
50
+ export function deleteConfig() {
51
+ const file = getConfigFile();
52
+ if (existsSync(file)) {
53
+ unlinkSync(file);
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+ export function generateDeviceId() {
59
+ return `dev_${randomBytes(6).toString("hex")}`;
60
+ }