android-midscene-automation 0.1.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/README.md +160 -0
- package/bin/android-midscene-automation.js +27 -0
- package/index.html +12 -0
- package/package.json +49 -0
- package/remote-agent/index.ts +206 -0
- package/server/appium-recorder/appium-runner.ts +427 -0
- package/server/appium-recorder/repository.ts +228 -0
- package/server/appium-recorder/routes.ts +219 -0
- package/server/config-store.ts +167 -0
- package/server/config.ts +130 -0
- package/server/device-locks/repository.ts +147 -0
- package/server/device-locks/service.ts +72 -0
- package/server/device-locks/types.ts +22 -0
- package/server/device-sessions/repository.ts +169 -0
- package/server/device-sessions/service.ts +59 -0
- package/server/device-sessions/types.ts +24 -0
- package/server/http-api.ts +1389 -0
- package/server/model-call-usage-importer.ts +108 -0
- package/server/model-tester.ts +104 -0
- package/server/model-usage-repository.ts +131 -0
- package/server/operations/repository.ts +218 -0
- package/server/operations/service.ts +84 -0
- package/server/operations/types.ts +27 -0
- package/server/paths.ts +27 -0
- package/server/remote-agents/protocol.ts +38 -0
- package/server/remote-agents/registry.ts +136 -0
- package/server/remote-agents/routes.ts +89 -0
- package/server/script-agent.ts +284 -0
- package/server/script-db.ts +281 -0
- package/server/script-runner.ts +551 -0
- package/server/storage/sqlite.ts +49 -0
- package/server/test-case-import/formatter.ts +28 -0
- package/server/test-case-import/parsers/excel.ts +69 -0
- package/server/test-case-import/parsers/txt.ts +11 -0
- package/server/test-case-import/parsers/word.ts +9 -0
- package/server/test-case-import/service.ts +58 -0
- package/server/test-case-import/text-normalizer.ts +98 -0
- package/server/test-case-import/types.ts +24 -0
- package/server/test-case-import/validator.ts +34 -0
- package/src/App.vue +1450 -0
- package/src/api.ts +290 -0
- package/src/appium-recorder/AppiumPage.vue +894 -0
- package/src/appium-recorder/api.ts +64 -0
- package/src/appium-recorder/components/ComponentTree.vue +44 -0
- package/src/appium-recorder/components/NodeDetail.vue +152 -0
- package/src/appium-recorder/components/RecordedSteps.vue +79 -0
- package/src/appium-recorder/tree.ts +129 -0
- package/src/appium-recorder/types.ts +88 -0
- package/src/assets/device-actions/back.svg +5 -0
- package/src/assets/device-actions/home.svg +3 -0
- package/src/assets/device-actions/power.svg +5 -0
- package/src/assets/device-actions/tasks.svg +3 -0
- package/src/assets/device-actions/volume-down.svg +3 -0
- package/src/assets/device-actions/volume-up.svg +3 -0
- package/src/components/config/ModelUsageChart.vue +188 -0
- package/src/components/device/DevicePreviewPanel.vue +266 -0
- package/src/components/generator/GeneratedCodePanel.vue +70 -0
- package/src/components/generator/TestCaseFileUpload.vue +97 -0
- package/src/config/midscene-model-presets.ts +75 -0
- package/src/config/prompt-example.ts +6 -0
- package/src/main.ts +7 -0
- package/src/pages/AiGeneratorPage.vue +90 -0
- package/src/pages/AutomationPage.vue +161 -0
- package/src/pages/ConfigPage.vue +273 -0
- package/src/pages/GeneratorPage.vue +97 -0
- package/src/pages/ManualStepsPage.vue +179 -0
- package/src/script-generator/codegen.ts +126 -0
- package/src/script-generator/index.ts +4 -0
- package/src/script-generator/presets.ts +37 -0
- package/src/script-generator/step-options.ts +52 -0
- package/src/script-generator/types.ts +27 -0
- package/src/style.css +1983 -0
- package/src/types.ts +157 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +8 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +16 -0
- package/vite.config.ts +28 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
|
+
import {
|
|
7
|
+
deleteAppiumRecordedScript,
|
|
8
|
+
getAppiumRecordedScript,
|
|
9
|
+
listAppiumRecordedScripts,
|
|
10
|
+
saveAppiumRecordedScript,
|
|
11
|
+
type AppiumRecordedStepRecord,
|
|
12
|
+
} from './repository';
|
|
13
|
+
import { replayAppiumScript } from './appium-runner';
|
|
14
|
+
import { isRemoteDeviceId, sendRemoteCommand } from '../remote-agents/registry';
|
|
15
|
+
|
|
16
|
+
function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
|
|
17
|
+
res.statusCode = statusCode;
|
|
18
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
19
|
+
res.end(JSON.stringify(payload));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readBody<T>(req: IncomingMessage) {
|
|
23
|
+
let body = '';
|
|
24
|
+
req.on('data', (chunk) => {
|
|
25
|
+
body += chunk;
|
|
26
|
+
});
|
|
27
|
+
return await new Promise<T>((resolve) => {
|
|
28
|
+
req.on('end', () => {
|
|
29
|
+
resolve(JSON.parse(body || '{}') as T);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function execFileText(command: string, args: string[] = []) {
|
|
35
|
+
return new Promise<string>((resolve, reject) => {
|
|
36
|
+
execFile(command, args, { maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
37
|
+
if (error) {
|
|
38
|
+
reject(new Error(stderr || error.message));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
resolve(stdout);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function dumpWindowHierarchy(deviceId: string) {
|
|
47
|
+
const localPath = path.join(os.tmpdir(), `midscene-appium-${deviceId.replace(/[^\w.-]/g, '_')}-${Date.now()}.xml`);
|
|
48
|
+
const remotePath = '/data/local/tmp/midscene_appium_uidump.xml';
|
|
49
|
+
await execFileText('adb', ['-s', deviceId, 'shell', 'uiautomator', 'dump', remotePath]);
|
|
50
|
+
await execFileText('adb', ['-s', deviceId, 'pull', remotePath, localPath]);
|
|
51
|
+
try {
|
|
52
|
+
return await fs.readFile(localPath, 'utf8');
|
|
53
|
+
} finally {
|
|
54
|
+
await fs.unlink(localPath).catch(() => undefined);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function getCurrentActivity(deviceId: string) {
|
|
59
|
+
const output = await execFileText('adb', ['-s', deviceId, 'shell', 'dumpsys', 'activity', 'activities']);
|
|
60
|
+
const resumedLine = output
|
|
61
|
+
.split(/\r?\n/)
|
|
62
|
+
.find((line) => /(?:topResumedActivity|ResumedActivity|mResumedActivity)/.test(line));
|
|
63
|
+
return resumedLine?.match(/\s([\w.$]+\/[\w.$]+)\s/)?.[1] || '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function tapDevice(deviceId: string, x: number, y: number) {
|
|
67
|
+
await execFileText('adb', [
|
|
68
|
+
'-s',
|
|
69
|
+
deviceId,
|
|
70
|
+
'shell',
|
|
71
|
+
'input',
|
|
72
|
+
'tap',
|
|
73
|
+
String(Math.round(x)),
|
|
74
|
+
String(Math.round(y)),
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function pressDeviceKey(deviceId: string, keyCode: number) {
|
|
79
|
+
await execFileText('adb', [
|
|
80
|
+
'-s',
|
|
81
|
+
deviceId,
|
|
82
|
+
'shell',
|
|
83
|
+
'input',
|
|
84
|
+
'keyevent',
|
|
85
|
+
String(Math.round(keyCode)),
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function handleAppiumRecorderRequest(
|
|
90
|
+
req: IncomingMessage,
|
|
91
|
+
res: ServerResponse,
|
|
92
|
+
selectedDeviceId: string,
|
|
93
|
+
allowLocalDevice = true,
|
|
94
|
+
) {
|
|
95
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
96
|
+
const pathname = requestUrl.pathname;
|
|
97
|
+
if (!pathname.startsWith('/api/appium-recorder')) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const assertDeviceAllowed = (deviceId: string) => {
|
|
103
|
+
if (!allowLocalDevice && deviceId && !isRemoteDeviceId(deviceId)) {
|
|
104
|
+
throw new Error('局域网访问者只能使用远程代理设备');
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (pathname === '/api/appium-recorder/tree' && req.method === 'GET') {
|
|
109
|
+
const deviceId = requestUrl.searchParams.get('deviceId')?.trim() || selectedDeviceId;
|
|
110
|
+
if (!deviceId) throw new Error('未检测到可用设备');
|
|
111
|
+
assertDeviceAllowed(deviceId);
|
|
112
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
113
|
+
const data = await sendRemoteCommand(deviceId, 'tree') as { xml?: string; activity?: string; dumpedAt?: string };
|
|
114
|
+
sendJson(res, {
|
|
115
|
+
deviceId,
|
|
116
|
+
xml: data.xml || '',
|
|
117
|
+
activity: data.activity || '',
|
|
118
|
+
dumpedAt: data.dumpedAt || new Date().toISOString(),
|
|
119
|
+
});
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
const [xml, activity] = await Promise.all([
|
|
123
|
+
dumpWindowHierarchy(deviceId),
|
|
124
|
+
getCurrentActivity(deviceId).catch(() => ''),
|
|
125
|
+
]);
|
|
126
|
+
sendJson(res, { deviceId, xml, activity, dumpedAt: new Date().toISOString() });
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (pathname === '/api/appium-recorder/tap' && req.method === 'POST') {
|
|
131
|
+
const parsed = await readBody<{ deviceId?: string; x?: number; y?: number }>(req);
|
|
132
|
+
const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
|
|
133
|
+
if (!deviceId) throw new Error('未检测到可用设备');
|
|
134
|
+
assertDeviceAllowed(deviceId);
|
|
135
|
+
if (!Number.isFinite(parsed.x) || !Number.isFinite(parsed.y)) throw new Error('点击坐标无效');
|
|
136
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
137
|
+
await sendRemoteCommand(deviceId, 'tap', { x: Number(parsed.x), y: Number(parsed.y) });
|
|
138
|
+
sendJson(res, { success: true });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
await tapDevice(deviceId, Number(parsed.x), Number(parsed.y));
|
|
142
|
+
sendJson(res, { success: true });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (pathname === '/api/appium-recorder/key' && req.method === 'POST') {
|
|
147
|
+
const parsed = await readBody<{ deviceId?: string; keyCode?: number }>(req);
|
|
148
|
+
const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
|
|
149
|
+
const keyCode = Number(parsed.keyCode);
|
|
150
|
+
if (!deviceId) throw new Error('未检测到可用设备');
|
|
151
|
+
assertDeviceAllowed(deviceId);
|
|
152
|
+
if (!Number.isFinite(keyCode)) throw new Error('按键无效');
|
|
153
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
154
|
+
await sendRemoteCommand(deviceId, 'key', { keyCode });
|
|
155
|
+
sendJson(res, { success: true });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
await pressDeviceKey(deviceId, keyCode);
|
|
159
|
+
sendJson(res, { success: true });
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (pathname === '/api/appium-recorder/scripts' && req.method === 'GET') {
|
|
164
|
+
sendJson(res, { scripts: listAppiumRecordedScripts() });
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (pathname === '/api/appium-recorder/scripts' && req.method === 'POST') {
|
|
169
|
+
const parsed = await readBody<{
|
|
170
|
+
id?: string;
|
|
171
|
+
name?: string;
|
|
172
|
+
appPackage?: string;
|
|
173
|
+
appActivity?: string;
|
|
174
|
+
deviceId?: string;
|
|
175
|
+
steps?: AppiumRecordedStepRecord[];
|
|
176
|
+
}>(req);
|
|
177
|
+
const script = saveAppiumRecordedScript({
|
|
178
|
+
id: parsed.id,
|
|
179
|
+
name: parsed.name || '',
|
|
180
|
+
appPackage: parsed.appPackage || '',
|
|
181
|
+
appActivity: parsed.appActivity || '',
|
|
182
|
+
deviceId: parsed.deviceId || selectedDeviceId,
|
|
183
|
+
steps: parsed.steps || [],
|
|
184
|
+
});
|
|
185
|
+
sendJson(res, { script });
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const deleteMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)$/);
|
|
190
|
+
if (deleteMatch && req.method === 'DELETE') {
|
|
191
|
+
deleteAppiumRecordedScript(decodeURIComponent(deleteMatch[1]));
|
|
192
|
+
sendJson(res, { success: true });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const replayMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/replay$/);
|
|
197
|
+
if (replayMatch && req.method === 'POST') {
|
|
198
|
+
const parsed = await readBody<{ deviceId?: string }>(req);
|
|
199
|
+
const script = getAppiumRecordedScript(decodeURIComponent(replayMatch[1]));
|
|
200
|
+
if (!script) throw new Error('Appium 录制脚本不存在');
|
|
201
|
+
const deviceId = parsed.deviceId || selectedDeviceId;
|
|
202
|
+
assertDeviceAllowed(deviceId);
|
|
203
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
204
|
+
const result = await sendRemoteCommand(deviceId, 'replay', { script }) as { success?: boolean; output?: string };
|
|
205
|
+
sendJson(res, result, result.success ? 200 : 500);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
const result = await replayAppiumScript(script, deviceId);
|
|
209
|
+
sendJson(res, result, result.success ? 200 : 500);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
sendJson(res, { message: 'Appium Recorder 接口不存在' }, 404);
|
|
214
|
+
return true;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
sendJson(res, { message: error instanceof Error ? error.message : 'Appium Recorder 请求失败' }, 500);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { appPath } from './paths';
|
|
5
|
+
import type { AppConfig } from './config';
|
|
6
|
+
|
|
7
|
+
export type AppPresetRecord = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
packageName: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type AppPresetRow = {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
package_name: string;
|
|
19
|
+
created_at: string;
|
|
20
|
+
updated_at: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const dbPath = appPath('.midscene-app', 'script-cache.sqlite');
|
|
24
|
+
const sqliteBin = process.env.SQLITE3_BIN || '/usr/bin/sqlite3';
|
|
25
|
+
let initialized = false;
|
|
26
|
+
|
|
27
|
+
function createId() {
|
|
28
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureDbDir() {
|
|
32
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sqlString(value: string) {
|
|
36
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runSql(sql: string) {
|
|
40
|
+
ensureDbDir();
|
|
41
|
+
execFileSync(sqliteBin, [dbPath], {
|
|
42
|
+
input: sql,
|
|
43
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function querySql<T>(sql: string) {
|
|
48
|
+
ensureDbDir();
|
|
49
|
+
const output = execFileSync(sqliteBin, ['-json', dbPath, sql], {
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
52
|
+
});
|
|
53
|
+
return JSON.parse(output || '[]') as T[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rowToAppPreset(row: AppPresetRow): AppPresetRecord {
|
|
57
|
+
return {
|
|
58
|
+
id: row.id,
|
|
59
|
+
name: row.name,
|
|
60
|
+
packageName: row.package_name,
|
|
61
|
+
createdAt: row.created_at,
|
|
62
|
+
updatedAt: row.updated_at,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function initConfigDb() {
|
|
67
|
+
if (initialized) return;
|
|
68
|
+
initialized = true;
|
|
69
|
+
runSql(`
|
|
70
|
+
PRAGMA journal_mode = WAL;
|
|
71
|
+
CREATE TABLE IF NOT EXISTS app_config (
|
|
72
|
+
key TEXT PRIMARY KEY,
|
|
73
|
+
value_json TEXT NOT NULL,
|
|
74
|
+
updated_at TEXT NOT NULL
|
|
75
|
+
);
|
|
76
|
+
CREATE TABLE IF NOT EXISTS app_presets (
|
|
77
|
+
id TEXT PRIMARY KEY,
|
|
78
|
+
name TEXT NOT NULL,
|
|
79
|
+
package_name TEXT NOT NULL,
|
|
80
|
+
created_at TEXT NOT NULL,
|
|
81
|
+
updated_at TEXT NOT NULL,
|
|
82
|
+
UNIQUE(name, package_name)
|
|
83
|
+
);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_app_presets_updated_at ON app_presets(updated_at DESC);
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function loadModelConfigFromDb() {
|
|
89
|
+
initConfigDb();
|
|
90
|
+
const row = querySql<{ value_json: string }>(`
|
|
91
|
+
SELECT value_json
|
|
92
|
+
FROM app_config
|
|
93
|
+
WHERE key = 'model_config'
|
|
94
|
+
LIMIT 1;
|
|
95
|
+
`)[0];
|
|
96
|
+
|
|
97
|
+
return row ? JSON.parse(row.value_json) as Partial<AppConfig> : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function saveModelConfigToDb(config: AppConfig) {
|
|
101
|
+
initConfigDb();
|
|
102
|
+
const now = new Date().toISOString();
|
|
103
|
+
runSql(`
|
|
104
|
+
INSERT INTO app_config (key, value_json, updated_at)
|
|
105
|
+
VALUES ('model_config', ${sqlString(JSON.stringify(config))}, ${sqlString(now)})
|
|
106
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
107
|
+
value_json = excluded.value_json,
|
|
108
|
+
updated_at = excluded.updated_at;
|
|
109
|
+
`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function listAppPresetRecords() {
|
|
113
|
+
initConfigDb();
|
|
114
|
+
return querySql<AppPresetRow>(`
|
|
115
|
+
SELECT id, name, package_name, created_at, updated_at
|
|
116
|
+
FROM app_presets
|
|
117
|
+
ORDER BY updated_at DESC;
|
|
118
|
+
`).map(rowToAppPreset);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function saveAppPresetRecord(input: { id?: string; name: string; packageName: string }) {
|
|
122
|
+
initConfigDb();
|
|
123
|
+
const now = new Date().toISOString();
|
|
124
|
+
const name = input.name.trim();
|
|
125
|
+
const packageName = input.packageName.trim();
|
|
126
|
+
|
|
127
|
+
if (!name) throw new Error('App 名称不能为空');
|
|
128
|
+
if (!packageName) throw new Error('App 包名不能为空');
|
|
129
|
+
|
|
130
|
+
const existing = querySql<{ id: string }>(`
|
|
131
|
+
SELECT id
|
|
132
|
+
FROM app_presets
|
|
133
|
+
WHERE name = ${sqlString(name)}
|
|
134
|
+
AND package_name = ${sqlString(packageName)}
|
|
135
|
+
LIMIT 1;
|
|
136
|
+
`)[0];
|
|
137
|
+
|
|
138
|
+
if (existing && existing.id !== input.id) {
|
|
139
|
+
throw new Error('已存在相同 App 名称和包名的配置');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const id = input.id || createId();
|
|
143
|
+
|
|
144
|
+
runSql(`
|
|
145
|
+
INSERT INTO app_presets (id, name, package_name, created_at, updated_at)
|
|
146
|
+
VALUES (${sqlString(id)}, ${sqlString(name)}, ${sqlString(packageName)}, ${sqlString(now)}, ${sqlString(now)})
|
|
147
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
148
|
+
name = excluded.name,
|
|
149
|
+
package_name = excluded.package_name,
|
|
150
|
+
updated_at = excluded.updated_at;
|
|
151
|
+
`);
|
|
152
|
+
|
|
153
|
+
const row = querySql<AppPresetRow>(`
|
|
154
|
+
SELECT id, name, package_name, created_at, updated_at
|
|
155
|
+
FROM app_presets
|
|
156
|
+
WHERE id = ${sqlString(id)}
|
|
157
|
+
LIMIT 1;
|
|
158
|
+
`)[0];
|
|
159
|
+
|
|
160
|
+
return rowToAppPreset(row);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function removeAppPresetRecord(id: string) {
|
|
164
|
+
initConfigDb();
|
|
165
|
+
runSql(`DELETE FROM app_presets WHERE id = ${sqlString(id)};`);
|
|
166
|
+
return { success: true };
|
|
167
|
+
}
|
package/server/config.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import { appPath } from './paths';
|
|
5
|
+
import { loadModelConfigFromDb, saveModelConfigToDb } from './config-store';
|
|
6
|
+
|
|
7
|
+
export type AppConfig = {
|
|
8
|
+
midscene: {
|
|
9
|
+
model: {
|
|
10
|
+
provider: 'custom' | 'codex';
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
apiKey: string;
|
|
13
|
+
name: string;
|
|
14
|
+
family: string;
|
|
15
|
+
};
|
|
16
|
+
env: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
scriptOptimizer: {
|
|
19
|
+
model: {
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
apiKey: string;
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let cachedConfig: AppConfig | null = null;
|
|
28
|
+
const configPath = appPath('config.json');
|
|
29
|
+
const legacyConfigPath = appPath('config.yaml');
|
|
30
|
+
|
|
31
|
+
function defaultConfig(): AppConfig {
|
|
32
|
+
return {
|
|
33
|
+
midscene: {
|
|
34
|
+
model: {
|
|
35
|
+
provider: 'custom',
|
|
36
|
+
baseUrl: '',
|
|
37
|
+
apiKey: '',
|
|
38
|
+
name: '',
|
|
39
|
+
family: '',
|
|
40
|
+
},
|
|
41
|
+
env: {},
|
|
42
|
+
},
|
|
43
|
+
scriptOptimizer: {
|
|
44
|
+
model: {
|
|
45
|
+
baseUrl: '',
|
|
46
|
+
apiKey: '',
|
|
47
|
+
name: '',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeEnv(env: unknown): Record<string, string> {
|
|
54
|
+
if (!env || typeof env !== 'object' || Array.isArray(env)) return {};
|
|
55
|
+
return Object.fromEntries(
|
|
56
|
+
Object.entries(env)
|
|
57
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== ''),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeConfig(config: Partial<AppConfig> | null | undefined): AppConfig {
|
|
62
|
+
const fallback = defaultConfig();
|
|
63
|
+
return {
|
|
64
|
+
midscene: {
|
|
65
|
+
model: {
|
|
66
|
+
provider: config?.midscene?.model?.provider || (
|
|
67
|
+
config?.midscene?.model?.baseUrl === 'codex://app-server' ? 'codex' : fallback.midscene.model.provider
|
|
68
|
+
),
|
|
69
|
+
baseUrl: config?.midscene?.model?.baseUrl || fallback.midscene.model.baseUrl,
|
|
70
|
+
apiKey: config?.midscene?.model?.apiKey || fallback.midscene.model.apiKey,
|
|
71
|
+
name: config?.midscene?.model?.name || fallback.midscene.model.name,
|
|
72
|
+
family: config?.midscene?.model?.family || fallback.midscene.model.family,
|
|
73
|
+
},
|
|
74
|
+
env: normalizeEnv(config?.midscene?.env),
|
|
75
|
+
},
|
|
76
|
+
scriptOptimizer: {
|
|
77
|
+
model: {
|
|
78
|
+
baseUrl: config?.scriptOptimizer?.model?.baseUrl || fallback.scriptOptimizer.model.baseUrl,
|
|
79
|
+
apiKey: config?.scriptOptimizer?.model?.apiKey || fallback.scriptOptimizer.model.apiKey,
|
|
80
|
+
name: config?.scriptOptimizer?.model?.name || fallback.scriptOptimizer.model.name,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function loadConfig(): AppConfig {
|
|
87
|
+
if (cachedConfig) {
|
|
88
|
+
return cachedConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dbConfig = loadModelConfigFromDb();
|
|
92
|
+
if (dbConfig) {
|
|
93
|
+
cachedConfig = normalizeConfig(dbConfig);
|
|
94
|
+
return cachedConfig;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (fs.existsSync(configPath)) {
|
|
98
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Partial<AppConfig>;
|
|
99
|
+
cachedConfig = normalizeConfig(parsed);
|
|
100
|
+
saveModelConfigToDb(cachedConfig);
|
|
101
|
+
return cachedConfig;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (fs.existsSync(legacyConfigPath)) {
|
|
105
|
+
const parsed = YAML.parse(fs.readFileSync(legacyConfigPath, 'utf8')) as Partial<AppConfig>;
|
|
106
|
+
cachedConfig = normalizeConfig(parsed);
|
|
107
|
+
saveConfig(cachedConfig);
|
|
108
|
+
return cachedConfig;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
cachedConfig = defaultConfig();
|
|
112
|
+
saveConfig(cachedConfig);
|
|
113
|
+
return cachedConfig;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function saveConfig(config: AppConfig) {
|
|
117
|
+
cachedConfig = normalizeConfig(config);
|
|
118
|
+
saveModelConfigToDb(cachedConfig);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function applyMidsceneEnv() {
|
|
122
|
+
const config = loadConfig();
|
|
123
|
+
for (const [key, value] of Object.entries(config.midscene.env)) {
|
|
124
|
+
process.env[key] = value;
|
|
125
|
+
}
|
|
126
|
+
process.env.MIDSCENE_MODEL_BASE_URL = config.midscene.model.baseUrl;
|
|
127
|
+
process.env.MIDSCENE_MODEL_API_KEY = config.midscene.model.apiKey;
|
|
128
|
+
process.env.MIDSCENE_MODEL_NAME = config.midscene.model.name;
|
|
129
|
+
process.env.MIDSCENE_MODEL_FAMILY = config.midscene.model.family;
|
|
130
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createId,
|
|
3
|
+
querySql,
|
|
4
|
+
runSql,
|
|
5
|
+
sqlJson,
|
|
6
|
+
sqlString,
|
|
7
|
+
} from '../storage/sqlite';
|
|
8
|
+
import type { DeviceLockOwnerType, DeviceLockRecord } from './types';
|
|
9
|
+
|
|
10
|
+
type DeviceLockRow = {
|
|
11
|
+
id: string;
|
|
12
|
+
device_id: string;
|
|
13
|
+
owner_type: DeviceLockOwnerType;
|
|
14
|
+
owner_id: string;
|
|
15
|
+
metadata_json: string;
|
|
16
|
+
acquired_at: string;
|
|
17
|
+
expires_at: string;
|
|
18
|
+
released_at: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let initialized = false;
|
|
22
|
+
|
|
23
|
+
function rowToRecord(row: DeviceLockRow): DeviceLockRecord {
|
|
24
|
+
return {
|
|
25
|
+
id: row.id,
|
|
26
|
+
deviceId: row.device_id,
|
|
27
|
+
ownerType: row.owner_type,
|
|
28
|
+
ownerId: row.owner_id,
|
|
29
|
+
metadata: JSON.parse(row.metadata_json || '{}') as Record<string, unknown>,
|
|
30
|
+
acquiredAt: row.acquired_at,
|
|
31
|
+
expiresAt: row.expires_at,
|
|
32
|
+
releasedAt: row.released_at || '',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function initDeviceLockRepository() {
|
|
37
|
+
if (initialized) return;
|
|
38
|
+
initialized = true;
|
|
39
|
+
runSql(`
|
|
40
|
+
PRAGMA journal_mode = WAL;
|
|
41
|
+
CREATE TABLE IF NOT EXISTS device_locks (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
device_id TEXT NOT NULL,
|
|
44
|
+
owner_type TEXT NOT NULL,
|
|
45
|
+
owner_id TEXT NOT NULL,
|
|
46
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
47
|
+
acquired_at TEXT NOT NULL,
|
|
48
|
+
expires_at TEXT NOT NULL,
|
|
49
|
+
released_at TEXT NULL
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_device_locks_active
|
|
52
|
+
ON device_locks(device_id, released_at, expires_at);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_device_locks_owner
|
|
54
|
+
ON device_locks(owner_id);
|
|
55
|
+
`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function cleanupExpiredDeviceLocks(now: string) {
|
|
59
|
+
initDeviceLockRepository();
|
|
60
|
+
runSql(`
|
|
61
|
+
UPDATE device_locks
|
|
62
|
+
SET released_at = ${sqlString(now)}
|
|
63
|
+
WHERE released_at IS NULL
|
|
64
|
+
AND datetime(expires_at) <= datetime(${sqlString(now)});
|
|
65
|
+
`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function listActiveDeviceLockRecords(now: string) {
|
|
69
|
+
initDeviceLockRepository();
|
|
70
|
+
cleanupExpiredDeviceLocks(now);
|
|
71
|
+
return querySql<DeviceLockRow>(`
|
|
72
|
+
SELECT *
|
|
73
|
+
FROM device_locks
|
|
74
|
+
WHERE released_at IS NULL
|
|
75
|
+
AND datetime(expires_at) > datetime(${sqlString(now)})
|
|
76
|
+
ORDER BY datetime(acquired_at) DESC;
|
|
77
|
+
`).map(rowToRecord);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function findActiveDeviceLockRecord(deviceId: string, now: string) {
|
|
81
|
+
initDeviceLockRepository();
|
|
82
|
+
cleanupExpiredDeviceLocks(now);
|
|
83
|
+
const row = querySql<DeviceLockRow>(`
|
|
84
|
+
SELECT *
|
|
85
|
+
FROM device_locks
|
|
86
|
+
WHERE device_id = ${sqlString(deviceId)}
|
|
87
|
+
AND released_at IS NULL
|
|
88
|
+
AND datetime(expires_at) > datetime(${sqlString(now)})
|
|
89
|
+
ORDER BY datetime(acquired_at) DESC
|
|
90
|
+
LIMIT 1;
|
|
91
|
+
`)[0];
|
|
92
|
+
|
|
93
|
+
return row ? rowToRecord(row) : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function insertDeviceLockRecord(input: {
|
|
97
|
+
deviceId: string;
|
|
98
|
+
ownerType: DeviceLockOwnerType;
|
|
99
|
+
ownerId: string;
|
|
100
|
+
metadata: Record<string, unknown>;
|
|
101
|
+
acquiredAt: string;
|
|
102
|
+
expiresAt: string;
|
|
103
|
+
}) {
|
|
104
|
+
initDeviceLockRepository();
|
|
105
|
+
const id = createId('dlock');
|
|
106
|
+
runSql(`
|
|
107
|
+
INSERT INTO device_locks (
|
|
108
|
+
id,
|
|
109
|
+
device_id,
|
|
110
|
+
owner_type,
|
|
111
|
+
owner_id,
|
|
112
|
+
metadata_json,
|
|
113
|
+
acquired_at,
|
|
114
|
+
expires_at
|
|
115
|
+
)
|
|
116
|
+
VALUES (
|
|
117
|
+
${sqlString(id)},
|
|
118
|
+
${sqlString(input.deviceId)},
|
|
119
|
+
${sqlString(input.ownerType)},
|
|
120
|
+
${sqlString(input.ownerId)},
|
|
121
|
+
${sqlJson(input.metadata)},
|
|
122
|
+
${sqlString(input.acquiredAt)},
|
|
123
|
+
${sqlString(input.expiresAt)}
|
|
124
|
+
);
|
|
125
|
+
`);
|
|
126
|
+
return id;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function refreshDeviceLockRecord(input: { lockId: string; expiresAt: string }) {
|
|
130
|
+
initDeviceLockRepository();
|
|
131
|
+
runSql(`
|
|
132
|
+
UPDATE device_locks
|
|
133
|
+
SET expires_at = ${sqlString(input.expiresAt)}
|
|
134
|
+
WHERE id = ${sqlString(input.lockId)}
|
|
135
|
+
AND released_at IS NULL;
|
|
136
|
+
`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function releaseDeviceLockRecord(ownerId: string, releasedAt: string) {
|
|
140
|
+
initDeviceLockRepository();
|
|
141
|
+
runSql(`
|
|
142
|
+
UPDATE device_locks
|
|
143
|
+
SET released_at = ${sqlString(releasedAt)}
|
|
144
|
+
WHERE owner_id = ${sqlString(ownerId)}
|
|
145
|
+
AND released_at IS NULL;
|
|
146
|
+
`);
|
|
147
|
+
}
|