@aepyornis/fastboot.ts 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +1 -0
- package/.prettierrc.json +3 -0
- package/README.md +56 -0
- package/eslint.config.mjs +10 -0
- package/package.json +27 -0
- package/src/client.ts +344 -0
- package/src/device.ts +258 -0
- package/src/flasher.ts +174 -0
- package/src/images.ts +218 -0
- package/src/index.ts +3 -0
- package/src/sparse.ts +408 -0
- package/tsconfig.json +15 -0
package/.prettierignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dist
|
package/.prettierrc.json
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# fastboot.ts
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
pnpm install
|
|
5
|
+
pnpm run build
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
src/device.ts handles interfacing with WebUSB and implements fastboot protocol
|
|
9
|
+
src/client.ts implements higher level API, similar to fastboot cli tool
|
|
10
|
+
src/flasher.ts flashes zip image from a list of instructions
|
|
11
|
+
src/sparse.ts sparse image utilities Copyright (c) 2021 Danny Lin <danny@kdrag0n.dev>
|
|
12
|
+
|
|
13
|
+
TODO
|
|
14
|
+
|
|
15
|
+
--apply-vbmeta
|
|
16
|
+
repack_ramdisk ?
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { FastbootClient, FashbootFlasher } from "@aepyornis/fastboot.ts"
|
|
20
|
+
|
|
21
|
+
const client = await FastbootClient.create()
|
|
22
|
+
|
|
23
|
+
// run commands
|
|
24
|
+
await client.unlock()
|
|
25
|
+
await client.getVar("product")
|
|
26
|
+
|
|
27
|
+
// flash CalyxOS
|
|
28
|
+
import OpfsBlobStore from "@aepyornis/opfs_blob_store"
|
|
29
|
+
const bs = await OpfsBlobStore.create()
|
|
30
|
+
const hash = "a4434edb21e5e12a00ab9949f48f06c48169adcaeb4dce644857e1528b275274"
|
|
31
|
+
const url = "https://release.calyxinstitute.org/lynx-factory-25605200.zip"
|
|
32
|
+
await bs.fetch(hash, url)
|
|
33
|
+
const file = await bs.get(hash)
|
|
34
|
+
|
|
35
|
+
const instructions = `
|
|
36
|
+
fastboot --set-active=other reboot-bootloader
|
|
37
|
+
sleep 5
|
|
38
|
+
fastboot flash --slot=other bootloader bootloader-lynx-lynx-15.2-12878710.img
|
|
39
|
+
fastboot --set-active=other reboot-bootloader
|
|
40
|
+
sleep 5
|
|
41
|
+
fastboot flash --slot=other radio radio-lynx-g5300q-241205-250127-B-12973597.img
|
|
42
|
+
fastboot --set-active=other reboot-bootloader
|
|
43
|
+
sleep 5
|
|
44
|
+
fastboot flash --slot=other radio radio-lynx-g5300q-241205-250127-B-12973597.img
|
|
45
|
+
fastboot --set-active=other reboot-bootloader
|
|
46
|
+
sleep 5
|
|
47
|
+
fastboot erase avb_custom_key
|
|
48
|
+
fastboot flash avb_custom_key avb_custom_key.img
|
|
49
|
+
fastboot --skip-reboot -w update image-lynx-bp1a.250305.019.zip
|
|
50
|
+
fastboot reboot-bootloader
|
|
51
|
+
`
|
|
52
|
+
|
|
53
|
+
const client = await FastbootClient.create()
|
|
54
|
+
const deviceFlasher = new FastbootFlasher()
|
|
55
|
+
await deviceFlasher.run(instructions)
|
|
56
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aepyornis/fastboot.ts",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "fastboot again",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
8
|
+
"build": "esbuild src/index.ts --bundle --sourcemap --format=esm --platform=browser --target=chrome121 --outfile=dist/fastboot.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [],
|
|
11
|
+
"author": "Ziggy, Calyx Institute",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"packageManager": "pnpm@10.8.0",
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@eslint/js": "^9.24.0",
|
|
16
|
+
"@types/w3c-web-usb": "^1.0.10",
|
|
17
|
+
"esbuild": "^0.25.2",
|
|
18
|
+
"eslint": "^9.24.0",
|
|
19
|
+
"globals": "^16.0.0",
|
|
20
|
+
"prettier": "^3.5.3",
|
|
21
|
+
"typescript": "^5.8.3",
|
|
22
|
+
"typescript-eslint": "^8.29.1"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@zip.js/zip.js": "^2.7.60"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { BlobWriter, Entry } from "@zip.js/zip.js"
|
|
2
|
+
import { IMAGES } from "./images"
|
|
3
|
+
import { parseFileHeader, splitBlob, fromRaw } from "./sparse"
|
|
4
|
+
import { FastbootDevice } from "./device"
|
|
5
|
+
|
|
6
|
+
export class FastbootError extends Error {}
|
|
7
|
+
|
|
8
|
+
const FastbootUSBDeviceFilter = {
|
|
9
|
+
classCode: 0xff,
|
|
10
|
+
subclassCode: 0x42,
|
|
11
|
+
protocolCode: 0x03,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Logger {
|
|
15
|
+
log(message: string): void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// higher level API to interact with fastboot device
|
|
19
|
+
// translates CLI commands to
|
|
20
|
+
export class FastbootClient {
|
|
21
|
+
fd: FastbootDevice
|
|
22
|
+
logger: Logger
|
|
23
|
+
|
|
24
|
+
constructor(usb_device: USBDevice, logger: Logger = window.console) {
|
|
25
|
+
this.fd = new FastbootDevice(usb_device, logger)
|
|
26
|
+
this.logger = logger
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getVar(variable: string) {
|
|
30
|
+
return this.fd.getVar(variable)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async lock() {
|
|
34
|
+
await this.flashing("lock")
|
|
35
|
+
await this.fd.waitForReconnect()
|
|
36
|
+
if (await this.unlocked()) {
|
|
37
|
+
throw new FastbootError("failed to lock device")
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async unlock() {
|
|
42
|
+
await this.flashing("unlock")
|
|
43
|
+
await this.fd.waitForReconnect()
|
|
44
|
+
if (await this.locked()) {
|
|
45
|
+
throw new FastbootError("failed to unlock device")
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async reboot() {
|
|
50
|
+
this.logger.log("rebooting")
|
|
51
|
+
await this.fd.exec("reboot")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async rebootBootloader() {
|
|
55
|
+
this.logger.log("rebooting into bootloader")
|
|
56
|
+
this.fd.exec("reboot-bootloader")
|
|
57
|
+
await this.fd.waitForReconnect()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async rebootFastboot() {
|
|
61
|
+
this.logger.log("rebooting into fastboot")
|
|
62
|
+
this.fd.exec("reboot-fastboot")
|
|
63
|
+
await this.fd.waitForReconnect()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async doFlash(
|
|
67
|
+
partition: string,
|
|
68
|
+
blob: Blob,
|
|
69
|
+
slot: "current" | "other" | "a" | "b" = "current",
|
|
70
|
+
applyVbmeta: boolean = false,
|
|
71
|
+
) {
|
|
72
|
+
// add _a or _b
|
|
73
|
+
// !(await this.isUserspace()) ?
|
|
74
|
+
if (
|
|
75
|
+
partition !== "avb_custom_key" &&
|
|
76
|
+
(await this.getVar(`has-slot:${partition}`)) === "yes"
|
|
77
|
+
) {
|
|
78
|
+
if (slot === "current") {
|
|
79
|
+
partition += "_" + (await this.currentSlot())
|
|
80
|
+
} else if (slot === "other") {
|
|
81
|
+
partition += "_" + (await this.otherSlot())
|
|
82
|
+
} else if (slot === "a" || slot === "b") {
|
|
83
|
+
partition += "_" + slot
|
|
84
|
+
} else {
|
|
85
|
+
throw new FastbootError(`Unknown Slot: ${slot}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { blobSize, totalBytes, isSparse } = await parseBlobHeader(blob)
|
|
90
|
+
|
|
91
|
+
// should_flash_in_userspace() ?
|
|
92
|
+
// Logical partitions need to be resized before flashing because
|
|
93
|
+
// they're sized perfectly to the payload.
|
|
94
|
+
if (
|
|
95
|
+
(await this.isUserspace()) &&
|
|
96
|
+
(await this.getVar(`is-logical:${partition}`)) === "yes"
|
|
97
|
+
) {
|
|
98
|
+
await this.resizePartition(partition, totalBytes)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const max = await this.maxDownloadSize()
|
|
102
|
+
|
|
103
|
+
if (blobSize > max && !isSparse) {
|
|
104
|
+
this.logger.log(`${partition} image is raw, converting to sparse`)
|
|
105
|
+
blob = await fromRaw(blob)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.logger.log(
|
|
109
|
+
`Flashing ${totalBytes} bytes to ${partition} w/ max ${max} bytes per split`,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
let splits = 0
|
|
113
|
+
let sentBytes = 0
|
|
114
|
+
for await (const split of splitBlob(blob, max)) {
|
|
115
|
+
await this.fd.transferData(split.data)
|
|
116
|
+
this.logger.log(`run command flash:${partition}`)
|
|
117
|
+
await this.fd.sendCommand(`flash:${partition}`)
|
|
118
|
+
sentBytes += split.bytes
|
|
119
|
+
splits += 1
|
|
120
|
+
this.logger.log(
|
|
121
|
+
`${partition} #${splits}) sent ${split.bytes} bytes. ${sentBytes}/${blobSize}`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
this.logger.log(
|
|
125
|
+
`Flashed ${partition} with ${splits} split(s). Bytes sent: ${sentBytes}`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// fb->ResizePartition ?
|
|
130
|
+
async resizePartition(name: string, totalBytes: number) {
|
|
131
|
+
// As per AOSP fastboot, we reset the partition to 0 bytes first
|
|
132
|
+
// to optimize extent allocation before setting the actual size.
|
|
133
|
+
await this.fd.sendCommand(`resize-logical-partition:${name}:0`)
|
|
134
|
+
await this.fd.sendCommand(`resize-logical-partition:${name}:${totalBytes}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async flashing(command: "unlock" | "lock") {
|
|
138
|
+
if (
|
|
139
|
+
(command === "unlock" && (await this.unlocked())) ||
|
|
140
|
+
(command === "lock" && (await this.locked()))
|
|
141
|
+
) {
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.logger.log(`ACTION NEEDED: flashing ${command}`)
|
|
146
|
+
await this.fd.exec(`flashing ${command}`)
|
|
147
|
+
await this.fd.waitForReconnect()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// run directions, typically the contents of fastboot-info.txt
|
|
151
|
+
// retriving the flashing data from files in entries
|
|
152
|
+
async fastbootInfo(
|
|
153
|
+
entries: Entry[],
|
|
154
|
+
directions: string,
|
|
155
|
+
wipe: boolean = false,
|
|
156
|
+
) {
|
|
157
|
+
const lines = directions
|
|
158
|
+
.split("\n")
|
|
159
|
+
.map((x) => x.trim())
|
|
160
|
+
.filter((l) => !(l == "" || l[0] == "#" || l.slice(0, 7) == "version"))
|
|
161
|
+
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
this.logger.log(`fastboot-info: ${line}`)
|
|
164
|
+
const parts = line.split(" ").map((x) => x.trim())
|
|
165
|
+
const command = parts.shift()
|
|
166
|
+
|
|
167
|
+
switch (command) {
|
|
168
|
+
case "flash": {
|
|
169
|
+
let slot = "current"
|
|
170
|
+
let applyVbmeta = false
|
|
171
|
+
let partition = null
|
|
172
|
+
let filename = null
|
|
173
|
+
|
|
174
|
+
for (const arg of parts) {
|
|
175
|
+
if (arg === "--slot-other") {
|
|
176
|
+
slot = "other"
|
|
177
|
+
} else if (arg === "--apply-vbmeta") {
|
|
178
|
+
applyVbmeta = true
|
|
179
|
+
} else if (partition == null) {
|
|
180
|
+
partition = arg
|
|
181
|
+
} else {
|
|
182
|
+
filename = arg
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (filename === null) {
|
|
187
|
+
filename = IMAGES.find(
|
|
188
|
+
(img) => img["nickname"] === partition,
|
|
189
|
+
)?.img_name
|
|
190
|
+
if (!filename) {
|
|
191
|
+
throw new Error(`Unknown partition: ${partition}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const entry = entries.find((e) => e.filename === filename)
|
|
196
|
+
if (!entry) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`partition ${partition} with filename ${filename} not found in zipfile.`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
this.logger.log(`Extracting ${filename}`)
|
|
202
|
+
const blob = await entry.getData(
|
|
203
|
+
new BlobWriter("application/octet-stream"),
|
|
204
|
+
)
|
|
205
|
+
this.logger.log(
|
|
206
|
+
`flashing partition ${partition} with ${filename} from nested zip`,
|
|
207
|
+
)
|
|
208
|
+
await this.doFlash(partition, blob, slot, applyVbmeta)
|
|
209
|
+
break
|
|
210
|
+
}
|
|
211
|
+
case "reboot":
|
|
212
|
+
if (parts[0] === "fastboot") {
|
|
213
|
+
await this.rebootFastboot()
|
|
214
|
+
} else {
|
|
215
|
+
await this.rebootBootloader()
|
|
216
|
+
}
|
|
217
|
+
break
|
|
218
|
+
case "update-super": {
|
|
219
|
+
await this.updateSuper(entries, wipe)
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
case "if-wipe":
|
|
223
|
+
if (wipe && parts[0] === "erase" && parts[1]) {
|
|
224
|
+
await this.erase(parts[1])
|
|
225
|
+
}
|
|
226
|
+
break
|
|
227
|
+
case "erase":
|
|
228
|
+
await this.erase(parts[0])
|
|
229
|
+
break
|
|
230
|
+
default:
|
|
231
|
+
throw new Error(`unknown command ${command}`)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async updateSuper(entries: Entry[], wipe: boolean) {
|
|
237
|
+
const superEmptyImage = entries.find(
|
|
238
|
+
(e) => e.filename === "super_empty.img",
|
|
239
|
+
)
|
|
240
|
+
if (!superEmptyImage) {
|
|
241
|
+
throw new FastbootError(`super_empty.img not found`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let superName = "super"
|
|
245
|
+
try {
|
|
246
|
+
superName = await this.getVar("super-partition-name")
|
|
247
|
+
} catch (err) {}
|
|
248
|
+
|
|
249
|
+
// fastboot-info does this
|
|
250
|
+
// await this.rebootFastboot()
|
|
251
|
+
|
|
252
|
+
const blob = await superEmptyImage.getData(
|
|
253
|
+
new BlobWriter("application/octet-stream"),
|
|
254
|
+
)
|
|
255
|
+
const buffer = await blob.arrayBuffer()
|
|
256
|
+
|
|
257
|
+
await this.fd.transferData(buffer)
|
|
258
|
+
await this.fd.sendCommand(`update-super:${superName}${wipe ? ":wipe" : ""}`)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async erase(partition: string) {
|
|
262
|
+
return this.fd.exec(`erase:${partition}`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async setActiveOtherSlot() {
|
|
266
|
+
const otherSlot = await this.otherSlot()
|
|
267
|
+
return await this.fd.exec(`set_active:${otherSlot}`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async maxDownloadSize() {
|
|
271
|
+
try {
|
|
272
|
+
const deviceMax = parseInt(await this.getVar("max-download-size"), 16)
|
|
273
|
+
const upperLimit = 1024 * 1024 * 1024 * 1 // 1 GiB
|
|
274
|
+
return Math.min(deviceMax, upperLimit)
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.debug(err)
|
|
277
|
+
}
|
|
278
|
+
// FAIL or empty variable means no max, set a reasonable limit to conserve memory
|
|
279
|
+
return 512 * 1024 * 1024 // 512 MiB
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async unlocked() {
|
|
283
|
+
return (await this.getVar("unlocked")) === "yes"
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async locked() {
|
|
287
|
+
return (await this.getVar("unlocked")) === "no"
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async currentSlot() {
|
|
291
|
+
return this.getVar("current-slot")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async otherSlot() {
|
|
295
|
+
const currentSlot = await this.getVar("current-slot")
|
|
296
|
+
if (currentSlot === "a") {
|
|
297
|
+
return "b"
|
|
298
|
+
} else if (currentSlot === "b") {
|
|
299
|
+
return "a"
|
|
300
|
+
} else {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Unable to determine other slot, current slot: ${currentSlot}`,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async isUserspace() {
|
|
308
|
+
return (await this.getVar("is-userspace")) === "yes"
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
static async create() {
|
|
312
|
+
return new FastbootClient(await this.requestUsbDevice(), window.console)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
static requestUsbDevice(): Promise<USBDevice> {
|
|
316
|
+
return window.navigator.usb.requestDevice({
|
|
317
|
+
filters: [FastbootUSBDeviceFilter],
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function parseBlobHeader(blob: Blob): {
|
|
323
|
+
blobSize: number
|
|
324
|
+
totalBytes: number
|
|
325
|
+
isSparse: boolean
|
|
326
|
+
} {
|
|
327
|
+
const FILE_HEADER_SIZE = 28
|
|
328
|
+
const blobSize = blob.size
|
|
329
|
+
let totalBytes = blobSize
|
|
330
|
+
let isSparse = false
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const fileHeader = await blob.slice(0, FILE_HEADER_SIZE).arrayBuffer()
|
|
334
|
+
const sparseHeader = parseFileHeader(fileHeader)
|
|
335
|
+
if (sparseHeader !== null) {
|
|
336
|
+
totalBytes = sparseHeader.blocks * sparseHeader.blockSize
|
|
337
|
+
isSparse = true
|
|
338
|
+
}
|
|
339
|
+
} catch (error) {
|
|
340
|
+
console.debug(error)
|
|
341
|
+
// ImageError = invalid, so keep blob.size
|
|
342
|
+
}
|
|
343
|
+
return { blobSize, totalBytes, isSparse }
|
|
344
|
+
}
|
package/src/device.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
type CommandPacket = {
|
|
2
|
+
command: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
type ResponsePacket = {
|
|
6
|
+
status: "OKAY" | "FAIL" | "DATA" | "INFO" | "TEXT"
|
|
7
|
+
message?: string
|
|
8
|
+
dataLength?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type FastbootSession = {
|
|
12
|
+
phase: 1 | 2 | 3 | 4 | 5
|
|
13
|
+
status: null | "OKAY" | "FAIL"
|
|
14
|
+
packets: (CommandPacket | ResponsePacket)[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Logger {
|
|
18
|
+
log(message: string): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class FastbootDeviceError extends Error {
|
|
22
|
+
status: string
|
|
23
|
+
|
|
24
|
+
constructor(status: string, message: string) {
|
|
25
|
+
super(`Bootloader replied with ${status}: ${message}`)
|
|
26
|
+
this.status = status
|
|
27
|
+
this.name = "FastbootDeviceError"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// implements the fastboot protocol over WebUSB
|
|
32
|
+
export class FastbootDevice {
|
|
33
|
+
device: USBDevice
|
|
34
|
+
serialNumber: string;
|
|
35
|
+
in: USBEndpoint
|
|
36
|
+
out: USBEndpoint
|
|
37
|
+
session: FastbootSession
|
|
38
|
+
sessions: FastbootSession[]
|
|
39
|
+
logger: Logger
|
|
40
|
+
|
|
41
|
+
constructor(device, logger = window.console) {
|
|
42
|
+
this.device = device
|
|
43
|
+
this.serialNumber = this.device.serialNumber
|
|
44
|
+
this.session = null
|
|
45
|
+
this.sessions = []
|
|
46
|
+
this.logger = logger
|
|
47
|
+
this.setup()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// validate device and assign endpoints attributes
|
|
51
|
+
setup() {
|
|
52
|
+
if (this.device.configurations.length > 1) {
|
|
53
|
+
console.warn(
|
|
54
|
+
`device has ${device.configurations.length} configurations. Using the first one.`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// this.in = this.device.configurations[0].interfaces[0].alternate.endpoints[0]
|
|
59
|
+
const endpoints =
|
|
60
|
+
this.device.configurations[0].interfaces[0].alternate.endpoints
|
|
61
|
+
|
|
62
|
+
if (endpoints.length !== 2) {
|
|
63
|
+
throw new Error("USB Interface must have only 2 endpoints")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const endpoint of endpoints) {
|
|
67
|
+
if (endpoint.direction === "in") {
|
|
68
|
+
this.in = endpoint
|
|
69
|
+
} else if (endpoint.direction === "out") {
|
|
70
|
+
this.out = endpoint
|
|
71
|
+
} else {
|
|
72
|
+
throw new Error(`Endpoint error: ${endpoint}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async connect() {
|
|
78
|
+
if (!this.device.opened) {
|
|
79
|
+
await this.device.open()
|
|
80
|
+
}
|
|
81
|
+
await this.device.selectConfiguration(1)
|
|
82
|
+
await this.device.claimInterface(0)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// some commands (like "flashing lock") will disconnect the device
|
|
86
|
+
// we have to re-assign this.device after it reconnects
|
|
87
|
+
async reconnect() {
|
|
88
|
+
const devices = await navigator.usb.getDevices()
|
|
89
|
+
for (const device of devices) {
|
|
90
|
+
if (device.serialNumber === this.serialNumber) {
|
|
91
|
+
this.device = device
|
|
92
|
+
this.setup()
|
|
93
|
+
await this.connect()
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new Error("Could not find device in navigator.usb.getDevices()")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
waitForReconnect(): Promise<boolean> {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
navigator.usb.addEventListener(
|
|
103
|
+
"connect",
|
|
104
|
+
async () => {
|
|
105
|
+
try {
|
|
106
|
+
await this.reconnect()
|
|
107
|
+
resolve(true)
|
|
108
|
+
} catch (e) {
|
|
109
|
+
reject(e)
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
{ once: true },
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async getPacket(): ResponsePacket {
|
|
118
|
+
this.logger.log(`receiving packet from endpoint ${this.in.endpointNumber}`)
|
|
119
|
+
const inPacket = await this.device.transferIn(this.in.endpointNumber, 256)
|
|
120
|
+
const inPacketText = new TextDecoder().decode(inPacket.data)
|
|
121
|
+
const status = inPacketText.substring(0, 4)
|
|
122
|
+
const message = inPacketText.slice(4).trim()
|
|
123
|
+
switch (status) {
|
|
124
|
+
case "INFO":
|
|
125
|
+
return { status, message: `(bootloader) ${message}` }
|
|
126
|
+
case "TEXT":
|
|
127
|
+
return { status, message }
|
|
128
|
+
case "FAIL":
|
|
129
|
+
return { status, message }
|
|
130
|
+
case "OKAY":
|
|
131
|
+
return { status, message }
|
|
132
|
+
case "DATA": {
|
|
133
|
+
const dataLength = parseInt(inPacketText.slice(4, 12), 16)
|
|
134
|
+
return {
|
|
135
|
+
status,
|
|
136
|
+
dataLength,
|
|
137
|
+
message: `ready to transfer ${dataLength} bytes`,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
throw new Error(`invalid packet: ${inPacketText}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async getPackets() {
|
|
146
|
+
let response
|
|
147
|
+
do {
|
|
148
|
+
response = await this.getPacket()
|
|
149
|
+
this.session.packets.push(response)
|
|
150
|
+
this.logger.log(`[${response.status}] ${response.message}`)
|
|
151
|
+
} while (["INFO", "TEXT"].includes(response.status))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async sendCommand(text): ResponsePacket {
|
|
155
|
+
this.session.packets.push({ command: text } as CommandPacket)
|
|
156
|
+
const outPacket = new TextEncoder().encode(text)
|
|
157
|
+
this.logger.log(
|
|
158
|
+
`transfering "${text}" to endpoint ${this.out.endpointNumber}`,
|
|
159
|
+
)
|
|
160
|
+
const result: USBOutTransferResult = await this.device.transferOut(
|
|
161
|
+
this.out.endpointNumber,
|
|
162
|
+
outPacket,
|
|
163
|
+
)
|
|
164
|
+
await this.getPackets()
|
|
165
|
+
|
|
166
|
+
if (this.lastPacket.status === "FAIL") {
|
|
167
|
+
this.session.status = "FAIL"
|
|
168
|
+
throw new FastbootDeviceError(
|
|
169
|
+
this.lastPacket.status,
|
|
170
|
+
this.lastPacket.message,
|
|
171
|
+
)
|
|
172
|
+
} else {
|
|
173
|
+
return this.lastPacket
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async exec(command): ResponsePacket {
|
|
178
|
+
if (this.isActive) {
|
|
179
|
+
throw new Error("fastboot device is busy")
|
|
180
|
+
} else if (!this.device.opened) {
|
|
181
|
+
await this.connect()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (this.session) {
|
|
185
|
+
this.sessions.push(this.session)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.session = { status: null, packets: [] }
|
|
189
|
+
|
|
190
|
+
return this.sendCommand(command)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async getVar(variable: FastbootClientVariables): Promise<string> {
|
|
194
|
+
await this.exec(`getvar:${variable}`)
|
|
195
|
+
return this.lastPacket.message
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get lastPacket() {
|
|
199
|
+
return this.session.packets[this.session.packets.length - 1]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
get isActive() {
|
|
203
|
+
if (!this.session || this.session.packets.length === 0) {
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return !["FAIL", "OKAY"].includes(this.lastPacket.status)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// send buffer to phone
|
|
211
|
+
// download:00001234 -> "DATA" -> transferOut -> "OKAY"
|
|
212
|
+
async transferData(buffer: ArrayBuffer) {
|
|
213
|
+
// Bootloader requires an 8-digit hex number
|
|
214
|
+
const xferHex = buffer.byteLength.toString(16).padStart(8, "0")
|
|
215
|
+
if (xferHex.length !== 8) {
|
|
216
|
+
throw new FastbootDeviceError(
|
|
217
|
+
"FAIL",
|
|
218
|
+
`Transfer size overflow: ${xferHex} is more than 8 digits`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
this.logger.log(`Sending command download:${xferHex}.`)
|
|
223
|
+
const response = await this.sendCommand(`download:${xferHex}`)
|
|
224
|
+
|
|
225
|
+
if (response.status !== "DATA") {
|
|
226
|
+
throw new FastbootDeviceError(
|
|
227
|
+
"FAIL",
|
|
228
|
+
`response to download:${xferHex} is ${response.status}. Expected DATA.`,
|
|
229
|
+
)
|
|
230
|
+
} else if (response.dataLength !== buffer.byteLength) {
|
|
231
|
+
throw new FastbootDeviceError(
|
|
232
|
+
"FAIL",
|
|
233
|
+
`Bootloader wants ${response.dataLength} bytes, requested to send ${buffer.byteLength} bytes`,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const BULK_TRANSFER_SIZE = 16384
|
|
238
|
+
this.logger.log(`Sending payload: ${buffer.byteLength} bytes`)
|
|
239
|
+
let i = 0
|
|
240
|
+
let remainingBytes = buffer.byteLength
|
|
241
|
+
while (remainingBytes > 0) {
|
|
242
|
+
const chunk = buffer.slice(
|
|
243
|
+
i * BULK_TRANSFER_SIZE,
|
|
244
|
+
(i + 1) * BULK_TRANSFER_SIZE,
|
|
245
|
+
)
|
|
246
|
+
if (i % 1000 === 0) {
|
|
247
|
+
this.logger.log(
|
|
248
|
+
`Sending ${chunk.byteLength} bytes to endpoint, ${remainingBytes} remaining, i=${i}`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
await this.device.transferOut(this.out.endpointNumber, chunk)
|
|
252
|
+
remainingBytes -= chunk.byteLength
|
|
253
|
+
i += 1
|
|
254
|
+
}
|
|
255
|
+
this.logger.log("Payload sent, waiting for response...")
|
|
256
|
+
await this.getPackets()
|
|
257
|
+
}
|
|
258
|
+
}
|