talon-agent 1.38.1 → 1.39.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/package.json
CHANGED
|
@@ -46,4 +46,12 @@ export const meshHandlers: SharedActionHandlers = {
|
|
|
46
46
|
body.local_path,
|
|
47
47
|
body.remote_path,
|
|
48
48
|
),
|
|
49
|
+
// Remote self-update: push a new APK and silently (re)install it, keeping
|
|
50
|
+
// the mesh connection across the app restart.
|
|
51
|
+
update_device: (body) =>
|
|
52
|
+
getMeshService().updateDeviceApp(
|
|
53
|
+
body.device,
|
|
54
|
+
body.apk_path,
|
|
55
|
+
body.remote_path,
|
|
56
|
+
),
|
|
49
57
|
};
|
package/src/core/mesh/service.ts
CHANGED
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { randomUUID } from "node:crypto";
|
|
30
|
-
import {
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
import { createReadStream } from "node:fs";
|
|
32
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
31
33
|
import { tmpdir } from "node:os";
|
|
32
34
|
import { basename, dirname, join, resolve } from "node:path";
|
|
33
35
|
import type { Readable } from "node:stream";
|
|
@@ -758,6 +760,87 @@ export class MeshService {
|
|
|
758
760
|
};
|
|
759
761
|
}
|
|
760
762
|
|
|
763
|
+
/**
|
|
764
|
+
* `update_device`: remote self-update for the companion. Streams a new APK
|
|
765
|
+
* to the device, then tells it to silently install (via Shizuku) and
|
|
766
|
+
* restart. The mesh foreground service's autoRunOnMyPackageReplaced brings
|
|
767
|
+
* the connection back on its own — the link drops only for the seconds the
|
|
768
|
+
* process is swapped, no manual reopen.
|
|
769
|
+
*
|
|
770
|
+
* The APK is hashed here and the digest travels with the install command;
|
|
771
|
+
* the device re-hashes the pushed file and refuses to install on a
|
|
772
|
+
* mismatch, so a truncated transfer can never be installed.
|
|
773
|
+
*/
|
|
774
|
+
async updateDeviceApp(
|
|
775
|
+
query: unknown,
|
|
776
|
+
localApkPath: unknown,
|
|
777
|
+
remotePath?: unknown,
|
|
778
|
+
): Promise<MeshToolResult> {
|
|
779
|
+
const local =
|
|
780
|
+
typeof localApkPath === "string" && localApkPath.trim()
|
|
781
|
+
? resolve(dirs.workspace, localApkPath.trim())
|
|
782
|
+
: "";
|
|
783
|
+
if (!local) return { ok: false, text: "A local APK path is required." };
|
|
784
|
+
await this.load();
|
|
785
|
+
const resolved = this.resolveDevice(query);
|
|
786
|
+
if ("error" in resolved) return { ok: false, text: resolved.error };
|
|
787
|
+
const target = resolved.target;
|
|
788
|
+
if (target.capabilities && !target.capabilities.includes("install_apk")) {
|
|
789
|
+
return {
|
|
790
|
+
ok: false,
|
|
791
|
+
text: `${target.name} can't self-update — it needs a companion build with the install_apk capability and Shizuku enabled (device control on).`,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
let sha256: string;
|
|
796
|
+
let size: number;
|
|
797
|
+
try {
|
|
798
|
+
({ sha256, size } = await hashFile(local));
|
|
799
|
+
} catch (err) {
|
|
800
|
+
return {
|
|
801
|
+
ok: false,
|
|
802
|
+
text: `Cannot read APK ${local}: ${(err as Error).message}`,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
if (size === 0) {
|
|
806
|
+
return { ok: false, text: `APK ${local} is empty.` };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const remote =
|
|
810
|
+
typeof remotePath === "string" && remotePath.trim()
|
|
811
|
+
? remotePath.trim()
|
|
812
|
+
: "/sdcard/Download/talon-companion-update.apk";
|
|
813
|
+
|
|
814
|
+
// 1. Stream the APK to the device.
|
|
815
|
+
const push = await this.pushFileToDevice(target.id, local, remote);
|
|
816
|
+
if (!push.ok) {
|
|
817
|
+
return { ok: false, text: `Update aborted — push failed: ${push.text}` };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// 2. Trigger the silent install (device verifies the digest first).
|
|
821
|
+
const dispatched = await this.dispatchCommand(
|
|
822
|
+
target.id,
|
|
823
|
+
"install_apk",
|
|
824
|
+
{ path: remote, sha256 },
|
|
825
|
+
this.commandTimeoutMs,
|
|
826
|
+
);
|
|
827
|
+
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
828
|
+
if (!dispatched.result.ok) {
|
|
829
|
+
return {
|
|
830
|
+
ok: false,
|
|
831
|
+
text:
|
|
832
|
+
dispatched.result.message ?? `${target.name} refused the install.`,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
ok: true,
|
|
837
|
+
text:
|
|
838
|
+
`Pushed ${formatBytes(size)} and staged the update on ${target.name}. ` +
|
|
839
|
+
`${dispatched.result.message ?? "Installing now."} ` +
|
|
840
|
+
`Confirm with get_device_status once it reconnects (appVersion should change).`,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
761
844
|
/**
|
|
762
845
|
* Chunked read of a remote file into a Buffer. Loops `read_file` with
|
|
763
846
|
* increasing offsets until the device reports EOF.
|
|
@@ -1246,6 +1329,22 @@ function formatBytes(bytes: number): string {
|
|
|
1246
1329
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1247
1330
|
}
|
|
1248
1331
|
|
|
1332
|
+
/** Stream a file through SHA-256 without loading it into memory (APKs are big
|
|
1333
|
+
* and Buffer has a hard ceiling). Returns the hex digest and byte size. */
|
|
1334
|
+
async function hashFile(
|
|
1335
|
+
path: string,
|
|
1336
|
+
): Promise<{ sha256: string; size: number }> {
|
|
1337
|
+
const { size } = await stat(path);
|
|
1338
|
+
const hash = createHash("sha256");
|
|
1339
|
+
await new Promise<void>((resolve, reject) => {
|
|
1340
|
+
createReadStream(path)
|
|
1341
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
1342
|
+
.on("end", () => resolve())
|
|
1343
|
+
.on("error", reject);
|
|
1344
|
+
});
|
|
1345
|
+
return { sha256: hash.digest("hex"), size };
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1249
1348
|
// ── Process-wide instance ─────────────────────────────────────────────────────
|
|
1250
1349
|
|
|
1251
1350
|
let instance: MeshService | null = null;
|
package/src/core/tools/mesh.ts
CHANGED
|
@@ -150,4 +150,25 @@ export const meshTools: ToolDefinition[] = [
|
|
|
150
150
|
execute: (params, bridge) => bridge("device_push_file", params),
|
|
151
151
|
tag: "mesh",
|
|
152
152
|
},
|
|
153
|
+
{
|
|
154
|
+
name: "update_device",
|
|
155
|
+
description:
|
|
156
|
+
"Remotely update a Talon companion (Android): stream a new APK from the daemon to the device and silently reinstall it via Shizuku, keeping app data. The companion's mesh runs in a foreground service that auto-restarts after the package is replaced, so the connection returns on its own within seconds — no manual reopen. The APK is hashed and the device verifies it before installing (a truncated push is refused; a differently-signed APK is refused by Android). Requires the device to have device control on and Shizuku granted. Confirm success with get_device_status afterwards (appVersion should change).",
|
|
157
|
+
schema: {
|
|
158
|
+
device: deviceParam,
|
|
159
|
+
apk_path: z
|
|
160
|
+
.string()
|
|
161
|
+
.describe(
|
|
162
|
+
"Path to the new APK, relative to the workspace (or absolute).",
|
|
163
|
+
),
|
|
164
|
+
remote_path: z
|
|
165
|
+
.string()
|
|
166
|
+
.optional()
|
|
167
|
+
.describe(
|
|
168
|
+
"Where to stage the APK on the device (default /sdcard/Download/talon-companion-update.apk).",
|
|
169
|
+
),
|
|
170
|
+
},
|
|
171
|
+
execute: (params, bridge) => bridge("update_device", params),
|
|
172
|
+
tag: "mesh",
|
|
173
|
+
},
|
|
153
174
|
];
|