@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,322 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { render, Box, Text, useInput, useApp, Spacer } from 'ink';
3
+ import { ClashAPI } from '../utils/api';
4
+ import { CoreManager } from '../utils/core';
5
+ import { on, off, getSystemProxyStatus } from './system';
6
+
7
+ const formatSize = (bytes: number) => {
8
+ if (bytes === 0) return '0 B';
9
+ const k = 1024;
10
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
11
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
12
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
13
+ };
14
+
15
+ const ProxyListStatic = ({ groups, mode }: { groups: any[], mode: string }) => {
16
+ return (
17
+ <Box flexDirection="column" padding={1}>
18
+ <Box marginBottom={1}>
19
+ <Text>Current Mode: </Text>
20
+ <Text color="yellow" bold>{mode.toUpperCase()}</Text>
21
+ </Box>
22
+ {groups.map((group) => {
23
+ let itemIndex = 0;
24
+ const isProxy = group.name === 'Proxy';
25
+ const isGlobal = group.name === 'GLOBAL';
26
+
27
+ return (
28
+ <Box key={group.name} flexDirection="column" marginBottom={1}>
29
+ <Text bold color="cyan">
30
+ ┌ {group.name}
31
+ </Text>
32
+ {group.all.map((node: string) => {
33
+ itemIndex++;
34
+ const isSelected = group.now === node;
35
+ let indexLabel = ' ';
36
+ if (isProxy) indexLabel = `P${itemIndex}`.padEnd(4);
37
+ else if (isGlobal) indexLabel = `G${itemIndex}`.padEnd(4);
38
+
39
+ return (
40
+ <Box key={node}>
41
+ <Text color="gray">│ </Text>
42
+ <Text color="gray">{indexLabel}</Text>
43
+ <Text color={isSelected ? 'green' : 'white'}>
44
+ {isSelected ? '● ' : '○ '} {node}
45
+ </Text>
46
+ </Box>
47
+ );
48
+ })}
49
+ <Text color="gray">└{'─'.repeat(20)}</Text>
50
+ </Box>
51
+ );
52
+ })}
53
+ </Box>
54
+ );
55
+ };
56
+
57
+ const LSApp = ({ groups, mode }: { groups: any[], mode: string }) => {
58
+ const { exit } = useApp();
59
+ useEffect(() => {
60
+ exit();
61
+ }, []);
62
+ return <ProxyListStatic groups={groups} mode={mode} />;
63
+ };
64
+
65
+ export const renderLS = async () => {
66
+ const [data, config] = await Promise.all([
67
+ ClashAPI.getProxies(),
68
+ ClashAPI.getConfigs()
69
+ ]);
70
+
71
+ if (!data || !data.proxies) {
72
+ console.log('Failed to fetch proxies. Is Otter running?');
73
+ return;
74
+ }
75
+
76
+ const mode = config?.mode || 'Unknown';
77
+
78
+ const g = Object.values(data.proxies).filter((p: any) => p.type === 'Selector');
79
+ g.sort((a: any, b: any) => {
80
+ if (['Proxy', 'GLOBAL'].includes(a.name)) return -1;
81
+ if (['Proxy', 'GLOBAL'].includes(b.name)) return 1;
82
+ return 0;
83
+ });
84
+
85
+ const { waitUntilExit } = render(<LSApp groups={g} mode={mode} />);
86
+ await waitUntilExit();
87
+ };
88
+
89
+ const TuiApp = () => {
90
+ const { exit } = useApp();
91
+ const [proxies, setProxies] = useState<any>(null);
92
+ const [groups, setGroups] = useState<any[]>([]);
93
+ const [selectedGroupIndex, setSelectedGroupIndex] = useState(0);
94
+ const [selectedNodeIndex, setSelectedNodeIndex] = useState(0);
95
+ const [status, setStatus] = useState<any>(null);
96
+ const [mode, setMode] = useState<string>('');
97
+ const [systemProxyEnabled, setSystemProxyEnabled] = useState(false);
98
+ const [message, setMessage] = useState('');
99
+ const [activeTab, setActiveTab] = useState<'groups' | 'nodes'>('groups'); // Focus control
100
+
101
+ useEffect(() => {
102
+ const init = async () => {
103
+ const s = await CoreManager.getStatus();
104
+ setStatus(s);
105
+ const sysStatus = await getSystemProxyStatus();
106
+ setSystemProxyEnabled(sysStatus);
107
+ if (s.running) {
108
+ await refreshProxies();
109
+ const c = await ClashAPI.getConfigs();
110
+ if (c) setMode(c.mode);
111
+ }
112
+ };
113
+ init();
114
+ // Auto refresh status every 5s
115
+ const interval = setInterval(async () => {
116
+ const s = await CoreManager.getStatus();
117
+ setStatus(s);
118
+ const sysStatus = await getSystemProxyStatus();
119
+ setSystemProxyEnabled(sysStatus);
120
+ if (s.running) {
121
+ const c = await ClashAPI.getConfigs();
122
+ if (c) setMode(c.mode);
123
+ }
124
+ }, 5000);
125
+ return () => clearInterval(interval);
126
+ }, []);
127
+
128
+ const refreshProxies = async () => {
129
+ const data = await ClashAPI.getProxies();
130
+ if (data && data.proxies) {
131
+ setProxies(data.proxies);
132
+ const g = Object.values(data.proxies).filter((p: any) => p.type === 'Selector');
133
+ // Sort to put Proxy/GLOBAL first
134
+ g.sort((a: any, b: any) => {
135
+ if (['Proxy', 'GLOBAL'].includes(a.name)) return -1;
136
+ if (['Proxy', 'GLOBAL'].includes(b.name)) return 1;
137
+ return 0;
138
+ });
139
+ setGroups(g);
140
+ }
141
+ };
142
+
143
+ useInput(async (input, key) => {
144
+ if (key.escape || input === 'q') {
145
+ exit();
146
+ return;
147
+ }
148
+
149
+ if (input === 's') {
150
+ setMessage(systemProxyEnabled ? 'Disabling System Proxy...' : 'Enabling System Proxy...');
151
+ try {
152
+ if (systemProxyEnabled) {
153
+ await off(true);
154
+ } else {
155
+ await on(true);
156
+ }
157
+ const sysStatus = await getSystemProxyStatus();
158
+ setSystemProxyEnabled(sysStatus);
159
+ setMessage(sysStatus ? 'System Proxy Enabled' : 'System Proxy Disabled');
160
+ } catch (e: any) {
161
+ setMessage(`Error: ${e.message}`);
162
+ }
163
+ return;
164
+ }
165
+
166
+ if (input === 'm') {
167
+ const modes = ['Rule', 'Global', 'Direct'];
168
+ const currentModeIndex = modes.findIndex(m => m.toLowerCase() === mode.toLowerCase());
169
+ const nextMode = modes[(currentModeIndex + 1) % modes.length] || 'Rule';
170
+ setMessage(`Switching mode to ${nextMode}...`);
171
+ try {
172
+ await ClashAPI.updateConfig({ mode: nextMode });
173
+ setMode(nextMode);
174
+ setMessage(`Mode switched to ${nextMode}`);
175
+ } catch (e: any) {
176
+ setMessage(`Error: ${e.message}`);
177
+ }
178
+ return;
179
+ }
180
+
181
+ if (groups.length === 0) return;
182
+
183
+ const currentGroup = groups[selectedGroupIndex];
184
+ const nodes = currentGroup.all;
185
+
186
+ if (key.tab) {
187
+ setActiveTab(prev => prev === 'groups' ? 'nodes' : 'groups');
188
+ return;
189
+ }
190
+
191
+ if (activeTab === 'groups') {
192
+ if (key.upArrow) {
193
+ setSelectedGroupIndex(prev => Math.max(0, prev - 1));
194
+ setSelectedNodeIndex(0); // Reset node selection when group changes
195
+ }
196
+ if (key.downArrow) {
197
+ setSelectedGroupIndex(prev => Math.min(groups.length - 1, prev + 1));
198
+ setSelectedNodeIndex(0);
199
+ }
200
+ if (key.rightArrow || key.return) {
201
+ setActiveTab('nodes');
202
+ }
203
+ } else {
204
+ // Nodes tab
205
+ if (key.upArrow) {
206
+ setSelectedNodeIndex(prev => Math.max(0, prev - 1));
207
+ }
208
+ if (key.downArrow) {
209
+ setSelectedNodeIndex(prev => Math.min(nodes.length - 1, prev + 1));
210
+ }
211
+ if (key.leftArrow) {
212
+ setActiveTab('groups');
213
+ }
214
+ if (key.return) {
215
+ const nodeToSelect = nodes[selectedNodeIndex];
216
+ setMessage(`Switching ${currentGroup.name} to ${nodeToSelect}...`);
217
+ try {
218
+ await ClashAPI.switchProxy(currentGroup.name, nodeToSelect);
219
+ setMessage(`Switched to ${nodeToSelect}`);
220
+ await refreshProxies();
221
+ } catch (e: any) {
222
+ setMessage(`Error: ${e.message}`);
223
+ }
224
+ }
225
+ }
226
+ });
227
+
228
+ if (!status) return <Text>Loading...</Text>;
229
+ if (!status.running) return <Text color="red">Otter Core is not running. Run 'ot up' first.</Text>;
230
+ if (groups.length === 0) return <Text>Loading proxies...</Text>;
231
+
232
+ const currentGroup = groups[selectedGroupIndex];
233
+ const nodes = currentGroup.all;
234
+
235
+ // Scroll logic for nodes
236
+ const visibleNodes = 15;
237
+ let startNode = 0;
238
+ if (selectedNodeIndex > visibleNodes / 2) {
239
+ startNode = Math.min(selectedNodeIndex - Math.floor(visibleNodes / 2), nodes.length - visibleNodes);
240
+ }
241
+ startNode = Math.max(0, startNode);
242
+ const endNode = Math.min(startNode + visibleNodes, nodes.length);
243
+ const visibleNodeList = nodes.slice(startNode, endNode);
244
+
245
+ return (
246
+ <Box flexDirection="column" padding={1} height={25}>
247
+ {/* Header */}
248
+ <Box borderStyle="round" borderColor="cyan" flexDirection="row" justifyContent="space-between" paddingX={1}>
249
+ <Text color="cyan" bold>🦦 Otter TUI</Text>
250
+ <Box>
251
+ <Text>SysProxy: <Text color={systemProxyEnabled ? 'green' : 'red'}>{systemProxyEnabled ? 'ON' : 'OFF'}</Text> | </Text>
252
+ <Text>Mode: <Text color="magenta">{mode}</Text> | </Text>
253
+ <Text>Ver: <Text color="green">{status.version}</Text> | </Text>
254
+ <Text>Mem: <Text color="yellow">{formatSize(status.memory || 0)}</Text> | </Text>
255
+ <Text>PID: {status.pid}</Text>
256
+ </Box>
257
+ </Box>
258
+
259
+ {/* Main Content */}
260
+ <Box flexDirection="row" flexGrow={1}>
261
+
262
+ {/* Left Panel: Groups */}
263
+ <Box flexDirection="column" width="30%" borderStyle="single" borderColor={activeTab === 'groups' ? 'green' : 'gray'} paddingX={1}>
264
+ <Box marginBottom={1}>
265
+ <Text bold underline>Proxy Groups</Text>
266
+ </Box>
267
+ {groups.map((g, idx) => {
268
+ const isSelected = idx === selectedGroupIndex;
269
+ return (
270
+ <Box key={g.name}>
271
+ <Text color={isSelected ? 'green' : 'white'} bold={isSelected}>
272
+ {isSelected ? '› ' : ' '}{g.name}
273
+ </Text>
274
+ </Box>
275
+ );
276
+ })}
277
+ </Box>
278
+
279
+ {/* Right Panel: Nodes */}
280
+ <Box flexDirection="column" width="70%" borderStyle="single" borderColor={activeTab === 'nodes' ? 'green' : 'gray'} paddingX={1}>
281
+ <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
282
+ <Text bold underline>Nodes in [{currentGroup.name}]</Text>
283
+ <Text color="gray">{selectedNodeIndex + 1}/{nodes.length}</Text>
284
+ </Box>
285
+
286
+ {visibleNodeList.map((node: string, idx: number) => {
287
+ const realIndex = startNode + idx;
288
+ const isFocused = realIndex === selectedNodeIndex;
289
+ const isActive = currentGroup.now === node;
290
+
291
+ return (
292
+ <Box key={node} flexDirection="row">
293
+ <Text color={isFocused ? 'cyan' : 'white'}>
294
+ {isFocused ? '› ' : ' '}
295
+ </Text>
296
+ <Text color={isActive ? 'green' : (isFocused ? 'cyan' : 'white')}>
297
+ {isActive ? '● ' : ' '}
298
+ {node}
299
+ </Text>
300
+ </Box>
301
+ );
302
+ })}
303
+ {nodes.length > visibleNodes && (
304
+ <Box marginTop={1}>
305
+ <Text color="gray">... {nodes.length - endNode} more ...</Text>
306
+ </Box>
307
+ )}
308
+ </Box>
309
+ </Box>
310
+
311
+ {/* Footer / Status Bar */}
312
+ <Box borderStyle="round" borderColor="gray" paddingX={1}>
313
+ <Text>{message || 'Arrows: Navigate | Enter: Select | Tab: Switch Panel | [s]: SysProxy | [m]: Mode | q: Quit'}</Text>
314
+ </Box>
315
+ </Box>
316
+ );
317
+ };
318
+
319
+ export const ui = async () => {
320
+ const { waitUntilExit } = render(<TuiApp />);
321
+ await waitUntilExit();
322
+ };
@@ -0,0 +1,107 @@
1
+ import fs from 'fs-extra';
2
+ import yaml from 'js-yaml';
3
+ import { CONFIG_FILE } from './paths';
4
+
5
+ interface Config {
6
+ 'external-controller'?: string;
7
+ secret?: string;
8
+ 'mixed-port'?: number;
9
+ port?: number;
10
+ 'socks-port'?: number;
11
+ }
12
+
13
+ export class ClashAPI {
14
+ private static async getConfig(): Promise<Config> {
15
+ if (!await fs.pathExists(CONFIG_FILE)) {
16
+ throw new Error('Config file not found');
17
+ }
18
+ const content = await fs.readFile(CONFIG_FILE, 'utf-8');
19
+ return yaml.load(content) as Config;
20
+ }
21
+
22
+ private static async getBaseUrl(): Promise<string> {
23
+ const config = await this.getConfig();
24
+ const controller = config['external-controller'] || '127.0.0.1:9090';
25
+ return `http://${controller}`;
26
+ }
27
+
28
+ private static async getHeaders(): Promise<Record<string, string>> {
29
+ const config = await this.getConfig();
30
+ const headers: Record<string, string> = {
31
+ 'Content-Type': 'application/json',
32
+ };
33
+ if (config.secret) {
34
+ headers['Authorization'] = `Bearer ${config.secret}`;
35
+ }
36
+ return headers;
37
+ }
38
+
39
+ static async getProxies(): Promise<any> {
40
+ const baseUrl = await this.getBaseUrl();
41
+ const headers = await this.getHeaders();
42
+ try {
43
+ const res = await fetch(`${baseUrl}/proxies`, { headers });
44
+ if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
45
+ return await res.json();
46
+ } catch (e) {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ static async switchProxy(groupName: string, nodeName: string) {
52
+ const baseUrl = await this.getBaseUrl();
53
+ const headers = await this.getHeaders();
54
+ const res = await fetch(`${baseUrl}/proxies/${encodeURIComponent(groupName)}`, {
55
+ method: 'PUT',
56
+ headers,
57
+ body: JSON.stringify({ name: nodeName })
58
+ });
59
+ if (!res.ok) throw new Error(`Failed to switch proxy: ${res.statusText}`);
60
+ }
61
+
62
+ static async getDelay(nodeName: string) {
63
+ const baseUrl = await this.getBaseUrl();
64
+ const headers = await this.getHeaders();
65
+ const url = `${baseUrl}/proxies/${encodeURIComponent(nodeName)}/delay?timeout=5000&url=http://www.gstatic.com/generate_204`;
66
+ const res = await fetch(url, { headers });
67
+ if (!res.ok) throw new Error(`Failed to test latency: ${res.statusText}`);
68
+ return await res.json();
69
+ }
70
+
71
+ static async getProxyPort(): Promise<{ http: number, socks: number, mixed: number }> {
72
+ const config = await this.getConfig();
73
+ return {
74
+ http: config.port || 0,
75
+ socks: config['socks-port'] || 0,
76
+ mixed: config['mixed-port'] || 0
77
+ };
78
+ }
79
+
80
+ static async getConfigs(): Promise<any> {
81
+ const baseUrl = await this.getBaseUrl();
82
+ const headers = await this.getHeaders();
83
+ try {
84
+ const res = await fetch(`${baseUrl}/configs`, { headers });
85
+ if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
86
+ return await res.json();
87
+ } catch (e) {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ static async updateConfig(config: any) {
93
+ const baseUrl = await this.getBaseUrl();
94
+ const headers = await this.getHeaders();
95
+ const res = await fetch(`${baseUrl}/configs`, {
96
+ method: 'PATCH',
97
+ headers,
98
+ body: JSON.stringify(config)
99
+ });
100
+ if (!res.ok) throw new Error(`Failed to update config: ${res.statusText}`);
101
+ }
102
+
103
+ static async getTrafficUrl(): Promise<string> {
104
+ const baseUrl = await this.getBaseUrl();
105
+ return baseUrl.replace('http://', 'ws://') + '/traffic';
106
+ }
107
+ }
@@ -0,0 +1,153 @@
1
+ import { spawn, exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import fs from 'fs-extra';
4
+ import { BIN_PATH, CONFIG_FILE, HOME_DIR, LOG_FILE, PID_FILE } from './paths';
5
+ import psList from 'ps-list';
6
+
7
+ const execAsync = promisify(exec);
8
+
9
+ export class CoreManager {
10
+ static async init() {
11
+ await fs.ensureDir(HOME_DIR);
12
+ }
13
+
14
+ static async isRunning(): Promise<boolean> {
15
+ if (!await fs.pathExists(PID_FILE)) {
16
+ return false;
17
+ }
18
+ const pid = parseInt(await fs.readFile(PID_FILE, 'utf-8'), 10);
19
+ if (isNaN(pid)) return false;
20
+
21
+ const list = await psList();
22
+ return list.some(p => p.pid === pid);
23
+ }
24
+
25
+ static async start() {
26
+ await this.init();
27
+
28
+ if (await this.isRunning()) {
29
+ console.log('Otter (Mihomo) is already running.');
30
+ return;
31
+ }
32
+
33
+ if (!await fs.pathExists(BIN_PATH)) {
34
+ throw new Error(`Mihomo binary not found at ${BIN_PATH}`);
35
+ }
36
+
37
+ // Ensure executable permission
38
+ await fs.chmod(BIN_PATH, 0o755);
39
+
40
+ // Check for quarantine on macOS
41
+ if (process.platform === 'darwin') {
42
+ try {
43
+ await execAsync(`xattr -p com.apple.quarantine "${BIN_PATH}"`);
44
+ // If command succeeds, the attribute exists
45
+ console.error('\n\x1b[31mError: The Mihomo binary is quarantined by macOS.\x1b[0m');
46
+ console.error('You need to run the following command to allow it to run:');
47
+ console.error(`\n \x1b[33msudo xattr -d com.apple.quarantine "${BIN_PATH}"\x1b[0m\n`);
48
+ throw new Error('Binary is quarantined');
49
+ } catch (e: any) {
50
+ // If command fails (exit code 1), the attribute does not exist, which is good.
51
+ // If it's another error, we ignore it for now.
52
+ if (e.message === 'Binary is quarantined') throw e;
53
+ }
54
+ }
55
+
56
+ // Check config
57
+ if (!await fs.pathExists(CONFIG_FILE)) {
58
+ console.log(`Config file not found. Creating default at ${CONFIG_FILE}...`);
59
+ const defaultConfig = `
60
+ mixed-port: 7890
61
+ allow-lan: false
62
+ mode: rule
63
+ log-level: info
64
+ external-controller: 127.0.0.1:9090
65
+ `;
66
+ await fs.writeFile(CONFIG_FILE, defaultConfig.trim());
67
+ } else {
68
+ // Check if external-controller is enabled in existing config
69
+ const configContent = await fs.readFile(CONFIG_FILE, 'utf-8');
70
+ if (!configContent.includes('external-controller')) {
71
+ console.warn(`Warning: 'external-controller' not found in ${CONFIG_FILE}. API features might not work.`);
72
+ console.warn(`Please add 'external-controller: 127.0.0.1:9090' to your config.`);
73
+ }
74
+ }
75
+
76
+ // Ensure log file exists
77
+ await fs.ensureFile(LOG_FILE);
78
+ const logFd = await fs.open(LOG_FILE, 'a');
79
+
80
+ console.log(`Starting Mihomo from ${BIN_PATH}...`);
81
+
82
+ const child = spawn(BIN_PATH, ['-d', HOME_DIR], {
83
+ detached: true,
84
+ stdio: ['ignore', logFd, logFd]
85
+ });
86
+
87
+ if (child.pid) {
88
+ await fs.writeFile(PID_FILE, child.pid.toString());
89
+ child.unref();
90
+ console.log(`Mihomo started with PID ${child.pid}`);
91
+ } else {
92
+ throw new Error('Failed to start Mihomo process');
93
+ }
94
+ }
95
+
96
+ static async stop() {
97
+ if (!await this.isRunning()) {
98
+ console.log('Otter is not running.');
99
+ return;
100
+ }
101
+ const pid = parseInt(await fs.readFile(PID_FILE, 'utf-8'), 10);
102
+ try {
103
+ process.kill(pid);
104
+ console.log(`Stopped process ${pid}`);
105
+ } catch (e) {
106
+ console.error(`Failed to stop process ${pid}:`, e);
107
+ }
108
+ await fs.remove(PID_FILE);
109
+ }
110
+
111
+ static async getStatus() {
112
+ const running = await this.isRunning();
113
+ if (!running) {
114
+ return { running: false };
115
+ }
116
+
117
+ const pid = parseInt(await fs.readFile(PID_FILE, 'utf-8'), 10);
118
+
119
+ // Try to fetch version from API
120
+ // Default external controller is 127.0.0.1:9090
121
+ // We should parse config.yaml to find the real port, but for now assume default
122
+ let version = 'Unknown';
123
+ let memory = 0;
124
+
125
+ try {
126
+ // Using Bun's built-in fetch
127
+ const res = await fetch('http://127.0.0.1:9090/version');
128
+ if (res.ok) {
129
+ const data = await res.json() as any;
130
+ version = data.version || 'Unknown';
131
+ }
132
+ } catch (e) {
133
+ version = 'API Unreachable';
134
+ }
135
+
136
+ // Get memory usage (RSS in Bytes)
137
+ try {
138
+ const { stdout } = await execAsync(`ps -o rss= -p ${pid}`);
139
+ if (stdout) {
140
+ memory = parseInt(stdout.trim(), 10) * 1024; // KB to Bytes
141
+ }
142
+ } catch (e) {
143
+ // Fallback to 0
144
+ }
145
+
146
+ return {
147
+ running: true,
148
+ pid,
149
+ version,
150
+ memory
151
+ };
152
+ }
153
+ }