@justlovepl/desktop-bridge 0.2.6 → 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/README.md CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  Independent npm distribution for the Desktop Bridge **local device client**. It does not publish the upstream Desktop Commander package or cloud relay.
4
4
 
5
- **Status: development branch, not yet published or production validated.**
5
+ Version 0.2.7 adds Windows Task Scheduler background management. npm remains the installation, upgrade and control entry point. **0.2.7 is currently a release candidate; public npm publication is pending the publisher account's one-time-password verification.**
6
6
 
7
7
  Requires Node.js 24 and npm. Native installers for computers without Node remain available at <https://mcp.ilovestudy.de/start.html>.
8
8
 
9
9
  ```sh
10
10
  npm install -g @justlovepl/desktop-bridge
11
- desktop-bridge setup # must type 1 or 2; blank Enter prompts again
11
+ desktop-bridge setup # default: background, login startup enabled
12
+ desktop-bridge setup --mode manual # explicitly choose foreground/manual operation
12
13
  desktop-bridge status
13
14
  desktop-bridge start
14
15
  desktop-bridge stop
@@ -20,18 +21,21 @@ desktop-bridge repair # repairs AND re-pairs in the browser
20
21
  desktop-bridge uninstall # preserves credentials/results unless --purge
21
22
  ```
22
23
 
23
- **The CLI installation itself never prompts or starts a service.** Each interactive `desktop-bridge setup` or reinstall must explicitly select `1` (manual) or `2` (background); pressing Enter without a choice repeats the prompt. For non-interactive setup, a startup mode is mandatory. No existing credential, Agent, or login startup configuration may change before the choice is validated.\n\nUse `desktop-bridge setup --mode manual` or `--mode background` in automated shells. `--administrator` requires explicit OS authorization and respects the existing protected service restrictions. `--root <directory>` selects a dedicated custom data directory. **There is no install/start action in npm postinstall**.
24
+ **Installing the npm CLI does not start a service.** Run `desktop-bridge setup` to install/pair the client and select background operation by default. Use `--mode manual` for foreground operation; `--no-start` also selects manual mode. The native installers still ask the user to select a mode explicitly.
25
+
26
+ On Windows, background mode registers a Task Scheduler task for the current user with limited privileges, starts it independently of the terminal/SSH session, and starts it again on user login. The task has no runtime time limit, allows battery operation, and restarts on failures. This mode requires that user to be logged into Windows; running before login is the separate administrator service option. `stop` leaves the task registered but stops the client; `start` resumes it; `mode manual` and `uninstall` remove it. An update migrates the old login shortcut to the managed task while preserving pairing and the saved mode.
27
+
28
+ `--administrator` requires explicit OS authorization and respects the existing protected service restrictions. `--root <directory>` selects a dedicated custom data directory. **There is no install/start action in npm postinstall**.
24
29
 
25
30
  `desktop-bridge uninstall --purge` permanently deletes local credentials and result journals. Uninstalling locally does not revoke the server token; use device management at <https://mcp.ilovestudy.de/start.html> to revoke device authorization. Existing administrator service installations require the protected service's own approved uninstall flow.
26
31
 
27
32
  Update the npm CLI and then apply the local client release:
28
33
 
29
34
  ```sh
30
- npm update -g @justlovepl/desktop-bridge
31
- desktop-bridge setup
35
+ desktop-bridge update
32
36
  ```
33
37
 
34
- `npm update` alone does not replace an already running agent. `desktop-bridge update` upgrades the CLI and Agent while explicitly passing the existing saved mode, with credential, mode, and task journal protection; routine update is not an interactive re-install. A new pairing still requires an explicit browser login and approval if the existing device authorization was revoked.
38
+ `npm update` alone does not replace an already running agent. `desktop-bridge update` upgrades the CLI and Agent while explicitly passing the existing saved mode, with credential, mode, and task journal protection. It refuses to install a CLI older than the current version. `repair` also preserves the saved mode, but deliberately requires browser re-pairing approval. A revoked device always requires explicit new pairing.
35
39
 
36
40
  ## Packaging / tests
37
41
 
@@ -51,13 +51,14 @@ export function parse(argv){
51
51
  }else if(!['--administrator','--no-browser','--no-start'].includes(a))throw new Error('Unknown setup option: '+a);
52
52
  }
53
53
  }
54
+ if(command==='setup'&&!remainder.includes('--mode')&&!remainder.includes('--no-start'))remainder.push('--mode','background');
54
55
  return {command,args:remainder,root:customRoot};
55
56
  }
56
57
 
57
58
  function usage(){
58
59
  return 'Desktop Bridge '+manifest.version+'\n\n'+
59
60
  'Usage: desktop-bridge <command> [options]\n\n'+
60
- ' setup [--mode manual|background] [--administrator] Install and pair\n'+
61
+ ' setup [--mode manual|background] [--administrator] Install and pair (default: background)\n'+
61
62
  ' start | stop | restart Manage local agent\n'+
62
63
  ' status Show local connection status\n'+
63
64
  ' pair Re-pair in your browser\n'+
@@ -93,7 +94,10 @@ export async function main(argv=process.argv.slice(2)){
93
94
  if(parsed.command==='update')return performUpdate({root,packageRoot,packageName:manifest.name});
94
95
  if(parsed.command==='repair'){
95
96
  console.log('Repair also requires browser re-pairing approval.');
96
- return childRun(installer,['--repair',...rootArgs]);
97
+ const {savedMode}=await import('../dist/client/install-options.mjs');
98
+ const mode=savedMode(root);
99
+ if(!mode)throw new Error('Cannot identify saved startup mode. Run setup --mode manual|background.');
100
+ return childRun(installer,['--repair','--mode',mode,...rootArgs]);
97
101
  }
98
102
  if(parsed.command==='uninstall'){
99
103
  const status=await childRun(installer,['--uninstall',...parsed.args,...rootArgs]);
package/bin/update.mjs CHANGED
@@ -56,9 +56,13 @@ export async function performUpdate({
56
56
  const previousPrivilege=settings.privilege??'standard';
57
57
  const journalFile=path.join(root,'journal.json');
58
58
  const oldJournal=fs.existsSync(journalFile)?readJson(journalFile):null;
59
+ const installedPackage=readJson(path.join(packageRoot,'package.json'));
60
+ if(!/^\d+\.\d+\.\d+$/.test(installedPackage.version))throw new Error('Unsupported CLI version; refusing an unsafe update.');
59
61
  const npmCli=npmScript??findNpmCli();
60
62
  print('Updating npm CLI (device credentials and configuration are kept outside npm)...');
61
- const npmStatus=await execute(process.execPath,[npmCli,'install','--global','--no-audit','--no-fund',packageName+'@latest']);
63
+ // A locally tested newer build must not silently downgrade to an older
64
+ // published client. npm resolves the newest matching stable release.
65
+ const npmStatus=await execute(process.execPath,[npmCli,'install','--global','--no-audit','--no-fund',packageName+'@>='+installedPackage.version]);
62
66
  if(npmStatus!==0)throw new Error('npm package update failed; local client was not reinstalled.');
63
67
  const packageFile=path.join(packageRoot,'package.json');
64
68
  const current=readJson(packageFile);
@@ -61,7 +61,7 @@ function flag(state) {
61
61
  fs.writeFileSync(path.join(root,'maintenance.flag'),state,{mode:0o600});
62
62
  }
63
63
  function settings(mode) {
64
- writePrivateJson(path.join(root,'client-settings.json'),{mode,privilege:'administrator',version:'0.2.6'});
64
+ writePrivateJson(path.join(root,'client-settings.json'),{mode,privilege:'administrator',version:'0.2.7'});
65
65
  }
66
66
  function relocate(source,mode) {
67
67
  const {base}=validateSource(source);
@@ -80,7 +80,7 @@ function relocate(source,mode) {
80
80
  const stage=path.join(parent,'.desktop-bridge-admin-stage-'+process.pid);
81
81
  const backup=path.join(parent,'.desktop-bridge-admin-backup-'+process.pid);
82
82
  fs.mkdirSync(stage,{mode:0o700});
83
- const selected=['.desktop-bridge-client','agent-config.json','launch.json','runner.mjs','lifecycle.mjs','control.mjs','startup.mjs','install-options.mjs','admin-runtime.mjs','admin-service.mjs','admin-elevate.mjs','runtime','releases'];
83
+ const selected=['.desktop-bridge-client','agent-config.json','launch.json','runner.mjs','lifecycle.mjs','control.mjs','startup.mjs','windows-background.mjs','install-options.mjs','admin-runtime.mjs','admin-service.mjs','admin-elevate.mjs','runtime','releases'];
84
84
  try{
85
85
  for(const entry of selected){const file=path.join(base,entry);if(fs.existsSync(file))copyTreeSafe(file,path.join(stage,entry));}
86
86
  const oldConfig=fs.existsSync(path.join(root,'agent-config.json'))?JSON.parse(fs.readFileSync(path.join(root,'agent-config.json'),'utf8')):null;
@@ -93,7 +93,7 @@ function relocate(source,mode) {
93
93
  const previous=path.join(root,name);if(fs.existsSync(previous))copyTreeSafe(previous,path.join(stage,name));
94
94
  else if(fs.existsSync(path.join(base,name)))copyTreeSafe(path.join(base,name),path.join(stage,name));
95
95
  }
96
- writePrivateJson(path.join(stage,'client-settings.json'),{mode,privilege:'administrator',version:'0.2.6'});
96
+ writePrivateJson(path.join(stage,'client-settings.json'),{mode,privilege:'administrator',version:'0.2.7'});
97
97
  fs.writeFileSync(path.join(stage,'maintenance.flag'),'installing',{mode:0o600});
98
98
  secureTree(stage);
99
99
  if(fs.existsSync(root))fs.renameSync(root,backup);
@@ -146,7 +146,7 @@ if(action==='install'){
146
146
  if(value&&value!=='--purge')throw new Error('Only --purge is accepted for uninstall');
147
147
  if(purge)fs.rmSync(root,{recursive:true,force:true});
148
148
  else{
149
- for(const entry of ['runtime','releases','runner.mjs','lifecycle.mjs','control.mjs','startup.mjs','install-options.mjs','admin-runtime.mjs','admin-service.mjs','admin-elevate.mjs','launch.json'])fs.rmSync(path.join(root,entry),{recursive:true,force:true});
149
+ for(const entry of ['runtime','releases','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'])fs.rmSync(path.join(root,entry),{recursive:true,force:true});
150
150
  }
151
151
  console.log('Administrator service uninstalled. Device revocation is separate.');
152
152
  }
@@ -106,7 +106,7 @@ const requireRuntime=createRequire(path.join(config.runtimeDir,'package.json'));
106
106
  const {Client}=await import(pathToFileURL(requireRuntime.resolve('@modelcontextprotocol/sdk/client/index.js')));
107
107
  const {StdioClientTransport}=await import(pathToFileURL(requireRuntime.resolve('@modelcontextprotocol/sdk/client/stdio.js')));
108
108
  const WebSocket=requireRuntime('ws');
109
- const client=new Client({name:'personal-desktop-bridge-agent',version:'0.2.6'},{capabilities:{}});
109
+ const client=new Client({name:'personal-desktop-bridge-agent',version:'0.2.7'},{capabilities:{}});
110
110
  const transport=new StdioClientTransport({command:process.execPath,args:[config.mcpEntry],cwd:config.workingDirectory??directory,env:{...process.env},stderr:'pipe'});
111
111
  let ws,retryTimer,stopping=false,attempt=0,localHealthy=false,checkingHealth=false,healthFailures=0,remoteStopping=false;
112
112
  const heartbeatPath=path.join(directory,'heartbeat.json');
@@ -221,7 +221,7 @@ function connect() {
221
221
  let lastSeen=Date.now(),welcomed=false;
222
222
  const heartbeat=setInterval(()=>{if(Date.now()-lastSeen>65000)socket.terminate();},15000);
223
223
  socket.on('ping',()=>lastSeen=Date.now());
224
- socket.on('open',()=>{send(socket,{type:'hello',protocol:2,deviceId:config.deviceId,tools,capabilities:{shutdown:true},version:'0.2.6',platform:process.platform});});
224
+ socket.on('open',()=>{send(socket,{type:'hello',protocol:2,deviceId:config.deviceId,tools,capabilities:{shutdown:true},version:'0.2.7',platform:process.platform});});
225
225
  socket.on('message',data=>{
226
226
  lastSeen=Date.now();
227
227
  try {
@@ -8,7 +8,7 @@ export function saveConfig(filename,config) {
8
8
  export async function pairDevice(config,filename,{openBrowser=true,intervalMs}={}) {
9
9
  const server=new URL(config.serverUrl);
10
10
  if(server.protocol!=='https:'&&!['127.0.0.1','localhost'].includes(server.hostname))throw new Error('配对需要 HTTPS');
11
- const response=await fetch(new URL('/api/pair/start',server),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:config.deviceName??os.hostname(),platform:process.platform,version:'0.2.6'}),signal:AbortSignal.timeout(15000)});
11
+ const response=await fetch(new URL('/api/pair/start',server),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:config.deviceName??os.hostname(),platform:process.platform,version:'0.2.7'}),signal:AbortSignal.timeout(15000)});
12
12
  if(!response.ok)throw new Error('无法开始配对,请检查网络');
13
13
  const p=await response.json();
14
14
  console.log('请在网页登录并确认这台电脑。配对码:'+p.userCode+'\n'+p.verificationUri);
@@ -26,7 +26,7 @@ if(settings.privilege==='administrator'&&process.env.BRIDGE_ADMIN_CONTEXT!=='1')
26
26
  const request=elevationRequest(action,{mode:value});
27
27
  await run(request.command,request.args);
28
28
  if(action==='mode'){
29
- fs.writeFileSync(path.join(root,'client-settings.json'),JSON.stringify({mode:value,privilege:'administrator',version:'0.2.6'},null,2),{mode:0o600});
29
+ fs.writeFileSync(path.join(root,'client-settings.json'),JSON.stringify({mode:value,privilege:'administrator',version:'0.2.7'},null,2),{mode:0o600});
30
30
  writeInstructions(root,launch.node,value,'administrator');
31
31
  }
32
32
  process.exit(0);
@@ -87,7 +87,7 @@ export async function verifyLocalTools({node,release,workingDirectory}){
87
87
  export async function verifyLocalToolsInProcess({node,release,workingDirectory}) {
88
88
  console.log('正在验证本地工具可以实际执行……');
89
89
  const {Client,StdioClientTransport,entry}=await probeModules(release);
90
- const client=new Client({name:'desktop-bridge-install-check',version:'0.2.6'},{capabilities:{}});
90
+ const client=new Client({name:'desktop-bridge-install-check',version:'0.2.7'},{capabilities:{}});
91
91
  const transport=new StdioClientTransport({command:node,args:[entry],cwd:workingDirectory,stderr:'pipe'});
92
92
  try{
93
93
  const connecting=client.connect(transport,{timeout:30000});transport.stderr?.on('data',()=>{});await connecting;
@@ -38,19 +38,19 @@ if(options.stop){console.log('Client stopped. Use your installed Start shortcut
38
38
  if(options.uninstall){
39
39
  await removeStartup(root);
40
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','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});
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
42
  if(options.purge){releaseLock(installLock);fs.rmSync(root,{recursive:true,force:true});}
43
43
  console.log('Client uninstalled. Revoke the device in device management to unlink it.');process.exit(0);
44
44
  }
45
45
  fs.writeFileSync(path.join(root,'.desktop-bridge-client'),'desktop-bridge-client\n',{mode:0o600});
46
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
47
  const {node,npm}=await ensureRuntime(root,source);
48
- const release=path.join(root,'releases','0.2.6'),configPath=path.join(root,'agent-config.json');
48
+ const release=path.join(root,'releases','0.2.7'),configPath=path.join(root,'agent-config.json');
49
49
  fs.mkdirSync(release,{recursive:true,mode:0o700});
50
50
  for(const file of ['package.json','package-lock.json'])fs.copyFileSync(path.join(source,file),path.join(release,file));
51
51
  const agentSource=fs.existsSync(path.join(source,'agent'))?path.join(source,'agent'):path.join(source,'..','agent');
52
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','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));
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
54
  await prepareDependencies({node,npm,release,force:!!options.repair});
55
55
  try{await verifyLocalTools({node,release,workingDirectory:root});}
56
56
  catch{
@@ -74,7 +74,7 @@ if(adminRequested){
74
74
  const helper=path.join(root,'admin-elevate.mjs');
75
75
  const request=elevationRequest('install',{source:root,mode});
76
76
  await run(request.command,request.args);
77
- fs.writeFileSync(path.join(root,'client-settings.json'),JSON.stringify({mode,privilege:'administrator',version:'0.2.6'},null,2),{mode:0o600});
77
+ fs.writeFileSync(path.join(root,'client-settings.json'),JSON.stringify({mode,privilege:'administrator',version:'0.2.7'},null,2),{mode:0o600});
78
78
  writeInstructions(root,node,mode,'administrator');
79
79
  }
80
80
  console.log(adminRequested?'管理员运行已授权并配置:'+mode+'。':mode==='manual'?'安装和配对完成:手动模式。使用时运行启动命令,保持窗口,按 Ctrl+C 结束;没有后台实例或登录自启。':'安装和配对完成:后台模式。客户端现在后台运行,登录系统后自动启动。');
@@ -36,6 +36,11 @@ export function startupFile(root,platform=process.platform,env=process.env,home=
36
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
37
  }
38
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
+ }
39
44
  const file=startupFile(root);
40
45
  if(process.platform==='darwin'&&fs.existsSync(file))try{await run('launchctl',['bootout','gui/'+process.getuid(),file],{stdio:'ignore'});}catch{}
41
46
  fs.rmSync(file,{force:true});
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "desktop-bridge-client-runtime",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "desktop-bridge-client-runtime",
9
- "version": "0.2.6",
9
+ "version": "0.2.7",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "1.32.1",
12
12
  "@wonderwhy-er/desktop-commander": "0.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "desktop-bridge-client-runtime",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import {spawn} from 'node:child_process';
4
- import {run,startupFile,removeStartup,stopClient,livePid} from './lifecycle.mjs';
4
+ import {run,startupFile,removeStartup,stopClient,livePid,sleep} from './lifecycle.mjs';
5
5
  export const shellQuote=value=>"'"+String(value).replace(/'/g,"'\\''")+"'";
6
6
  export const powershellQuote=value=>"'"+String(value).replace(/'/g,"''")+"'";
7
7
  const xml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]));
@@ -18,11 +18,8 @@ export function startupContent(node,root,platform=process.platform) {
18
18
  export async function registerStartup(node,root) {
19
19
  const file=startupFile(root);fs.mkdirSync(path.dirname(file),{recursive:true});
20
20
  if(process.platform==='win32'){
21
- const ps=powershellQuote;
22
- // Target Node directly. Routing through cmd.exe would expand % and ! in paths.
23
- const quote=value=>'"'+value.replace(/(\\*)"/g,'$1$1\\"').replace(/(\\+)$/,'$1$1')+'"';
24
- const argv=[path.join(root,'runner.mjs'),root].map(quote).join(' ');
25
- await run('powershell.exe',['-NoProfile','-NonInteractive','-Command',`$s=(New-Object -ComObject WScript.Shell).CreateShortcut(${ps(file)});$s.TargetPath=${ps(node)};$s.Arguments=${ps(argv)};$s.WorkingDirectory=${ps(root)};$s.WindowStyle=7;$s.Save()`]);
21
+ const {windowsBackground}=await import('./windows-background.mjs');
22
+ await windowsBackground('register',root,node);
26
23
  }else{
27
24
  fs.writeFileSync(file,startupContent(node,root));
28
25
  if(process.platform==='darwin'){
@@ -32,7 +29,7 @@ export async function registerStartup(node,root) {
32
29
  }
33
30
  }
34
31
  export function writeInstructions(root,node,mode,privilege='standard') {
35
- const contents=`Desktop Bridge 0.2.6 使用说明\n\n权限:${privilege==='administrator'?'管理员(操作将由系统提示授权;后台使用受保护系统服务)':'当前用户'}\n当前模式:${mode==='manual'?'手动 manual':'后台 background'}\n${mode==='manual'?'使用时运行启动命令,保持窗口,按 Ctrl+C 或关闭窗口结束。客户端与本地工具进程会一起停止。未设置登录自启。':'客户端现在后台运行,登录系统后自动启动。这是当前用户登录后自启,不是登录前的系统服务。'}\n\n${process.platform==='win32'?'请将以下命令复制到 PowerShell。':'请将以下命令复制到终端。'}\n\n启动:\n${commandFor(node,root,'start')}\n\n停止:\n${commandFor(node,root,'stop')}\n\n状态:\n${commandFor(node,root,'status')}\n\n切换为手动模式(停止后台实例,移除登录自启):\n${commandFor(node,root,'mode manual')}\n\n切换为后台模式(现在启动,登录系统后自动启动):\n${commandFor(node,root,'mode background')}\n\n本目录中的快捷入口:${process.platform==='win32'?'Start.cmd / Stop.cmd':process.platform==='darwin'?'Start.command / Stop.command':'start.sh / stop.sh'}。\n启动、停止和切换模式使用已安装的私有环境,不会重新安装依赖。\n\n重新配对(旧 Token 失效时):\n${commandFor(node,root,'pair')}\n${process.platform==='win32'?'或双击 RePair.cmd。':'在终端执行上述命令。'}\n配对必须由用户在网页重新确认。\n如果出现 authStatus=re_pair_required,请先重新配对。\n\n请保留本目录,其中保存设备凭据、日志与可恢复的任务结果。停止不会删除这些数据。\n`;
32
+ const contents=`Desktop Bridge 0.2.7 使用说明\n\n权限:${privilege==='administrator'?'管理员(操作将由系统提示授权;后台使用受保护系统服务)':'当前用户'}\n当前模式:${mode==='manual'?'手动 manual':'后台 background'}\n${mode==='manual'?'使用时运行启动命令,保持窗口,按 Ctrl+C 或关闭窗口结束。客户端与本地工具进程会一起停止。未设置登录自启。':'客户端现在后台运行,登录系统后自动启动。这是当前用户登录后自启,不是登录前的系统服务。'}\n\n${process.platform==='win32'?'请将以下命令复制到 PowerShell。':'请将以下命令复制到终端。'}\n\n启动:\n${commandFor(node,root,'start')}\n\n停止:\n${commandFor(node,root,'stop')}\n\n状态:\n${commandFor(node,root,'status')}\n\n切换为手动模式(停止后台实例,移除登录自启):\n${commandFor(node,root,'mode manual')}\n\n切换为后台模式(现在启动,登录系统后自动启动):\n${commandFor(node,root,'mode background')}\n\n本目录中的快捷入口:${process.platform==='win32'?'Start.cmd / Stop.cmd':process.platform==='darwin'?'Start.command / Stop.command':'start.sh / stop.sh'}。\n启动、停止和切换模式使用已安装的私有环境,不会重新安装依赖。\n\n重新配对(旧 Token 失效时):\n${commandFor(node,root,'pair')}\n${process.platform==='win32'?'或双击 RePair.cmd。':'在终端执行上述命令。'}\n配对必须由用户在网页重新确认。\n如果出现 authStatus=re_pair_required,请先重新配对。\n\n请保留本目录,其中保存设备凭据、日志与可恢复的任务结果。停止不会删除这些数据。\n`;
36
33
  fs.writeFileSync(path.join(root,'START-HERE.txt'),contents+(privilege==='administrator'?'\n管理员模式下,普通用户不能修改后台程序文件。系统隐私、Google 登录与 ChatGPT 工具确认仍需分别授权。\n':''),{mode:0o600});
37
34
  }
38
35
  export async function startBackground(root,node) {
@@ -40,6 +37,15 @@ export async function startBackground(root,node) {
40
37
  if(auth.authStatus==='re_pair_required')throw new Error('设备需要重新配对。请先执行 RePair.cmd 或 control.mjs pair。');
41
38
  if(livePid(path.join(root,'runner-lock','pid'))){console.log('Client is already running.');return;}
42
39
  fs.rmSync(path.join(root,'maintenance.flag'),{force:true});
40
+ if(process.platform==='win32'){
41
+ const {windowsBackground}=await import('./windows-background.mjs');
42
+ await windowsBackground('start',root,node);
43
+ for(let i=0;i<60;i++){
44
+ if(livePid(path.join(root,'runner-lock','pid')))return;
45
+ await sleep(250);
46
+ }
47
+ throw new Error('后台任务尚未启动。当前用户需已登录 Windows;请检查任务计划程序后重试。');
48
+ }
43
49
  const child=spawn(node,[path.join(root,'runner.mjs'),root],{detached:true,stdio:'ignore'});
44
50
  await new Promise((resolve,reject)=>{child.once('spawn',resolve);child.once('error',reject);});child.unref();
45
51
  }
@@ -47,7 +53,7 @@ export async function setMode(root,node,mode) {
47
53
  if(!['manual','background'].includes(mode))throw new Error('Mode must be manual or background');
48
54
  await stopClient(root);
49
55
  await removeStartup(root);
50
- const settings={mode,version:'0.2.6'};
56
+ const settings={mode,version:'0.2.7'};
51
57
  fs.writeFileSync(path.join(root,'client-settings.json.tmp'),JSON.stringify(settings,null,2),{mode:0o600});
52
58
  fs.renameSync(path.join(root,'client-settings.json.tmp'),path.join(root,'client-settings.json'));
53
59
  writeInstructions(root,node,mode);
@@ -0,0 +1,45 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+ import {execFile} from 'node:child_process';
5
+ import {promisify} from 'node:util';
6
+
7
+ const execute=promisify(execFile);
8
+ const ps=value=>"'"+String(value).replace(/'/g,"''")+"'";
9
+ const quote=value=>'"'+String(value).replace(/(\\*)"/g,'$1$1\\"').replace(/(\\+)$/,'$1$1')+'"';
10
+ export function backgroundTaskName(root){
11
+ return 'DesktopBridge-User-'+createHash('sha256').update(path.win32.resolve(root).toLowerCase()).digest('hex').slice(0,20);
12
+ }
13
+ export function backgroundTaskScript(action,root,node){
14
+ if(!['register','start','remove'].includes(action))throw new Error('Invalid background task action');
15
+ const name=ps(backgroundTaskName(root));
16
+ const args=[path.win32.join(root,'runner.mjs'),root].map(quote).join(' ');
17
+ const migrateShortcut="$legacy=Join-Path $env:APPDATA 'Microsoft\\Windows\\Start Menu\\Programs\\Startup\\Desktop Bridge.lnk';"+
18
+ "if(Test-Path -LiteralPath $legacy){$link=(New-Object -ComObject WScript.Shell).CreateShortcut($legacy);"+
19
+ `if($link.Arguments -eq ${ps(args)} -and $link.WorkingDirectory -eq ${ps(root)}){Remove-Item -LiteralPath $legacy}};`;
20
+ const preamble="$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue';"+
21
+ "$identity=[System.Security.Principal.WindowsIdentity]::GetCurrent();$sid=$identity.User.Value;"+
22
+ `$task=Get-ScheduledTask -TaskName ${name} -ErrorAction SilentlyContinue;`+
23
+ // A deterministic name must never let one user replace another user's task.
24
+ "if($task){$owner=$task.Principal.UserId;if($owner -notmatch '^S-1-'){$owner=([System.Security.Principal.NTAccount]$owner).Translate([System.Security.Principal.SecurityIdentifier]).Value};"+
25
+ "if($owner -ne $sid){throw 'Background task belongs to another Windows account'}};";
26
+ if(action==='remove')return preamble+`if($task){Stop-ScheduledTask -TaskName ${name};Unregister-ScheduledTask -TaskName ${name} -Confirm:$false};`+migrateShortcut+'exit 0';
27
+ const register=`$a=New-ScheduledTaskAction -Execute ${ps(node)} -Argument ${ps(args)} -WorkingDirectory ${ps(root)};`+
28
+ "$p=New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited;"+
29
+ "$t=New-ScheduledTaskTrigger -AtLogOn -User $sid;"+
30
+ "$s=New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries;"+
31
+ `Register-ScheduledTask -TaskName ${name} -Action $a -Trigger $t -Principal $p -Settings $s -Force|Out-Null;`;
32
+ // Refresh the action on installation; ordinary start reuses the registered task.
33
+ if(action==='register')return preamble+register+migrateShortcut;
34
+ return preamble+`if(-not $task){${register}}Start-ScheduledTask -TaskName ${name}`;
35
+ }
36
+ export async function windowsBackground(action,root,node){
37
+ const canonical=fs.realpathSync(root);
38
+ const script=backgroundTaskScript(action,canonical,node);
39
+ const encoded=Buffer.from(script,'utf16le').toString('base64');
40
+ try{
41
+ await execute('powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',encoded],{windowsHide:true,timeout:30000,maxBuffer:1024*1024});
42
+ }catch(error){
43
+ throw new Error('Windows background task '+action+' failed: '+String(error.stderr||error.message).slice(0,1500));
44
+ }
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justlovepl/desktop-bridge",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Cross-platform Desktop Bridge agent installer and lifecycle CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,7 +13,12 @@
13
13
  "bin": {
14
14
  "desktop-bridge": "./bin/desktop-bridge.mjs"
15
15
  },
16
- "files": ["bin/", "dist/", "README.md", "THIRD_PARTY_NOTICES.md"],
16
+ "files": [
17
+ "bin/",
18
+ "dist/",
19
+ "README.md",
20
+ "THIRD_PARTY_NOTICES.md"
21
+ ],
17
22
  "engines": {
18
23
  "node": ">=24 <25"
19
24
  },
@@ -24,5 +29,11 @@
24
29
  "publishConfig": {
25
30
  "access": "public"
26
31
  },
27
- "keywords": ["desktop-bridge", "mcp", "remote-desktop", "agent", "command-line"]
32
+ "keywords": [
33
+ "desktop-bridge",
34
+ "mcp",
35
+ "remote-desktop",
36
+ "agent",
37
+ "command-line"
38
+ ]
28
39
  }