@meanc/otter 0.0.1

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.
@@ -0,0 +1,181 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { render, Text, Box, useApp, useInput } from 'ink';
3
+ import { CoreManager } from '../utils/core';
4
+ import { ClashAPI } from '../utils/api';
5
+ import { SubscriptionManager } from '../utils/subscription';
6
+ import { LOG_FILE } from '../utils/paths';
7
+ import { spawn } from 'child_process';
8
+ import fs from 'fs-extra';
9
+
10
+ const formatSpeed = (bytes: number) => {
11
+ if (bytes === 0) return '0 B/s';
12
+ const k = 1024;
13
+ const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s'];
14
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
15
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
16
+ };
17
+
18
+ const formatSize = (bytes: number) => {
19
+ if (bytes === 0) return '0 B';
20
+ const k = 1024;
21
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
22
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
23
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
24
+ };
25
+
26
+ export const start = async () => {
27
+ try {
28
+ await CoreManager.start();
29
+ } catch (error: any) {
30
+ console.error('Error starting core:', error.message);
31
+ }
32
+ };
33
+
34
+ export const stop = async () => {
35
+ try {
36
+ await CoreManager.stop();
37
+ } catch (error: any) {
38
+ console.error('Error stopping core:', error.message);
39
+ }
40
+ };
41
+
42
+ export const status = async () => {
43
+ const StatusApp = () => {
44
+ const { exit } = useApp();
45
+ const [coreStatus, setCoreStatus] = useState<any>(null);
46
+ const [traffic, setTraffic] = useState({ up: 0, down: 0 });
47
+ const [subInfo, setSubInfo] = useState<{ active: string | null, count: number } | null>(null);
48
+ const [proxyCount, setProxyCount] = useState<number>(0);
49
+
50
+ useInput((input, key) => {
51
+ if (key.escape || input === 'q' || (key.ctrl && input === 'c')) {
52
+ exit();
53
+ }
54
+ });
55
+
56
+ useEffect(() => {
57
+ let ws: WebSocket | null = null;
58
+
59
+ const fetchData = async () => {
60
+ const s = await CoreManager.getStatus();
61
+ setCoreStatus(s);
62
+
63
+ if (s.running) {
64
+ // Subs
65
+ try {
66
+ const subs = await SubscriptionManager.getData();
67
+ setSubInfo({ active: subs.active, count: subs.subscriptions.length });
68
+ } catch (e) { }
69
+
70
+ // Proxies
71
+ try {
72
+ const p = await ClashAPI.getProxies();
73
+ if (p && p.proxies) {
74
+ setProxyCount(Object.keys(p.proxies).length);
75
+ }
76
+ } catch (e) { }
77
+
78
+ // Traffic WS
79
+ try {
80
+ const wsUrl = await ClashAPI.getTrafficUrl();
81
+ ws = new WebSocket(wsUrl);
82
+ ws.onmessage = (event) => {
83
+ const data = JSON.parse(event.data as string);
84
+ setTraffic(data);
85
+ };
86
+ } catch (e) { }
87
+ }
88
+ };
89
+
90
+ fetchData();
91
+
92
+ // Poll core status
93
+ const interval = setInterval(async () => {
94
+ const s = await CoreManager.getStatus();
95
+ setCoreStatus(s);
96
+ }, 2000);
97
+
98
+ return () => {
99
+ clearInterval(interval);
100
+ if (ws) ws.close();
101
+ };
102
+ }, []);
103
+
104
+ if (!coreStatus) return <Text>Loading status...</Text>;
105
+
106
+ if (!coreStatus.running) {
107
+ return (
108
+ <Box borderStyle="round" borderColor="red" padding={1}>
109
+ <Text color="red">Otter Core is stopped.</Text>
110
+ </Box>
111
+ );
112
+ }
113
+
114
+ return (
115
+ <Box flexDirection="column" borderStyle="round" borderColor="green" padding={1} width={50}>
116
+ <Box justifyContent="space-between">
117
+ <Text color="green" bold>Otter Core is running</Text>
118
+ <Text color="gray">Press 'q' to exit</Text>
119
+ </Box>
120
+
121
+ <Box marginTop={1} flexDirection="column">
122
+ <Box>
123
+ <Box width={12}><Text>PID:</Text></Box>
124
+ <Text color="blue">{coreStatus.pid}</Text>
125
+ </Box>
126
+ <Box>
127
+ <Box width={12}><Text>Version:</Text></Box>
128
+ <Text color="yellow">{coreStatus.version}</Text>
129
+ </Box>
130
+ <Box>
131
+ <Box width={12}><Text>Memory:</Text></Box>
132
+ <Text color="cyan">{formatSize(coreStatus.memory || 0)}</Text>
133
+ </Box>
134
+ </Box>
135
+
136
+ <Box marginTop={1} borderStyle="single" borderColor="gray" flexDirection="column">
137
+ <Box>
138
+ <Box width={12}><Text>Active Sub:</Text></Box>
139
+ <Text color="magenta">{subInfo?.active || 'None'}</Text>
140
+ </Box>
141
+ <Box>
142
+ <Box width={12}><Text>Total Subs:</Text></Box>
143
+ <Text>{subInfo?.count || 0}</Text>
144
+ </Box>
145
+ <Box>
146
+ <Box width={12}><Text>Proxies:</Text></Box>
147
+ <Text>{proxyCount}</Text>
148
+ </Box>
149
+ </Box>
150
+
151
+ <Box marginTop={1} flexDirection="row" justifyContent="space-around">
152
+ <Box flexDirection="column" alignItems="center">
153
+ <Text>Upload</Text>
154
+ <Text color="green">↑ {formatSpeed(traffic.up)}</Text>
155
+ </Box>
156
+ <Box flexDirection="column" alignItems="center">
157
+ <Text>Download</Text>
158
+ <Text color="green">↓ {formatSpeed(traffic.down)}</Text>
159
+ </Box>
160
+ </Box>
161
+ </Box>
162
+ );
163
+ };
164
+
165
+ const { waitUntilExit } = render(<StatusApp />);
166
+ await waitUntilExit();
167
+ };
168
+ export const log = async () => {
169
+ if (!await fs.pathExists(LOG_FILE)) {
170
+ console.log('No log file found.');
171
+ return;
172
+ }
173
+ console.log(`Tailing logs from ${LOG_FILE}...`);
174
+ const tail = spawn('tail', ['-f', LOG_FILE], { stdio: 'inherit' });
175
+
176
+ // Handle exit
177
+ process.on('SIGINT', () => {
178
+ tail.kill();
179
+ process.exit();
180
+ });
181
+ };
@@ -0,0 +1,203 @@
1
+ import { ClashAPI } from '../utils/api';
2
+ import chalk from 'chalk';
3
+ import { renderLS } from './ui';
4
+
5
+ export const list = async () => {
6
+ await renderLS();
7
+ };
8
+
9
+ export const use = async (nodeName?: string, options?: any) => {
10
+ const data = await ClashAPI.getProxies();
11
+ if (!data || !data.proxies) {
12
+ console.error(chalk.red('Failed to fetch proxies.'));
13
+ return;
14
+ }
15
+
16
+ const proxies = data.proxies;
17
+ const groups = Object.values(proxies).filter((p: any) => p.type === 'Selector');
18
+
19
+ // Helper to switch by index in a specific group
20
+ const switchByIndex = async (groupName: string, index: number) => {
21
+ const group = groups.find((g: any) => g.name === groupName);
22
+ if (!group) {
23
+ console.error(chalk.red(`Group '${groupName}' not found.`));
24
+ return true;
25
+ }
26
+ if (index > 0 && index <= (group as any).all.length) {
27
+ const node = (group as any).all[index - 1];
28
+ try {
29
+ await ClashAPI.switchProxy(group.name, node);
30
+ console.log(chalk.green(`Switched [${group.name}] to '${node}'`));
31
+ } catch (e: any) {
32
+ console.error(chalk.red(e.message));
33
+ }
34
+ } else {
35
+ console.error(chalk.red(`Index ${index} out of range for ${groupName}.`));
36
+ }
37
+ return true;
38
+ };
39
+
40
+ // Handle -p (Proxy)
41
+ if (options?.proxy || options?.p) {
42
+ const idx = parseInt(options.proxy || options.p);
43
+ if (await switchByIndex('Proxy', idx)) return;
44
+ }
45
+
46
+ // Handle -g (GLOBAL)
47
+ if (options?.global || options?.g) {
48
+ const idx = parseInt(options.global || options.g);
49
+ if (await switchByIndex('GLOBAL', idx)) return;
50
+ }
51
+
52
+ // Handle bare number -> Default to Proxy group
53
+ if (nodeName && /^\d+$/.test(nodeName)) {
54
+ const idx = parseInt(nodeName);
55
+ if (await switchByIndex('Proxy', idx)) return;
56
+ }
57
+
58
+ if (!nodeName) {
59
+ console.error(chalk.red('Please specify a node name or index.'));
60
+ return;
61
+ }
62
+
63
+ // Find target node (fuzzy match)
64
+ // We need to find which group contains this node, or if the user wants to switch a specific group
65
+ // Usually 'use' implies switching the main selector (often 'Proxy' or 'GLOBAL' or the first one)
66
+ // Or we can search all groups and switch where possible.
67
+
68
+ // Strategy:
69
+ // 1. If nodeName matches a group name, maybe they want to select that group? (Not common in 'use node')
70
+ // 2. Search for nodeName in all groups.
71
+
72
+ let targetNode = nodeName;
73
+ let targetGroup: any = null;
74
+
75
+ // Simple fuzzy search helper
76
+ const findNode = (search: string, candidates: string[]) => {
77
+ const lower = search.toLowerCase();
78
+ return candidates.find(c => c.toLowerCase().includes(lower));
79
+ };
80
+
81
+ // Try to find a group that has this node
82
+ // We prioritize the "Proxy" or "GLOBAL" group if it exists
83
+ const priorityGroups = ['Proxy', 'GLOBAL', '节点选择'];
84
+
85
+ for (const gName of priorityGroups) {
86
+ const g = groups.find((x: any) => x.name === gName);
87
+ if (g) {
88
+ const match = findNode(nodeName, g.all);
89
+ if (match) {
90
+ targetGroup = g;
91
+ targetNode = match;
92
+ break;
93
+ }
94
+ }
95
+ }
96
+
97
+ // If not found in priority, search all
98
+ if (!targetGroup) {
99
+ for (const g of groups) {
100
+ const match = findNode(nodeName, (g as any).all);
101
+ if (match) {
102
+ targetGroup = g;
103
+ targetNode = match;
104
+ break;
105
+ }
106
+ }
107
+ }
108
+
109
+ if (!targetGroup) {
110
+ console.error(chalk.red(`Node '${nodeName}' not found in any selector group.`));
111
+ return;
112
+ }
113
+
114
+ try {
115
+ await ClashAPI.switchProxy(targetGroup.name, targetNode);
116
+ console.log(chalk.green(`Switched [${targetGroup.name}] to '${targetNode}'`));
117
+ } catch (e: any) {
118
+ console.error(chalk.red(e.message));
119
+ }
120
+ };
121
+
122
+ export const test = async () => {
123
+ const data = await ClashAPI.getProxies();
124
+ if (!data) return;
125
+
126
+ // Find current node of the main selector
127
+ const proxies = data.proxies;
128
+ const groups = Object.values(proxies).filter((p: any) => p.type === 'Selector');
129
+
130
+ // Heuristic: First group is usually the main one
131
+ const mainGroup: any = groups.find((g: any) => ['Proxy', 'GLOBAL'].includes(g.name)) || groups[0];
132
+
133
+ if (!mainGroup) {
134
+ console.log('No selector group found.');
135
+ return;
136
+ }
137
+
138
+ const currentNode = mainGroup.now;
139
+ console.log(`Testing latency for ${chalk.cyan(currentNode)}...`);
140
+
141
+ try {
142
+ const res = await ClashAPI.getDelay(currentNode);
143
+ console.log(chalk.green(`Latency: ${res.delay}ms`));
144
+ } catch (e: any) {
145
+ console.error(chalk.red('Timeout or Error'));
146
+ }
147
+ };
148
+
149
+ export const best = async () => {
150
+ console.log(chalk.blue('Testing all nodes to find the best one...'));
151
+
152
+ const data = await ClashAPI.getProxies();
153
+ if (!data || !data.proxies) {
154
+ console.error(chalk.red('Failed to fetch proxies. Is Otter running?'));
155
+ return;
156
+ }
157
+
158
+ const proxies = data.proxies;
159
+ const groups = Object.values(proxies).filter((p: any) => p.type === 'Selector');
160
+
161
+ if (groups.length === 0) {
162
+ console.error(chalk.red('No selector groups found in config.'));
163
+ return;
164
+ }
165
+
166
+ const mainGroup: any = groups.find((g: any) => ['Proxy', 'GLOBAL'].includes(g.name)) || groups[0];
167
+
168
+ if (!mainGroup) {
169
+ console.error(chalk.red('Could not determine main proxy group.'));
170
+ return;
171
+ }
172
+
173
+ console.log(chalk.gray(`Testing group: ${mainGroup.name} (${mainGroup.all.length} nodes)`));
174
+
175
+ const candidates: string[] = mainGroup.all;
176
+ let bestNode = '';
177
+ let minDelay = Infinity;
178
+
179
+ // Limit concurrency
180
+ const results = await Promise.all(candidates.map(async (node) => {
181
+ try {
182
+ const res = await ClashAPI.getDelay(node);
183
+ return { node, delay: res.delay };
184
+ } catch {
185
+ return { node, delay: Infinity };
186
+ }
187
+ }));
188
+
189
+ results.forEach(r => {
190
+ if (r.delay < minDelay && r.delay > 0) {
191
+ minDelay = r.delay;
192
+ bestNode = r.node;
193
+ }
194
+ });
195
+
196
+ if (bestNode) {
197
+ console.log(chalk.green(`Best node found: ${bestNode} (${minDelay}ms)`));
198
+ await ClashAPI.switchProxy(mainGroup.name, bestNode);
199
+ console.log(`Switched to ${bestNode}`);
200
+ } else {
201
+ console.error(chalk.red('No reachable nodes found.'));
202
+ }
203
+ };
@@ -0,0 +1,43 @@
1
+ import { SubscriptionManager } from '../utils/subscription';
2
+ import chalk from 'chalk';
3
+
4
+ export const add = async (url: string, name?: string) => {
5
+ try {
6
+ const subName = name || new URL(url).hostname;
7
+ await SubscriptionManager.add(subName, url);
8
+ } catch (error: any) {
9
+ console.error(chalk.red(`Error adding subscription: ${error.message}`));
10
+ }
11
+ };
12
+
13
+ export const remove = async (name: string) => {
14
+ try {
15
+ await SubscriptionManager.remove(name);
16
+ } catch (error: any) {
17
+ console.error(chalk.red(`Error removing subscription: ${error.message}`));
18
+ }
19
+ };
20
+
21
+ export const update = async (name: string) => {
22
+ try {
23
+ await SubscriptionManager.update(name);
24
+ } catch (error: any) {
25
+ console.error(chalk.red(`Error updating subscription: ${error.message}`));
26
+ }
27
+ };
28
+
29
+ export const use = async (name: string) => {
30
+ try {
31
+ await SubscriptionManager.use(name);
32
+ } catch (error: any) {
33
+ console.error(chalk.red(`Error switching subscription: ${error.message}`));
34
+ }
35
+ };
36
+
37
+ export const list = async () => {
38
+ try {
39
+ await SubscriptionManager.list();
40
+ } catch (error: any) {
41
+ console.error(chalk.red(`Error listing subscriptions: ${error.message}`));
42
+ }
43
+ };
@@ -0,0 +1,152 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import chalk from 'chalk';
4
+ import { ClashAPI } from '../utils/api';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ // Helper to detect active network service on macOS
9
+ const getActiveService = async () => {
10
+ try {
11
+ // This is a heuristic. We look for the service associated with the default route interface.
12
+ // 1. Get default route interface
13
+ const { stdout: routeOut } = await execAsync('route get default | grep interface');
14
+ const interfaceName = routeOut.split(':')[1].trim();
15
+
16
+ // 2. Get service name for that interface
17
+ const { stdout: servicesOut } = await execAsync('networksetup -listallhardwareports');
18
+
19
+ // Parse output to find the service name for the interface
20
+ // Output format:
21
+ // Hardware Port: Wi-Fi
22
+ // Device: en0
23
+ // Ethernet Address: ...
24
+
25
+ const lines = servicesOut.split('\n');
26
+ let currentService = '';
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const line = lines[i];
29
+ if (line.startsWith('Hardware Port:')) {
30
+ currentService = line.split(':')[1].trim();
31
+ }
32
+ if (line.includes(`Device: ${interfaceName}`)) {
33
+ return currentService;
34
+ }
35
+ }
36
+ return 'Wi-Fi'; // Fallback
37
+ } catch (e) {
38
+ return 'Wi-Fi'; // Fallback
39
+ }
40
+ };
41
+
42
+ export const getSystemProxyStatus = async () => {
43
+ try {
44
+ const service = await getActiveService();
45
+ const { stdout } = await execAsync(`networksetup -getwebproxy "${service}"`);
46
+ return stdout.includes('Enabled: Yes');
47
+ } catch (e) {
48
+ return false;
49
+ }
50
+ };
51
+
52
+ export const on = async (optionsOrSilent?: any) => {
53
+ const silent = typeof optionsOrSilent === 'boolean' ? optionsOrSilent : false;
54
+ try {
55
+ const service = await getActiveService();
56
+ const ports = await ClashAPI.getProxyPort();
57
+
58
+ if (ports.mixed === 0 && ports.http === 0 && ports.socks === 0) {
59
+ if (!silent) console.error(chalk.red('Could not determine proxy ports from config.'));
60
+ return;
61
+ }
62
+
63
+ const httpPort = ports.http || ports.mixed;
64
+ const socksPort = ports.socks || ports.mixed;
65
+
66
+ if (!silent) console.log(chalk.blue(`Enabling proxy for service: ${service}`));
67
+
68
+ if (httpPort) {
69
+ await execAsync(`networksetup -setwebproxy "${service}" 127.0.0.1 ${httpPort}`);
70
+ await execAsync(`networksetup -setsecurewebproxy "${service}" 127.0.0.1 ${httpPort}`);
71
+ if (!silent) console.log(chalk.green(`HTTP/HTTPS Proxy set to 127.0.0.1:${httpPort}`));
72
+ }
73
+
74
+ if (socksPort) {
75
+ await execAsync(`networksetup -setsocksfirewallproxy "${service}" 127.0.0.1 ${socksPort}`);
76
+ if (!silent) console.log(chalk.green(`SOCKS Proxy set to 127.0.0.1:${socksPort}`));
77
+ }
78
+
79
+ // Turn them on
80
+ if (httpPort) {
81
+ await execAsync(`networksetup -setwebproxystate "${service}" on`);
82
+ await execAsync(`networksetup -setsecurewebproxystate "${service}" on`);
83
+ }
84
+ if (socksPort) {
85
+ await execAsync(`networksetup -setsocksfirewallproxystate "${service}" on`);
86
+ }
87
+
88
+ } catch (error: any) {
89
+ if (!silent) {
90
+ console.error(chalk.red(`Error enabling system proxy: ${error.message}`));
91
+ console.error(chalk.yellow('Note: This command requires sudo privileges if not prompted.'));
92
+ }
93
+ }
94
+ };
95
+
96
+ export const off = async (optionsOrSilent?: any) => {
97
+ const silent = typeof optionsOrSilent === 'boolean' ? optionsOrSilent : false;
98
+ try {
99
+ const service = await getActiveService();
100
+ if (!silent) console.log(chalk.blue(`Disabling proxy for service: ${service}`));
101
+
102
+ await execAsync(`networksetup -setwebproxystate "${service}" off`);
103
+ await execAsync(`networksetup -setsecurewebproxystate "${service}" off`);
104
+ await execAsync(`networksetup -setsocksfirewallproxystate "${service}" off`);
105
+
106
+ if (!silent) console.log(chalk.green('System proxy disabled.'));
107
+ } catch (error: any) {
108
+ if (!silent) console.error(chalk.red(`Error disabling system proxy: ${error.message}`));
109
+ }
110
+ };
111
+
112
+ export const shell = async () => {
113
+ const ports = await ClashAPI.getProxyPort();
114
+ const port = ports.http || ports.mixed || 7890;
115
+
116
+ console.log(chalk.yellow('Copy and run the following commands in your terminal:'));
117
+ console.log('');
118
+ console.log(`export http_proxy=http://127.0.0.1:${port}`);
119
+ console.log(`export https_proxy=http://127.0.0.1:${port}`);
120
+ console.log(`export all_proxy=socks5://127.0.0.1:${port}`);
121
+ console.log('');
122
+ console.log(chalk.gray('# To undo:'));
123
+ console.log('unset http_proxy https_proxy all_proxy');
124
+ };
125
+
126
+ export const mode = async (modeName?: string) => {
127
+ if (!modeName) {
128
+ const config = await ClashAPI.getConfigs();
129
+ if (config) {
130
+ console.log(`Current mode: ${chalk.green(config.mode)}`);
131
+ } else {
132
+ console.error(chalk.red('Failed to get config.'));
133
+ }
134
+ return;
135
+ }
136
+
137
+ const validModes = ['global', 'rule', 'direct'];
138
+ const m = modeName.toLowerCase();
139
+ if (!validModes.includes(m)) {
140
+ console.error(chalk.red(`Invalid mode: ${modeName}. Valid modes: ${validModes.join(', ')}`));
141
+ return;
142
+ }
143
+
144
+ // Capitalize first letter for API
145
+ const apiMode = m.charAt(0).toUpperCase() + m.slice(1);
146
+ try {
147
+ await ClashAPI.updateConfig({ mode: apiMode });
148
+ console.log(chalk.green(`Switched to ${apiMode} mode.`));
149
+ } catch (e: any) {
150
+ console.error(chalk.red(e.message));
151
+ }
152
+ };