@justlovepl/desktop-bridge 0.0.0-stage → 0.2.7
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/LICENSE +21 -0
- package/README.md +50 -3
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/bin/desktop-bridge.mjs +118 -0
- package/bin/update.mjs +96 -0
- package/dist/client/Install.cmd +3 -0
- package/dist/client/Install.command +3 -0
- package/dist/client/Install.sh +137 -0
- package/dist/client/Pair-again.cmd +3 -0
- package/dist/client/RePair.cmd +9 -0
- package/dist/client/Start.cmd +4 -0
- package/dist/client/Start.command +3 -0
- package/dist/client/Stop.cmd +4 -0
- package/dist/client/Stop.command +3 -0
- package/dist/client/Uninstall.cmd +3 -0
- package/dist/client/Uninstall.command +3 -0
- package/dist/client/Uninstall.sh +3 -0
- package/dist/client/admin-elevate.mjs +48 -0
- package/dist/client/admin-runtime.mjs +119 -0
- package/dist/client/admin-service.mjs +152 -0
- package/dist/client/agent/agent.mjs +264 -0
- package/dist/client/agent/pair.mjs +31 -0
- package/dist/client/connection.json +1 -0
- package/dist/client/control.mjs +75 -0
- package/dist/client/control.ps1 +12 -0
- package/dist/client/environment-probe.mjs +5 -0
- package/dist/client/environment.mjs +99 -0
- package/dist/client/install-options.mjs +43 -0
- package/dist/client/install.mjs +84 -0
- package/dist/client/install.ps1 +108 -0
- package/dist/client/launch.sh +17 -0
- package/dist/client/lifecycle.mjs +47 -0
- package/dist/client/node-runtime.json +30 -0
- package/dist/client/package-lock.json +8457 -0
- package/dist/client/package.json +23 -0
- package/dist/client/runner.mjs +38 -0
- package/dist/client/start.sh +3 -0
- package/dist/client/startup.mjs +66 -0
- package/dist/client/stop.sh +3 -0
- package/dist/client/uninstall.ps1 +6 -0
- package/dist/client/windows-background.mjs +45 -0
- package/package.json +39 -6
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {fork} from 'node:child_process';
|
|
5
|
+
import {dataRoot,acquireLock,releaseLock,livePid,stopClient} from './lifecycle.mjs';
|
|
6
|
+
import {savedMode} from './install-options.mjs';
|
|
7
|
+
import {setMode,startBackground,writeInstructions} from './startup.mjs';
|
|
8
|
+
import {elevationRequest} from './admin-elevate.mjs';
|
|
9
|
+
import {run} from './lifecycle.mjs';
|
|
10
|
+
const source=path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const args=process.argv.slice(2);let customRoot;
|
|
12
|
+
for(let index=0;index<args.length;index++)if(args[index]==='--root'){if(!args[index+1]||args[index+1].startsWith('--'))throw new Error('--root requires a directory');customRoot=args[index+1];args.splice(index,2);index--;}
|
|
13
|
+
const [action,value,...extra]=args;
|
|
14
|
+
if(!['start','stop','status','mode','pair'].includes(action)||extra.length||(action==='mode'?!['manual','background'].includes(value):value!==undefined))throw new Error('Usage: control.mjs start|stop|status|pair|mode manual|background [--root directory]');
|
|
15
|
+
const root=path.resolve(customRoot??(fs.existsSync(path.join(source,'.desktop-bridge-client'))?source:dataRoot()));
|
|
16
|
+
const launch=JSON.parse(fs.readFileSync(path.join(root,'launch.json'),'utf8'));
|
|
17
|
+
let settings={};try{settings=JSON.parse(fs.readFileSync(path.join(root,'client-settings.json'),'utf8'));}catch{}
|
|
18
|
+
const authStatusPath=path.join(root,'auth-status.json');
|
|
19
|
+
let authStatus={};try{authStatus=JSON.parse(fs.readFileSync(authStatusPath,'utf8'));}catch{}
|
|
20
|
+
const needsPair=authStatus.authStatus==='re_pair_required';
|
|
21
|
+
const pairHelp=process.platform==='win32'?path.join(root,'RePair.cmd'):'Run control.mjs pair locally';
|
|
22
|
+
if(!launch.node||!fs.existsSync(launch.node))throw new Error('私有运行环境缺失,请重新运行安装器自动修复。');
|
|
23
|
+
if(settings.privilege==='administrator'&&process.env.BRIDGE_ADMIN_CONTEXT!=='1'){
|
|
24
|
+
if(action==='status'){console.log(JSON.stringify({mode:settings.mode,privilege:'administrator',authStatus:authStatus.authStatus??'unknown',note:'Administrator service status requires local system inspection.'},null,2));process.exit(0);}
|
|
25
|
+
if(action==='pair')throw new Error('管理员服务配对需要在受保护的服务安装器中操作;不能用普通用户命令修改管理员凭据。');
|
|
26
|
+
const request=elevationRequest(action,{mode:value});
|
|
27
|
+
await run(request.command,request.args);
|
|
28
|
+
if(action==='mode'){
|
|
29
|
+
fs.writeFileSync(path.join(root,'client-settings.json'),JSON.stringify({mode:value,privilege:'administrator',version:'0.2.7'},null,2),{mode:0o600});
|
|
30
|
+
writeInstructions(root,launch.node,value,'administrator');
|
|
31
|
+
}
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
if(action==='status'){
|
|
35
|
+
let heartbeat;try{heartbeat=JSON.parse(fs.readFileSync(path.join(root,'heartbeat.json'),'utf8'));}catch{}
|
|
36
|
+
const running=livePid(path.join(root,'runner-lock','pid'));
|
|
37
|
+
let agentPid;try{agentPid=Number(fs.readFileSync(path.join(root,'agent.lock'),'utf8'));}catch{}
|
|
38
|
+
const fresh=running&&heartbeat&&heartbeat.pid===agentPid&&Date.now()-heartbeat.time<90000&&livePid(path.join(root,'agent.lock'));
|
|
39
|
+
console.log(JSON.stringify({mode:savedMode(root),running,networkConnected:!!(fresh&&heartbeat.connected),localToolsAvailable:!!(fresh&&heartbeat.localToolsAvailable),authStatus:needsPair?'re_pair_required':'ok',...(needsPair?{message:'设备授权已撤销,必须重新配对。',rePairCommand:pairHelp}: {})},null,2));
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
const lock=path.join(root,'install-lock');if(!acquireLock(lock))throw new Error('Another client maintenance operation is running.');
|
|
43
|
+
process.on('exit',()=>releaseLock(lock));
|
|
44
|
+
if(action==='stop'){await stopClient(root);console.log('客户端已停止。设备凭据、日志和任务结果已保留。');process.exit(0);}
|
|
45
|
+
if(action==='mode'){await setMode(root,launch.node,value);console.log(value==='manual'?'已保存手动模式,移除登录自启。使用时运行启动命令,保持窗口,按 Ctrl+C 结束。':'已保存后台模式:登录系统后自动启动。');process.exit(0);}
|
|
46
|
+
if(action==='pair') {
|
|
47
|
+
await stopClient(root);
|
|
48
|
+
fs.rmSync(path.join(root,'maintenance.flag'),{force:true});
|
|
49
|
+
try {
|
|
50
|
+
// A new device token is only issued after the user approves in the browser.
|
|
51
|
+
await run(launch.node,[launch.agent,launch.config,'--pair','--setup-only']);
|
|
52
|
+
const fresh=JSON.parse(fs.readFileSync(launch.config,'utf8'));
|
|
53
|
+
if(!fresh.deviceToken||!fresh.deviceId)throw new Error('配对尚未完成');
|
|
54
|
+
fs.rmSync(authStatusPath,{force:true});
|
|
55
|
+
if(savedMode(root)==='background')await startBackground(root,launch.node);
|
|
56
|
+
console.log('重新配对成功。'+(savedMode(root)==='background'?'后台连接已恢复。':'手动模式:可运行 Start.cmd 启动。'));
|
|
57
|
+
} catch(error) {
|
|
58
|
+
fs.writeFileSync(authStatusPath,JSON.stringify({authStatus:'re_pair_required',reason:'pairing_incomplete',detectedAt:new Date().toISOString(),notificationShown:true}),{mode:0o600});
|
|
59
|
+
fs.writeFileSync(path.join(root,'maintenance.flag'),'pairing incomplete',{mode:0o600});
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
if(needsPair&&action==='start'){console.error('设备 Token 已失效,请先运行 '+pairHelp+' 重新配对。');process.exit(2);}
|
|
65
|
+
if(livePid(path.join(root,'runner-lock','pid'))){console.log('客户端已在运行。若要重新以前台模式启动,请先运行停止命令。');process.exit(0);}
|
|
66
|
+
if(savedMode(root)==='background'){await startBackground(root,launch.node);console.log('客户端已在后台启动。');process.exit(0);}
|
|
67
|
+
fs.rmSync(path.join(root,'maintenance.flag'),{force:true});
|
|
68
|
+
const runner=fork(path.join(root,'runner.mjs'),[root,'--foreground'],{execPath:launch.node,cwd:root,stdio:['inherit','inherit','inherit','ipc']});
|
|
69
|
+
await new Promise((resolve,reject)=>{runner.once('spawn',resolve);runner.once('error',reject);});
|
|
70
|
+
releaseLock(lock);
|
|
71
|
+
console.log('Desktop Bridge 正在此窗口运行。请保持窗口,按 Ctrl+C 或关闭窗口结束。');
|
|
72
|
+
let stopping=false;
|
|
73
|
+
function stop(){if(stopping)return;stopping=true;if(runner.connected)try{runner.send({type:'stop'},()=>{});}catch{}}
|
|
74
|
+
for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.on(signal,stop);
|
|
75
|
+
runner.once('exit',code=>{process.exitCode=stopping?0:code??1;});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
param([ValidateSet('start','stop','status','pair')][string]$Action,[string]$DataDirectory)
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
for ($i = 0; $i -lt $args.Count; $i++) {
|
|
4
|
+
if ($args[$i] -eq '--root') { $i++; if ($i -ge $args.Count) { throw '--root requires a directory.' }; $DataDirectory = $args[$i] }
|
|
5
|
+
else { throw ('Unknown option: ' + $args[$i]) }
|
|
6
|
+
}
|
|
7
|
+
$root = if ($DataDirectory) { $DataDirectory } elseif (Test-Path (Join-Path $PSScriptRoot '.desktop-bridge-client')) { $PSScriptRoot } else { Join-Path $env:LOCALAPPDATA 'DesktopBridgeClient' }
|
|
8
|
+
$launchPath = Join-Path $root 'launch.json'
|
|
9
|
+
if (-not (Test-Path $launchPath)) { throw 'Install Desktop Bridge first.' }
|
|
10
|
+
$launch = Get-Content $launchPath -Raw | ConvertFrom-Json
|
|
11
|
+
& $launch.node (Join-Path $root 'control.mjs') $Action --root $root
|
|
12
|
+
exit $LASTEXITCODE
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import {probeModules,verifyLocalToolsInProcess} from './environment.mjs';
|
|
2
|
+
const [action,release,workingDirectory]=process.argv.slice(2);
|
|
3
|
+
if(action==='dependencies')await probeModules(release);
|
|
4
|
+
else if(action==='tools')await verifyLocalToolsInProcess({node:process.execPath,release,workingDirectory});
|
|
5
|
+
else throw new Error('Unknown environment probe');
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {createHash} from 'node:crypto';
|
|
4
|
+
import {createRequire} from 'node:module';
|
|
5
|
+
import {pathToFileURL,fileURLToPath} from 'node:url';
|
|
6
|
+
import {spawn} from 'node:child_process';
|
|
7
|
+
import {Readable} from 'node:stream';
|
|
8
|
+
import {pipeline} from 'node:stream/promises';
|
|
9
|
+
import {run} from './lifecycle.mjs';
|
|
10
|
+
export function capture(command,args,{timeout=15000,...options}={}) {
|
|
11
|
+
return new Promise((resolve,reject)=>{
|
|
12
|
+
const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],...options});let stdout='',stderr='';
|
|
13
|
+
const timer=setTimeout(()=>{child.kill('SIGKILL');reject(new Error(path.basename(command)+' 检查超时'));},timeout);
|
|
14
|
+
child.stdout.on('data',chunk=>{if(stdout.length<65536)stdout+=chunk;});child.stderr.on('data',chunk=>{if(stderr.length<65536)stderr+=chunk;});
|
|
15
|
+
child.once('error',error=>{clearTimeout(timer);reject(error);});
|
|
16
|
+
child.once('exit',(code,signal)=>{clearTimeout(timer);code===0?resolve(stdout.trim()):reject(new Error(path.basename(command)+' 检查失败 ('+(signal??code)+'): '+stderr.slice(0,300)));});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export async function runtimeReady(node,npm) {
|
|
20
|
+
try{return await capture(node,['--version'])==='v24.19.0'&&/^\d+\.\d+\.\d+$/.test(await capture(node,[npm,'--version']));}catch{return false;}
|
|
21
|
+
}
|
|
22
|
+
export async function ensureRuntime(root,source,{runtimeSource=process.platform==='win32'?path.dirname(process.execPath):path.dirname(path.dirname(process.execPath))}={}) {
|
|
23
|
+
const manifest=JSON.parse(fs.readFileSync(path.join(source,'node-runtime.json'),'utf8'));
|
|
24
|
+
const runtime=path.join(root,'runtime',`node-v${manifest.version}-${process.platform}-${process.arch}`);
|
|
25
|
+
const node=path.join(runtime,process.platform==='win32'?'node.exe':'bin/node');
|
|
26
|
+
const npm=path.join(runtime,process.platform==='win32'?'node_modules/npm/bin/npm-cli.js':'lib/node_modules/npm/bin/npm-cli.js');
|
|
27
|
+
if(await runtimeReady(node,npm)){console.log('环境检查:私有 Node.js / npm 正常。');return {runtime,node,npm};}
|
|
28
|
+
console.log('环境检查:正在自动补齐私有 Node.js / npm,不修改系统环境。');
|
|
29
|
+
const sourceNode=path.join(runtimeSource,process.platform==='win32'?'node.exe':'bin/node');
|
|
30
|
+
const sourceNpm=path.join(runtimeSource,process.platform==='win32'?'node_modules/npm/bin/npm-cli.js':'lib/node_modules/npm/bin/npm-cli.js');
|
|
31
|
+
const same=fs.existsSync(runtime)&&fs.realpathSync(runtimeSource)===fs.realpathSync(runtime);
|
|
32
|
+
if(!same&&await runtimeReady(sourceNode,sourceNpm)){
|
|
33
|
+
fs.mkdirSync(path.dirname(runtime),{recursive:true,mode:0o700});fs.cpSync(runtimeSource,runtime,{recursive:true,force:true,verbatimSymlinks:true});
|
|
34
|
+
}else{
|
|
35
|
+
const key=(process.platform==='win32'?'win':process.platform)+'-'+process.arch,target=manifest.platforms[key];
|
|
36
|
+
if(!target)throw new Error('不支持的系统或处理器:'+key);
|
|
37
|
+
const temporary=fs.mkdtempSync(path.join(root,'.runtime-repair-'));
|
|
38
|
+
try{
|
|
39
|
+
const archive=path.join(temporary,target.filename),url=`https://nodejs.org/dist/v${manifest.version}/${target.filename}`;
|
|
40
|
+
const response=await fetch(url,{signal:AbortSignal.timeout(120000)});if(!response.ok||!response.body)throw new Error('官方下载失败 HTTP '+response.status);
|
|
41
|
+
await pipeline(Readable.fromWeb(response.body),fs.createWriteStream(archive,{mode:0o600}));
|
|
42
|
+
const hash=createHash('sha256');for await(const data of fs.createReadStream(archive))hash.update(data);
|
|
43
|
+
if(hash.digest('hex')!==target.sha256)throw new Error('Node.js 官方运行环境校验失败,请检查网络后重试。');
|
|
44
|
+
if(process.platform==='win32'){
|
|
45
|
+
const ps=value=>"'"+value.replace(/'/g,"''")+"'";
|
|
46
|
+
await run('powershell.exe',['-NoProfile','-NonInteractive','-Command',`Expand-Archive -LiteralPath ${ps(archive)} -DestinationPath ${ps(temporary)} -Force`]);
|
|
47
|
+
}else await run('tar',['-xzf',archive,'-C',temporary]);
|
|
48
|
+
const extracted=path.join(temporary,target.filename.replace(/\.zip$|\.tar\.gz$/,''));
|
|
49
|
+
fs.mkdirSync(path.dirname(runtime),{recursive:true,mode:0o700});
|
|
50
|
+
const activeNode=process.platform==='win32'&&fs.existsSync(node)&&fs.realpathSync(node)===fs.realpathSync(process.execPath);
|
|
51
|
+
fs.cpSync(extracted,runtime,{recursive:true,force:true,verbatimSymlinks:true,filter:file=>!activeNode||path.resolve(file)!==path.join(extracted,'node.exe')});
|
|
52
|
+
}finally{fs.rmSync(temporary,{recursive:true,force:true});}
|
|
53
|
+
}
|
|
54
|
+
if(!await runtimeReady(node,npm))throw new Error('无法准备私有 Node.js/npm。请检查可写权限与 nodejs.org 网络访问后重试。');
|
|
55
|
+
return {runtime,node,npm};
|
|
56
|
+
}
|
|
57
|
+
export async function probeModules(release) {
|
|
58
|
+
const require=createRequire(path.join(release,'package.json'));
|
|
59
|
+
const entry=path.join(release,'node_modules','@wonderwhy-er','desktop-commander','dist','index.js');if(!fs.existsSync(entry))throw new Error('Desktop Commander 入口缺失');
|
|
60
|
+
for(const name of ['@modelcontextprotocol/sdk/client/index.js','@modelcontextprotocol/sdk/client/stdio.js','ws','@vscode/ripgrep'])if(!fs.existsSync(require.resolve(name)))throw new Error(name+' 文件缺失');
|
|
61
|
+
const {Client}=await import(pathToFileURL(require.resolve('@modelcontextprotocol/sdk/client/index.js')));
|
|
62
|
+
const {StdioClientTransport}=await import(pathToFileURL(require.resolve('@modelcontextprotocol/sdk/client/stdio.js')));
|
|
63
|
+
const WebSocket=require('ws');if(typeof Client!=='function'||typeof StdioClientTransport!=='function'||typeof WebSocket!=='function')throw new Error('SDK/WebSocket 依赖异常');
|
|
64
|
+
const {rgPath}=await import(pathToFileURL(require.resolve('@vscode/ripgrep')));
|
|
65
|
+
if(!/^ripgrep\s/.test(await capture(rgPath,['--version'])))throw new Error('平台搜索工具 ripgrep 不可执行');
|
|
66
|
+
return {Client,StdioClientTransport,entry};
|
|
67
|
+
}
|
|
68
|
+
export async function prepareDependencies({node,npm,release,force=false}) {
|
|
69
|
+
const hash=createHash('sha256').update(fs.readFileSync(path.join(release,'package.json'))).update(fs.readFileSync(path.join(release,'package-lock.json'))).digest('hex');
|
|
70
|
+
const marker=path.join(release,'.dependencies.sha256');let previous;try{previous=fs.readFileSync(marker,'utf8');}catch{}
|
|
71
|
+
let needsInstall=force||hash!==previous;
|
|
72
|
+
if(!needsInstall)try{await probeDependencyProcess(node,release);}catch{needsInstall=true;}
|
|
73
|
+
if(needsInstall){
|
|
74
|
+
console.log('环境检查:自动安装或修复本地工具依赖,首次运行需要联网……');
|
|
75
|
+
try{await run(node,[npm,'ci','--omit=dev','--ignore-scripts','--no-fund','--no-audit'],{cwd:release,env:{...process.env,PATH:path.dirname(node)+path.delimiter+(process.env.PATH??'')}});}
|
|
76
|
+
catch{throw new Error('依赖自动安装失败。请确认安装目录可写,并能访问 registry.npmjs.org,然后重试。');}
|
|
77
|
+
}
|
|
78
|
+
await probeDependencyProcess(node,release);fs.writeFileSync(marker,hash,{mode:0o600});
|
|
79
|
+
console.log('环境检查:SDK、WebSocket、Desktop Commander 和本机搜索工具正常。');
|
|
80
|
+
return {installed:needsInstall};
|
|
81
|
+
}
|
|
82
|
+
const probePath=fileURLToPath(new URL('./environment-probe.mjs',import.meta.url));
|
|
83
|
+
async function probeDependencyProcess(node,release){await capture(node,[probePath,'dependencies',release],{timeout:30000});}
|
|
84
|
+
export async function verifyLocalTools({node,release,workingDirectory}){
|
|
85
|
+
const output=await capture(node,[probePath,'tools',release,workingDirectory],{timeout:70000});console.log(output);return Number(output.match(/(\d+) 个工具/)?.[1]);
|
|
86
|
+
}
|
|
87
|
+
export async function verifyLocalToolsInProcess({node,release,workingDirectory}) {
|
|
88
|
+
console.log('正在验证本地工具可以实际执行……');
|
|
89
|
+
const {Client,StdioClientTransport,entry}=await probeModules(release);
|
|
90
|
+
const client=new Client({name:'desktop-bridge-install-check',version:'0.2.7'},{capabilities:{}});
|
|
91
|
+
const transport=new StdioClientTransport({command:node,args:[entry],cwd:workingDirectory,stderr:'pipe'});
|
|
92
|
+
try{
|
|
93
|
+
const connecting=client.connect(transport,{timeout:30000});transport.stderr?.on('data',()=>{});await connecting;
|
|
94
|
+
const listed=await client.listTools({}, {timeout:15000});if(!listed.tools.length)throw new Error('本地工具列表为空');
|
|
95
|
+
const probe=listed.tools.find(tool=>['get_config','list_sessions'].includes(tool.name));if(!probe)throw new Error('本地工具缺少安全健康检查接口');
|
|
96
|
+
const result=await client.callTool({name:probe.name,arguments:{}},undefined,{timeout:15000});if(result.isError)throw new Error('本地工具执行检查未通过');
|
|
97
|
+
console.log('本地工具验证通过:'+listed.tools.length+' 个工具,实际调用正常。');return listed.tools.length;
|
|
98
|
+
}finally{await client.close();}
|
|
99
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {createInterface} from 'node:readline/promises';
|
|
4
|
+
import {startupFile} from './lifecycle.mjs';
|
|
5
|
+
export function parseInstallerOptions(args) {
|
|
6
|
+
const options={};
|
|
7
|
+
const switches={'--repair':'repair','--no-browser':'noBrowser','--no-start':'noStart','--stop':'stop','--uninstall':'uninstall','--purge':'purge','--administrator':'administrator'};
|
|
8
|
+
for(let i=0;i<args.length;i++){
|
|
9
|
+
const flag=args[i];
|
|
10
|
+
if(flag==='--root'||flag==='--mode'){
|
|
11
|
+
const value=args[++i];if(!value||value.startsWith('--'))throw new Error(flag+' requires a value');
|
|
12
|
+
const key=flag.slice(2);if(options[key]&&options[key]!==value)throw new Error('Conflicting '+flag+' values');options[key]=value;
|
|
13
|
+
}else if(switches[flag])options[switches[flag]]=true;
|
|
14
|
+
else throw new Error('Unknown option: '+flag);
|
|
15
|
+
}
|
|
16
|
+
if(options.mode&&!['manual','background'].includes(options.mode))throw new Error('--mode must be manual or background');
|
|
17
|
+
if(options.noStart&&options.mode==='background')throw new Error('--no-start selects manual mode and conflicts with --mode background');
|
|
18
|
+
if(options.noStart)options.mode='manual';
|
|
19
|
+
if(options.administrator&&(options.stop||options.uninstall))throw new Error('Administrator maintenance uses admin-elevate.mjs, not normal uninstall/stop.');
|
|
20
|
+
if(options.purge&&!options.uninstall)throw new Error('--purge requires --uninstall');
|
|
21
|
+
if(options.stop&&options.uninstall)throw new Error('--stop and --uninstall cannot be combined');
|
|
22
|
+
if((options.stop||options.uninstall)&&(options.mode||options.repair))throw new Error('Maintenance actions cannot be combined with --mode, --no-start or --repair');
|
|
23
|
+
return options;
|
|
24
|
+
}
|
|
25
|
+
export function savedMode(root) {
|
|
26
|
+
try{const mode=JSON.parse(fs.readFileSync(path.join(root,'client-settings.json'),'utf8')).mode;if(['manual','background'].includes(mode))return mode;throw new Error('Invalid saved mode');}
|
|
27
|
+
catch(error){if(error.code!=='ENOENT')throw new Error('Cannot read saved client mode: '+error.message);}
|
|
28
|
+
// Preserve the actual choice of installations created before settings existed.
|
|
29
|
+
if(fs.existsSync(path.join(root,'.desktop-bridge-client'))||fs.existsSync(path.join(root,'agent-config.json')))return fs.existsSync(startupFile(root))?'background':'manual';
|
|
30
|
+
}
|
|
31
|
+
export async function chooseMode(options,{previous,interactive=!!(process.stdin.isTTY&&process.stdout.isTTY),ask}={}) {
|
|
32
|
+
if(options.stop||options.uninstall)return;
|
|
33
|
+
if(options.mode)return options.mode;
|
|
34
|
+
if(!interactive)throw new Error('请选择运行模式:非交互式安装必须提供 --mode manual 或 --mode background。--no-start 等同手动模式。');
|
|
35
|
+
let readline;
|
|
36
|
+
if(!ask){readline=createInterface({input:process.stdin,output:process.stdout});ask=question=>readline.question(question);}
|
|
37
|
+
try{
|
|
38
|
+
for(;;){
|
|
39
|
+
const answer=(await ask('请选择 Desktop Bridge 的运行方式:\n 1) 手动模式 manual:使用时运行启动命令,保持窗口,Ctrl+C 结束。\n 2) 后台模式 background:现在后台运行,登录系统后自动启动。\n输入 1 或 2(必须选择,回车不使用默认值): ')).trim().toLowerCase();
|
|
40
|
+
if(['1','manual'].includes(answer))return 'manual';if(['2','background'].includes(answer))return 'background';
|
|
41
|
+
}
|
|
42
|
+
}finally{readline?.close();}
|
|
43
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
5
|
+
import {execFileSync} from 'node:child_process';
|
|
6
|
+
import {ensureRuntime,prepareDependencies,verifyLocalTools} from './environment.mjs';
|
|
7
|
+
import {parseInstallerOptions,chooseMode,savedMode} from './install-options.mjs';
|
|
8
|
+
import {setMode,commandFor,writeInstructions} from './startup.mjs';
|
|
9
|
+
import {elevationRequest} from './admin-elevate.mjs';
|
|
10
|
+
import {dataRoot,run,acquireLock,releaseLock,stopClient,removeStartup} from './lifecycle.mjs';
|
|
11
|
+
const source=path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const options=parseInstallerOptions(process.argv.slice(2));
|
|
13
|
+
const root=path.resolve(options.root??dataRoot());
|
|
14
|
+
const previousSettings=fs.existsSync(path.join(root,'client-settings.json'))?JSON.parse(fs.readFileSync(path.join(root,'client-settings.json'),'utf8')):{};
|
|
15
|
+
if(previousSettings.privilege==='administrator'&&(options.stop||options.uninstall))throw new Error('Administrator installation uses the installed control/administrator uninstall helper; user-mode cleanup would leave the protected service active.');
|
|
16
|
+
const adminRequested=!!(options.administrator||previousSettings.privilege==='administrator');
|
|
17
|
+
const mode=await chooseMode(options,{previous:options.stop||options.uninstall?undefined:savedMode(root)});
|
|
18
|
+
if(root===path.parse(root).root||root===os.homedir()||root===source||source.startsWith(root+path.sep))throw new Error('Choose a dedicated client data directory.');
|
|
19
|
+
fs.mkdirSync(root,{recursive:true,mode:0o700});if(process.platform!=='win32')fs.chmodSync(root,0o700);
|
|
20
|
+
const installLock=path.join(root,'install-lock');
|
|
21
|
+
if(!acquireLock(installLock))throw new Error('Another client maintenance operation is running.');
|
|
22
|
+
process.on('exit',()=>releaseLock(installLock));
|
|
23
|
+
if(process.platform==='win32'){
|
|
24
|
+
// SSH can set USERDOMAIN to WORKGROUP. Resolve the actual Windows token SID.
|
|
25
|
+
const account=execFileSync('whoami.exe',['/user','/fo','csv','/nh'],{encoding:'utf8',windowsHide:true});
|
|
26
|
+
const sid=account.match(/\bS-1-\d+(?:-\d+)+\b/)?.[0];
|
|
27
|
+
if(!sid)throw new Error('Cannot identify the signed-in Windows account SID.');
|
|
28
|
+
const identity='*'+sid;
|
|
29
|
+
// Change only the root ACL. Recursive /inheritance:r clears file ACLs on Windows.
|
|
30
|
+
await run('icacls.exe',[root,'/inheritance:r','/grant:r',identity+':(OI)(CI)F','*S-1-5-18:(OI)(CI)F','/Q']);
|
|
31
|
+
}
|
|
32
|
+
if(options.uninstall&&!fs.existsSync(path.join(root,'.desktop-bridge-client'))){
|
|
33
|
+
let legacy;try{legacy=JSON.parse(fs.readFileSync(path.join(root,'agent-config.json'),'utf8'));}catch{}
|
|
34
|
+
if(!legacy?.mcpEntry||!legacy?.runtimeDir)throw new Error('This directory is not a recognized client installation.');
|
|
35
|
+
}
|
|
36
|
+
await stopClient(root);
|
|
37
|
+
if(options.stop){console.log('Client stopped. Use your installed Start shortcut or control.mjs start to resume.');process.exit(0);}
|
|
38
|
+
if(options.uninstall){
|
|
39
|
+
await removeStartup(root);
|
|
40
|
+
// Keep credentials and result journal by default so a reinstall can recover them.
|
|
41
|
+
for(const name of ['releases','runtime','runner.mjs','lifecycle.mjs','control.mjs','startup.mjs','windows-background.mjs','install-options.mjs','admin-runtime.mjs','admin-service.mjs','admin-elevate.mjs','launch.json','run-client.cmd','heartbeat.json','START-HERE.txt','Start.cmd','Stop.cmd','RePair.cmd','Start.command','Stop.command','start.sh','stop.sh','launch.sh','control.ps1'])fs.rmSync(path.join(root,name),{recursive:true,force:true});
|
|
42
|
+
if(options.purge){releaseLock(installLock);fs.rmSync(root,{recursive:true,force:true});}
|
|
43
|
+
console.log('Client uninstalled. Revoke the device in device management to unlink it.');process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
fs.writeFileSync(path.join(root,'.desktop-bridge-client'),'desktop-bridge-client\n',{mode:0o600});
|
|
46
|
+
if(process.versions.node.split('.')[0]!=='24')throw new Error('Use Install.cmd, Install.command or Install.sh to prepare the supported Node 24 runtime.');
|
|
47
|
+
const {node,npm}=await ensureRuntime(root,source);
|
|
48
|
+
const release=path.join(root,'releases','0.2.7'),configPath=path.join(root,'agent-config.json');
|
|
49
|
+
fs.mkdirSync(release,{recursive:true,mode:0o700});
|
|
50
|
+
for(const file of ['package.json','package-lock.json'])fs.copyFileSync(path.join(source,file),path.join(release,file));
|
|
51
|
+
const agentSource=fs.existsSync(path.join(source,'agent'))?path.join(source,'agent'):path.join(source,'..','agent');
|
|
52
|
+
fs.cpSync(agentSource,path.join(release,'agent'),{recursive:true,force:true});
|
|
53
|
+
for(const file of ['runner.mjs','lifecycle.mjs','control.mjs','startup.mjs','windows-background.mjs','install-options.mjs','admin-runtime.mjs','admin-service.mjs','admin-elevate.mjs','Start.cmd','Stop.cmd','RePair.cmd','Start.command','Stop.command','start.sh','stop.sh','launch.sh','control.ps1'])fs.copyFileSync(path.join(source,file),path.join(root,file));
|
|
54
|
+
await prepareDependencies({node,npm,release,force:!!options.repair});
|
|
55
|
+
try{await verifyLocalTools({node,release,workingDirectory:root});}
|
|
56
|
+
catch{
|
|
57
|
+
console.log('本地工具验证未通过,正在自动重新安装依赖后重试一次……');
|
|
58
|
+
await prepareDependencies({node,npm,release,force:true});
|
|
59
|
+
try{await verifyLocalTools({node,release,workingDirectory:root});}
|
|
60
|
+
catch{throw new Error('本地工具仍无法执行,安装尚未完成。请检查目录权限、安全软件或网络后重试;客户端保持停止状态。');}
|
|
61
|
+
}
|
|
62
|
+
let config=JSON.parse(fs.readFileSync(fs.existsSync(configPath)?configPath:path.join(source,'connection.json'),'utf8').replace(/^\uFEFF/,''));
|
|
63
|
+
config={...config,runtimeDir:release,mcpEntry:path.join(release,'node_modules','@wonderwhy-er','desktop-commander','dist','index.js'),defaultShell:process.platform==='win32'?'powershell.exe':process.platform==='darwin'?'/bin/zsh':'/bin/bash'};
|
|
64
|
+
fs.writeFileSync(configPath,JSON.stringify(config,null,2),{mode:0o600});
|
|
65
|
+
fs.rmSync(path.join(root,'maintenance.flag'),{force:true});
|
|
66
|
+
const pairArgs=[path.join(release,'agent','agent.mjs'),configPath,'--setup-only'];
|
|
67
|
+
if(options.repair)pairArgs.push('--pair');if(options.noBrowser)pairArgs.push('--no-browser');
|
|
68
|
+
try{await run(node,pairArgs);}catch(error){fs.writeFileSync(path.join(root,'maintenance.flag'),'pairing failed',{mode:0o600});throw error;}
|
|
69
|
+
fs.writeFileSync(path.join(root,'launch.json'),JSON.stringify({node,agent:path.join(release,'agent','agent.mjs'),config:configPath}),{mode:0o600});
|
|
70
|
+
await setMode(root,node,adminRequested?'manual':mode);
|
|
71
|
+
if(adminRequested){
|
|
72
|
+
// Pair before privilege elevation. The protected service is never installed
|
|
73
|
+
// until the OS shows a UAC/sudo prompt and the user explicitly approves it.
|
|
74
|
+
const helper=path.join(root,'admin-elevate.mjs');
|
|
75
|
+
const request=elevationRequest('install',{source:root,mode});
|
|
76
|
+
await run(request.command,request.args);
|
|
77
|
+
fs.writeFileSync(path.join(root,'client-settings.json'),JSON.stringify({mode,privilege:'administrator',version:'0.2.7'},null,2),{mode:0o600});
|
|
78
|
+
writeInstructions(root,node,mode,'administrator');
|
|
79
|
+
}
|
|
80
|
+
console.log(adminRequested?'管理员运行已授权并配置:'+mode+'。':mode==='manual'?'安装和配对完成:手动模式。使用时运行启动命令,保持窗口,按 Ctrl+C 结束;没有后台实例或登录自启。':'安装和配对完成:后台模式。客户端现在后台运行,登录系统后自动启动。');
|
|
81
|
+
console.log('启动命令: '+commandFor(node,root,'start'));
|
|
82
|
+
console.log('停止命令: '+commandFor(node,root,'stop'));
|
|
83
|
+
console.log('使用说明: '+path.join(root,'START-HERE.txt'));
|
|
84
|
+
console.log('设备管理: '+config.serverUrl);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
param([switch]$Repair,[switch]$NoBrowser,[switch]$NoStart,[switch]$Stop,[switch]$Uninstall,[switch]$Purge,[string]$DataDirectory,[string]$Mode,[switch]$Administrator)
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
# Validate every portable option before downloading or changing the installation.
|
|
4
|
+
for ($i = 0; $i -lt $args.Count; $i++) {
|
|
5
|
+
switch ($args[$i]) {
|
|
6
|
+
'--repair' { $Repair = $true }
|
|
7
|
+
'--administrator' { $Administrator = $true }
|
|
8
|
+
'--no-browser' { $NoBrowser = $true }
|
|
9
|
+
'--no-start' { $NoStart = $true }
|
|
10
|
+
'--stop' { $Stop = $true }
|
|
11
|
+
'--uninstall' { $Uninstall = $true }
|
|
12
|
+
'--purge' { $Purge = $true }
|
|
13
|
+
'--mode' {
|
|
14
|
+
$i++
|
|
15
|
+
if ($i -ge $args.Count -or !$args[$i] -or $args[$i].StartsWith('--')) { throw '--mode 缺少 manual 或 background 参数值。' }
|
|
16
|
+
if ($Mode -and $Mode -ne $args[$i]) { throw '不能同时指定不同的 --mode。' }
|
|
17
|
+
$Mode = $args[$i]
|
|
18
|
+
}
|
|
19
|
+
'--root' {
|
|
20
|
+
$i++
|
|
21
|
+
if ($i -ge $args.Count -or !$args[$i] -or $args[$i].StartsWith('--')) { throw '--root 缺少目录参数值。' }
|
|
22
|
+
$DataDirectory = $args[$i]
|
|
23
|
+
}
|
|
24
|
+
default { throw ('未知参数:' + $args[$i]) }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if ($PSBoundParameters.ContainsKey('Mode') -and !$Mode) { throw '-Mode 缺少 manual 或 background 参数值。' }
|
|
28
|
+
if ($PSBoundParameters.ContainsKey('DataDirectory') -and !$DataDirectory) { throw '-DataDirectory 缺少目录参数值。' }
|
|
29
|
+
if ($Mode -and $Mode -cnotin @('manual','background')) { throw '--mode 只能是 manual 或 background。' }
|
|
30
|
+
if ($NoStart -and $Mode -eq 'background') { throw '--no-start 不能与 --mode background 同时使用。' }
|
|
31
|
+
if ($Stop -and $Uninstall) { throw '--stop 不能与 --uninstall 同时使用。' }
|
|
32
|
+
if ($Purge -and !$Uninstall) { throw '--purge 必须与 --uninstall 一起使用。' }
|
|
33
|
+
if ($PSVersionTable.PSVersion -lt [version]'5.1') { throw '需要 Windows PowerShell 5.1 或更新版本。' }
|
|
34
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
35
|
+
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
36
|
+
try { $metadata = Get-Content (Join-Path $PSScriptRoot 'node-runtime.json') -Raw | ConvertFrom-Json }
|
|
37
|
+
catch { throw '无法读取官方 Node 环境清单,请重新解压完整的插件安装包。' }
|
|
38
|
+
if ($metadata.version -ne '24.19.0') { throw 'Node 环境清单版本无效,需要 24.19.0。' }
|
|
39
|
+
$processor = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
|
|
40
|
+
if (!$processor) { throw '无法识别 Windows 处理器架构。' }
|
|
41
|
+
$arch = switch ($processor.ToUpperInvariant()) { 'ARM64' { 'arm64' } 'AMD64' { 'x64' } default { throw '支持的处理器:x64 和 ARM64。' } }
|
|
42
|
+
$entry = $metadata.platforms.('win-' + $arch)
|
|
43
|
+
if (!$entry -or $entry.filename -ne ('node-v24.19.0-win-' + $arch + '.zip') -or $entry.sha256 -cnotmatch '^[0-9a-f]{64}$') { throw '官方 Node 下载清单缺失或损坏,请重新解压安装包。' }
|
|
44
|
+
if (!$DataDirectory -and !$env:LOCALAPPDATA) { throw '无法识别当前用户数据目录,请通过 -DataDirectory 指定专用目录。' }
|
|
45
|
+
$dataRoot = if ($DataDirectory) { $DataDirectory } else { Join-Path $env:LOCALAPPDATA 'DesktopBridgeClient' }
|
|
46
|
+
$runtime = Join-Path $dataRoot ('runtime\node-v' + $metadata.version + '-win32-' + $arch)
|
|
47
|
+
$existingNode = Join-Path $runtime 'node.exe'
|
|
48
|
+
$existingNpm = Join-Path $runtime 'node_modules\npm\bin\npm-cli.js'
|
|
49
|
+
function Test-PrivateRuntime([string]$Node, [string]$Npm) {
|
|
50
|
+
if (!(Test-Path -LiteralPath $Node -PathType Leaf) -or !(Test-Path -LiteralPath $Npm -PathType Leaf)) { return $false }
|
|
51
|
+
try {
|
|
52
|
+
$version = & $Node --version 2>$null
|
|
53
|
+
if ($LASTEXITCODE -ne 0 -or $version -cne ('v' + $metadata.version)) { return $false }
|
|
54
|
+
$npmVersion = & $Node $Npm --version 2>$null
|
|
55
|
+
return $LASTEXITCODE -eq 0 -and $npmVersion -match '^\d+\.\d+\.\d+(?:[-+].*)?$'
|
|
56
|
+
} catch { return $false }
|
|
57
|
+
}
|
|
58
|
+
$useExisting = Test-PrivateRuntime $existingNode $existingNpm
|
|
59
|
+
$installArgs = @((Join-Path $PSScriptRoot 'install.mjs'))
|
|
60
|
+
if ($Administrator) { $installArgs += '--administrator' }; if ($Repair) { $installArgs += '--repair' }; if ($NoBrowser) { $installArgs += '--no-browser' }; if ($NoStart) { $installArgs += '--no-start' }
|
|
61
|
+
if ($Stop) { $installArgs += '--stop' }; if ($Uninstall) { $installArgs += '--uninstall' }; if ($Purge) { $installArgs += '--purge' }
|
|
62
|
+
if ($DataDirectory) { $installArgs += @('--root',$DataDirectory) }
|
|
63
|
+
if ($Mode) { $installArgs += @('--mode',$Mode) }
|
|
64
|
+
if ($useExisting -and !$Uninstall -and !$Stop) {
|
|
65
|
+
& $existingNode @installArgs
|
|
66
|
+
if ($LASTEXITCODE -ne 0) { throw '客户端操作失败,请查看上方诊断后重试。' }
|
|
67
|
+
exit 0
|
|
68
|
+
}
|
|
69
|
+
if (!$useExisting) {
|
|
70
|
+
if (Test-Path -LiteralPath $runtime) { Write-Host '私有 Node 或 npm 无法正常运行,将下载校验后的官方环境进行修复。' }
|
|
71
|
+
foreach ($tool in @('Invoke-WebRequest','Get-FileHash','Expand-Archive')) {
|
|
72
|
+
if (!(Get-Command $tool -ErrorAction SilentlyContinue)) { throw ('缺少 PowerShell 原生组件 ' + $tool + ',尚未开始下载。请修复 Windows PowerShell 5.1。') }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
# Give only the current Windows user access before placing any runtime files here.
|
|
76
|
+
$temp = Join-Path ([IO.Path]::GetTempPath()) ('desktop-bridge-' + [Guid]::NewGuid().ToString('N'))
|
|
77
|
+
try {
|
|
78
|
+
try {
|
|
79
|
+
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
80
|
+
$acl = New-Object System.Security.AccessControl.DirectorySecurity
|
|
81
|
+
$acl.SetOwner($sid)
|
|
82
|
+
$acl.SetAccessRuleProtection($true,$false)
|
|
83
|
+
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow')
|
|
84
|
+
$acl.AddAccessRule($rule)
|
|
85
|
+
if ($PSVersionTable.PSEdition -eq 'Desktop') { [IO.Directory]::CreateDirectory($temp,$acl) | Out-Null }
|
|
86
|
+
else { [IO.FileSystemAclExtensions]::CreateDirectory([IO.DirectoryInfo]::new($temp),$acl) | Out-Null }
|
|
87
|
+
} catch { throw '无法创建仅当前用户可访问的临时目录,请检查临时目录权限和磁盘空间。' }
|
|
88
|
+
if ($useExisting) {
|
|
89
|
+
# Maintenance must not run from the executable that uninstall removes.
|
|
90
|
+
$node = Join-Path $temp 'node.exe'
|
|
91
|
+
Copy-Item -LiteralPath $existingNode -Destination $node
|
|
92
|
+
} else {
|
|
93
|
+
$archive = Join-Path $temp 'runtime.zip'
|
|
94
|
+
Write-Host '正在准备经过 SHA-256 校验的官方 Node 24.19.0 环境…'
|
|
95
|
+
try { Invoke-WebRequest -UseBasicParsing ('https://nodejs.org/dist/v' + $metadata.version + '/' + $entry.filename) -OutFile $archive -TimeoutSec 60 }
|
|
96
|
+
catch { throw '官方环境下载失败,请检查网络、HTTPS 证书或代理设置。' }
|
|
97
|
+
try { $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() }
|
|
98
|
+
catch { throw '无法读取下载文件进行 SHA-256 校验,请检查文件权限。' }
|
|
99
|
+
if ($actual -cne $entry.sha256) { throw '官方 Node 环境校验失败(checksum mismatch),已拒绝执行。' }
|
|
100
|
+
try { Expand-Archive -LiteralPath $archive -DestinationPath $temp }
|
|
101
|
+
catch { throw '官方环境解压失败,请检查压缩包、磁盘空间和临时目录权限。' }
|
|
102
|
+
$node = Join-Path $temp ('node-v' + $metadata.version + '-win-' + $arch + '\node.exe')
|
|
103
|
+
$npm = Join-Path (Split-Path $node -Parent) 'node_modules\npm\bin\npm-cli.js'
|
|
104
|
+
if (!(Test-PrivateRuntime $node $npm)) { throw '下载的 Node 或 npm 无法运行,请检查系统架构、执行权限并重新下载。' }
|
|
105
|
+
}
|
|
106
|
+
& $node @installArgs
|
|
107
|
+
if ($LASTEXITCODE -ne 0) { throw '客户端操作失败,请查看上方诊断后重试。' }
|
|
108
|
+
} finally { if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue } }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
SOURCE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
4
|
+
ACTION=$1
|
|
5
|
+
shift
|
|
6
|
+
if [ -f "$SOURCE/.desktop-bridge-client" ]; then DATA_ROOT=$SOURCE
|
|
7
|
+
elif [ "$(uname -s)" = Darwin ]; then DATA_ROOT="$HOME/Library/Application Support/DesktopBridge"
|
|
8
|
+
else DATA_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/desktop-bridge"; fi
|
|
9
|
+
NEXT_ROOT=0
|
|
10
|
+
for ARG in "$@"; do
|
|
11
|
+
if [ "$NEXT_ROOT" = 1 ]; then DATA_ROOT=$ARG; NEXT_ROOT=0; elif [ "$ARG" = --root ]; then NEXT_ROOT=1; fi
|
|
12
|
+
done
|
|
13
|
+
case "$(uname -s)" in Darwin) PLATFORM=darwin;; Linux) PLATFORM=linux;; *) echo 'Use Start.cmd on Windows.' >&2; exit 1;; esac
|
|
14
|
+
case "$(uname -m)" in x86_64|amd64) ARCH=x64;; arm64|aarch64) ARCH=arm64;; *) echo 'Unsupported processor.' >&2; exit 1;; esac
|
|
15
|
+
NODE="$DATA_ROOT/runtime/node-v24.19.0-$PLATFORM-$ARCH/bin/node"
|
|
16
|
+
[ -x "$NODE" ] && [ -f "$DATA_ROOT/control.mjs" ] || { echo 'Install Desktop Bridge first.' >&2; exit 1; }
|
|
17
|
+
exec "$NODE" "$DATA_ROOT/control.mjs" "$ACTION" "$@"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
export const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
6
|
+
export function dataRoot(platform=process.platform,env=process.env,home=os.homedir()) {
|
|
7
|
+
return platform==='win32'?path.join(env.LOCALAPPDATA??path.join(home,'AppData','Local'),'DesktopBridgeClient'):platform==='darwin'?path.join(home,'Library','Application Support','DesktopBridge'):path.join(env.XDG_DATA_HOME??path.join(home,'.local','share'),'desktop-bridge');
|
|
8
|
+
}
|
|
9
|
+
export function run(command,args,options={}) {
|
|
10
|
+
return new Promise((resolve,reject)=>{const child=spawn(command,args,{stdio:'inherit',...options});child.once('error',reject);child.once('exit',(code,signal)=>code===0?resolve():reject(new Error(`${path.basename(command)} failed (${signal??code})`)));});
|
|
11
|
+
}
|
|
12
|
+
export function livePid(file) {
|
|
13
|
+
try {const pid=Number(fs.readFileSync(file,'utf8'));if(!Number.isInteger(pid)||pid<1)return false;try{process.kill(pid,0);return true;}catch(error){return error.code!=='ESRCH';}}catch{return false;}
|
|
14
|
+
}
|
|
15
|
+
export function acquireLock(directory) {
|
|
16
|
+
try{fs.mkdirSync(directory,{mode:0o700});}catch(error){
|
|
17
|
+
if(error.code!=='EEXIST')throw error;
|
|
18
|
+
// A just-created lock may not have a PID yet. Never steal it during this window.
|
|
19
|
+
if(livePid(path.join(directory,'pid'))||Date.now()-fs.statSync(directory).mtimeMs<30000)return false;
|
|
20
|
+
try{fs.rmSync(directory,{recursive:true});fs.mkdirSync(directory,{mode:0o700});}catch(error){if(error.code==='EEXIST')return false;throw error;}
|
|
21
|
+
}
|
|
22
|
+
fs.writeFileSync(path.join(directory,'pid'),String(process.pid),{mode:0o600});return true;
|
|
23
|
+
}
|
|
24
|
+
export function releaseLock(directory) {try{if(fs.readFileSync(path.join(directory,'pid'),'utf8')===String(process.pid))fs.rmSync(directory,{recursive:true});}catch{}}
|
|
25
|
+
export async function stopClient(root,{timeout=20000}={}) {
|
|
26
|
+
fs.writeFileSync(path.join(root,'maintenance.flag'),'stopped',{mode:0o600});
|
|
27
|
+
const deadline=Date.now()+timeout;
|
|
28
|
+
while(livePid(path.join(root,'runner-lock','pid'))||livePid(path.join(root,'agent.lock'))){
|
|
29
|
+
if(Date.now()>deadline)throw new Error('Client is still stopping. Retry after it exits.');
|
|
30
|
+
await sleep(200);
|
|
31
|
+
}
|
|
32
|
+
fs.rmSync(path.join(root,'runner-lock'),{recursive:true,force:true});
|
|
33
|
+
fs.rmSync(path.join(root,'agent.lock'),{force:true});
|
|
34
|
+
}
|
|
35
|
+
export function startupFile(root,platform=process.platform,env=process.env,home=os.homedir()) {
|
|
36
|
+
return platform==='win32'?path.join(env.APPDATA??path.join(home,'AppData','Roaming'),'Microsoft','Windows','Start Menu','Programs','Startup','Desktop Bridge.lnk'):platform==='darwin'?path.join(home,'Library','LaunchAgents','de.desktopbridge.agent.plist'):path.join(env.XDG_CONFIG_HOME??path.join(home,'.config'),'autostart','desktop-bridge.desktop');
|
|
37
|
+
}
|
|
38
|
+
export async function removeStartup(root) {
|
|
39
|
+
if(process.platform==='win32'){
|
|
40
|
+
const {windowsBackground}=await import('./windows-background.mjs');
|
|
41
|
+
await windowsBackground('remove',root);
|
|
42
|
+
return; // The Windows helper removes only a shortcut owned by this root.
|
|
43
|
+
}
|
|
44
|
+
const file=startupFile(root);
|
|
45
|
+
if(process.platform==='darwin'&&fs.existsSync(file))try{await run('launchctl',['bootout','gui/'+process.getuid(),file],{stdio:'ignore'});}catch{}
|
|
46
|
+
fs.rmSync(file,{force:true});
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "24.19.0",
|
|
3
|
+
"source": "https://nodejs.org/dist/v24.19.0/SHASUMS256.txt",
|
|
4
|
+
"platforms": {
|
|
5
|
+
"win-x64": {
|
|
6
|
+
"filename": "node-v24.19.0-win-x64.zip",
|
|
7
|
+
"sha256": "57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73"
|
|
8
|
+
},
|
|
9
|
+
"win-arm64": {
|
|
10
|
+
"filename": "node-v24.19.0-win-arm64.zip",
|
|
11
|
+
"sha256": "8502f4a50b458d4cc38ed8f2001556c2cd239d464920f74017926ccb1e1c157f"
|
|
12
|
+
},
|
|
13
|
+
"darwin-x64": {
|
|
14
|
+
"filename": "node-v24.19.0-darwin-x64.tar.gz",
|
|
15
|
+
"sha256": "d1b5e999db158c62fe8f7267a4476b035d8bd93b1a605bac24a3f0dd166e3316"
|
|
16
|
+
},
|
|
17
|
+
"darwin-arm64": {
|
|
18
|
+
"filename": "node-v24.19.0-darwin-arm64.tar.gz",
|
|
19
|
+
"sha256": "8294b7aa9b03997481c06babf1e8b270c859358f27da57a11509afe537ac381d"
|
|
20
|
+
},
|
|
21
|
+
"linux-x64": {
|
|
22
|
+
"filename": "node-v24.19.0-linux-x64.tar.gz",
|
|
23
|
+
"sha256": "f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4"
|
|
24
|
+
},
|
|
25
|
+
"linux-arm64": {
|
|
26
|
+
"filename": "node-v24.19.0-linux-arm64.tar.gz",
|
|
27
|
+
"sha256": "d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|