@aepyornis/fastboot.ts 0.0.13 → 0.0.15
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/mise.toml +1 -1
- package/package.json +5 -9
- package/src/client.ts +33 -19
- package/src/device.ts +100 -56
- package/src/flasher.ts +93 -21
- package/src/sparse.ts +9 -6
- package/tsconfig.json +19 -7
package/mise.toml
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
[tools]
|
|
2
|
-
node = "
|
|
2
|
+
node = "24"
|
package/package.json
CHANGED
|
@@ -1,24 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aepyornis/fastboot.ts",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
4
4
|
"description": "Fastboot using WebUSB",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"
|
|
8
|
-
"build": "esbuild src/index.ts --bundle --sourcemap --format=esm --platform=browser --target=
|
|
7
|
+
"type-check": "tsc --noEmit",
|
|
8
|
+
"build": "tsc --noEmit && esbuild src/index.ts --bundle --sourcemap --format=esm --platform=browser --target=chrome132 --outfile=dist/fastboot.js"
|
|
9
9
|
},
|
|
10
10
|
"keywords": [],
|
|
11
11
|
"author": "Ziggy, Calyx Institute",
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"devDependencies": {
|
|
14
|
-
"@eslint/js": "^9.24.0",
|
|
15
14
|
"@types/w3c-web-usb": "^1.0.10",
|
|
16
|
-
"esbuild": "^0.
|
|
17
|
-
"eslint": "^9.24.0",
|
|
18
|
-
"globals": "^16.0.0",
|
|
15
|
+
"esbuild": "^0.27.2",
|
|
19
16
|
"prettier": "^3.5.3",
|
|
20
|
-
"typescript": "^5.8.3"
|
|
21
|
-
"typescript-eslint": "^8.29.1"
|
|
17
|
+
"typescript": "^5.8.3"
|
|
22
18
|
},
|
|
23
19
|
"dependencies": {
|
|
24
20
|
"@zip.js/zip.js": "^2.7.60"
|
package/src/client.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { BlobWriter,
|
|
1
|
+
import { BlobWriter, type FileEntry } from "@zip.js/zip.js"
|
|
2
2
|
import { IMAGES } from "./images"
|
|
3
3
|
import { parseBlobHeader, splitBlob, fromRaw } from "./sparse"
|
|
4
|
-
import { FastbootDevice
|
|
4
|
+
import { FastbootDevice } from "./device"
|
|
5
5
|
|
|
6
6
|
export class FastbootError extends Error {}
|
|
7
7
|
|
|
@@ -17,17 +17,21 @@ interface Logger {
|
|
|
17
17
|
log(message: string): void
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
interface KeyValueDict {
|
|
21
|
+
[key: string]: string
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
// higher level API to interact with fastboot device
|
|
21
25
|
// translates CLI commands to
|
|
22
26
|
export class FastbootClient {
|
|
23
27
|
fd: FastbootDevice
|
|
24
28
|
logger: Logger
|
|
25
|
-
var_cache:
|
|
29
|
+
var_cache: KeyValueDict
|
|
26
30
|
|
|
27
31
|
constructor(usb_device: USBDevice, logger: Logger = window.console) {
|
|
28
32
|
this.fd = new FastbootDevice(usb_device, logger)
|
|
29
33
|
this.logger = logger
|
|
30
|
-
this.var_cache = {}
|
|
34
|
+
this.var_cache = {} as KeyValueDict
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
async getVar(variable: string) {
|
|
@@ -72,7 +76,6 @@ export class FastbootClient {
|
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
async rebootFastboot() {
|
|
75
|
-
const serialNumber = this.fd.serialNumber
|
|
76
79
|
this.logger.log("rebooting into fastboot")
|
|
77
80
|
await this.fd.exec("reboot-fastboot")
|
|
78
81
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
@@ -83,7 +86,7 @@ export class FastbootClient {
|
|
|
83
86
|
partition: string,
|
|
84
87
|
blob: Blob,
|
|
85
88
|
slot: "current" | "other" | "a" | "b" = "current",
|
|
86
|
-
applyVbmeta: boolean = false,
|
|
89
|
+
applyVbmeta: boolean = false, // TODO: Implement flashing vbmeta
|
|
87
90
|
) {
|
|
88
91
|
// add _a or _b
|
|
89
92
|
// !(await this.isUserspace()) ?
|
|
@@ -164,7 +167,11 @@ export class FastbootClient {
|
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
// run text, typically the contents of fastboot-info.txt
|
|
167
|
-
async fastbootInfo(
|
|
170
|
+
async fastbootInfo(
|
|
171
|
+
entries: FileEntry[],
|
|
172
|
+
text: string,
|
|
173
|
+
wipe: boolean = false,
|
|
174
|
+
) {
|
|
168
175
|
const lines = text
|
|
169
176
|
.split("\n")
|
|
170
177
|
.map((x) => x.trim())
|
|
@@ -216,7 +223,12 @@ export class FastbootClient {
|
|
|
216
223
|
this.logger.log(
|
|
217
224
|
`flashing partition ${partition} with ${filename} from nested zip`,
|
|
218
225
|
)
|
|
219
|
-
await this.doFlash(
|
|
226
|
+
await this.doFlash(
|
|
227
|
+
partition!, // TODO: Assert this better
|
|
228
|
+
blob,
|
|
229
|
+
slot as "current" | "b" | "a" | "other" | undefined, // TODO: Assert this better
|
|
230
|
+
applyVbmeta,
|
|
231
|
+
)
|
|
220
232
|
break
|
|
221
233
|
}
|
|
222
234
|
case "reboot":
|
|
@@ -236,7 +248,7 @@ export class FastbootClient {
|
|
|
236
248
|
}
|
|
237
249
|
break
|
|
238
250
|
case "erase":
|
|
239
|
-
await this.erase(parts[0])
|
|
251
|
+
await this.erase(parts[0]!) // TODO: Should not assume parts to at least have 1 ele
|
|
240
252
|
break
|
|
241
253
|
default:
|
|
242
254
|
throw new Error(`unknown command ${command}`)
|
|
@@ -244,7 +256,7 @@ export class FastbootClient {
|
|
|
244
256
|
}
|
|
245
257
|
}
|
|
246
258
|
|
|
247
|
-
async updateSuper(entries:
|
|
259
|
+
async updateSuper(entries: FileEntry[], wipe: boolean) {
|
|
248
260
|
const superEmptyImage = entries.find(
|
|
249
261
|
(e) => e.filename === "super_empty.img",
|
|
250
262
|
)
|
|
@@ -255,7 +267,10 @@ export class FastbootClient {
|
|
|
255
267
|
let superName = "super"
|
|
256
268
|
try {
|
|
257
269
|
superName = await this.getVar("super-partition-name")
|
|
258
|
-
} catch (err) {
|
|
270
|
+
} catch (err) {
|
|
271
|
+
// TODO: Maybe log this error somewhere?
|
|
272
|
+
void err
|
|
273
|
+
}
|
|
259
274
|
|
|
260
275
|
// fastboot-info does this
|
|
261
276
|
// await this.rebootFastboot()
|
|
@@ -329,15 +344,15 @@ export class FastbootClient {
|
|
|
329
344
|
|
|
330
345
|
// tested on bangkk only
|
|
331
346
|
async getUnlockData() {
|
|
332
|
-
await
|
|
347
|
+
await this.fd.exec(`oem get_unlock_data`)
|
|
333
348
|
|
|
334
349
|
let data = ""
|
|
335
350
|
|
|
336
|
-
for (
|
|
337
|
-
if (packet
|
|
351
|
+
for (const packet of this.fd.session.packets) {
|
|
352
|
+
if ("command" in packet) {
|
|
338
353
|
continue
|
|
339
354
|
} else if (packet.status === "INFO") {
|
|
340
|
-
|
|
355
|
+
const message = packet.message?.replace("(bootloader)", "").trim()
|
|
341
356
|
if (message === "Unlock data:") {
|
|
342
357
|
continue
|
|
343
358
|
} else {
|
|
@@ -363,13 +378,12 @@ export class FastbootClient {
|
|
|
363
378
|
})
|
|
364
379
|
}
|
|
365
380
|
|
|
366
|
-
static async findOrRequestDevice(serialNumber: string): Promise<
|
|
367
|
-
for (const device of
|
|
381
|
+
static async findOrRequestDevice(serialNumber: string): Promise<USBDevice> {
|
|
382
|
+
for (const device of await navigator.usb.getDevices()) {
|
|
368
383
|
if (device.serialNumber === serialNumber) {
|
|
369
|
-
|
|
384
|
+
return device
|
|
370
385
|
}
|
|
371
386
|
}
|
|
372
387
|
return await FastbootClient.requestUsbDevice()
|
|
373
388
|
}
|
|
374
|
-
|
|
375
389
|
}
|
package/src/device.ts
CHANGED
|
@@ -9,7 +9,7 @@ type ResponsePacket = {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
type FastbootSession = {
|
|
12
|
-
phase: 1 | 2 | 3 | 4 | 5
|
|
12
|
+
// phase: 1 | 2 | 3 | 4 | 5
|
|
13
13
|
status: null | "OKAY" | "FAIL"
|
|
14
14
|
packets: (CommandPacket | ResponsePacket)[]
|
|
15
15
|
}
|
|
@@ -18,6 +18,15 @@ interface Logger {
|
|
|
18
18
|
log(message: string): void
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export class FastbootUsbConnectionError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
message: string = "Could not find device in navigator.usb.getDevices()",
|
|
24
|
+
) {
|
|
25
|
+
super(message)
|
|
26
|
+
this.name = "FastbootUsbConnectionError"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
export class FastbootDeviceError extends Error {
|
|
22
31
|
status: string
|
|
23
32
|
|
|
@@ -32,16 +41,22 @@ export class FastbootDeviceError extends Error {
|
|
|
32
41
|
export class FastbootDevice {
|
|
33
42
|
device: USBDevice
|
|
34
43
|
serialNumber: string;
|
|
35
|
-
in
|
|
36
|
-
out
|
|
37
|
-
session: FastbootSession
|
|
44
|
+
in!: USBEndpoint
|
|
45
|
+
out!: USBEndpoint
|
|
46
|
+
session: FastbootSession
|
|
38
47
|
sessions: FastbootSession[]
|
|
39
48
|
logger: Logger
|
|
40
49
|
|
|
41
50
|
constructor(device: USBDevice, logger: Logger = window.console) {
|
|
51
|
+
if (!device.serialNumber) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"Access to USBDevice#serialNumber is necessary but stored only in temporary memory.",
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
this.device = device
|
|
43
|
-
this.serialNumber =
|
|
44
|
-
this.session = null
|
|
58
|
+
this.serialNumber = device.serialNumber
|
|
59
|
+
this.session = { status: null, packets: [] }
|
|
45
60
|
this.sessions = []
|
|
46
61
|
this.logger = logger
|
|
47
62
|
this.setup()
|
|
@@ -51,19 +66,19 @@ export class FastbootDevice {
|
|
|
51
66
|
setup() {
|
|
52
67
|
if (this.device.configurations.length > 1) {
|
|
53
68
|
console.warn(
|
|
54
|
-
`device has ${device.configurations.length} configurations. Using the first one.`,
|
|
69
|
+
`device has ${this.device.configurations.length} configurations. Using the first one.`,
|
|
55
70
|
)
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
// this.in = this.device.configurations[0].interfaces[0].alternate.endpoints[0]
|
|
59
74
|
const endpoints =
|
|
60
|
-
this.device.configurations[0]
|
|
75
|
+
this.device.configurations[0]?.interfaces[0]?.alternate.endpoints
|
|
61
76
|
|
|
62
|
-
if (endpoints.length !== 2) {
|
|
77
|
+
if (endpoints && endpoints.length !== 2) {
|
|
63
78
|
throw new Error("USB Interface must have only 2 endpoints")
|
|
64
79
|
}
|
|
65
80
|
|
|
66
|
-
for (const endpoint of endpoints) {
|
|
81
|
+
for (const endpoint of endpoints ?? []) {
|
|
67
82
|
if (endpoint.direction === "in") {
|
|
68
83
|
this.in = endpoint
|
|
69
84
|
} else if (endpoint.direction === "out") {
|
|
@@ -84,49 +99,70 @@ export class FastbootDevice {
|
|
|
84
99
|
|
|
85
100
|
// some commands (like "flashing lock") will disconnect the device
|
|
86
101
|
// we have to re-assign this.device after it reconnects
|
|
87
|
-
async reconnect() {
|
|
102
|
+
async reconnect(): Promise<boolean> {
|
|
88
103
|
const devices = await navigator.usb.getDevices()
|
|
89
104
|
for (const device of devices) {
|
|
90
105
|
if (device.serialNumber === this.serialNumber) {
|
|
106
|
+
this.logger.log(`reconnect: Found device ${device.serialNumber}`)
|
|
91
107
|
this.device = device
|
|
92
108
|
this.setup()
|
|
93
109
|
await this.connect()
|
|
94
110
|
return true
|
|
95
111
|
}
|
|
96
112
|
}
|
|
97
|
-
throw new
|
|
113
|
+
throw new FastbootUsbConnectionError()
|
|
98
114
|
}
|
|
99
115
|
|
|
116
|
+
// The install requires reboots and it's important we pause the
|
|
117
|
+
// install and reset the usb device after it reconnects
|
|
100
118
|
async waitForReconnect(): Promise<boolean> {
|
|
101
|
-
|
|
119
|
+
try {
|
|
120
|
+
this.logger.log("waitForReconnect try reconnect()")
|
|
121
|
+
return await this.reconnect()
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error(e)
|
|
124
|
+
this.logger.log("waitForReconnect wait 3 seconds")
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 3000))
|
|
126
|
+
}
|
|
102
127
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
try {
|
|
129
|
+
return await this.reconnect()
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (e instanceof FastbootUsbConnectionError) {
|
|
132
|
+
this.logger.log("waitForReconnect wait 30 seconds")
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 30000))
|
|
134
|
+
} else {
|
|
135
|
+
throw e
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// try once more then wait for navigator.usb connect event
|
|
140
|
+
try {
|
|
141
|
+
return await this.reconnect()
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (e instanceof FastbootUsbConnectionError) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
this.logger.log("adding navigator.usb connect listener")
|
|
146
|
+
navigator.usb.addEventListener(
|
|
147
|
+
"connect",
|
|
148
|
+
async () => {
|
|
149
|
+
try {
|
|
150
|
+
await this.reconnect()
|
|
151
|
+
resolve(true)
|
|
152
|
+
} catch (e) {
|
|
153
|
+
reject(e)
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
{ once: true },
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
126
160
|
}
|
|
161
|
+
|
|
162
|
+
return false // TODO: Assume that return false if all else fails
|
|
127
163
|
}
|
|
128
164
|
|
|
129
|
-
async getPacket(): ResponsePacket {
|
|
165
|
+
async getPacket(): Promise<ResponsePacket> {
|
|
130
166
|
this.logger.log(`receiving packet from endpoint ${this.in.endpointNumber}`)
|
|
131
167
|
const inPacket = await this.device.transferIn(this.in.endpointNumber, 256)
|
|
132
168
|
const inPacketText = new TextDecoder().decode(inPacket.data)
|
|
@@ -163,40 +199,39 @@ export class FastbootDevice {
|
|
|
163
199
|
} while (["INFO", "TEXT"].includes(response.status))
|
|
164
200
|
}
|
|
165
201
|
|
|
166
|
-
async sendCommand(text): ResponsePacket {
|
|
202
|
+
async sendCommand(text: string): Promise<ResponsePacket> {
|
|
167
203
|
this.session.packets.push({ command: text } as CommandPacket)
|
|
168
204
|
const outPacket = new TextEncoder().encode(text)
|
|
169
205
|
this.logger.log(
|
|
170
206
|
`transfering "${text}" to endpoint ${this.out.endpointNumber}`,
|
|
171
207
|
)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
outPacket,
|
|
175
|
-
)
|
|
208
|
+
await this.device.transferOut(this.out.endpointNumber, outPacket)
|
|
209
|
+
|
|
176
210
|
await this.getPackets()
|
|
177
211
|
|
|
178
|
-
if (
|
|
212
|
+
if (
|
|
213
|
+
this.lastPacket &&
|
|
214
|
+
"status" in this.lastPacket &&
|
|
215
|
+
this.lastPacket.status === "FAIL"
|
|
216
|
+
) {
|
|
179
217
|
this.session.status = "FAIL"
|
|
180
218
|
throw new FastbootDeviceError(
|
|
181
219
|
this.lastPacket.status,
|
|
182
|
-
this.lastPacket.message,
|
|
220
|
+
this.lastPacket.message ?? "",
|
|
183
221
|
)
|
|
184
222
|
} else {
|
|
185
|
-
return this.lastPacket
|
|
223
|
+
return this.lastPacket as ResponsePacket // TODO: Fix bad assertion
|
|
186
224
|
}
|
|
187
225
|
}
|
|
188
226
|
|
|
189
|
-
async exec(command): ResponsePacket {
|
|
227
|
+
async exec(command: string): Promise<ResponsePacket> {
|
|
190
228
|
if (this.isActive) {
|
|
191
229
|
throw new Error("fastboot device is busy")
|
|
192
230
|
} else if (!this.device.opened) {
|
|
193
231
|
await this.connect()
|
|
194
232
|
}
|
|
195
233
|
|
|
196
|
-
|
|
197
|
-
this.sessions.push(this.session)
|
|
198
|
-
}
|
|
199
|
-
|
|
234
|
+
this.sessions.push(this.session)
|
|
200
235
|
this.session = { status: null, packets: [] }
|
|
201
236
|
|
|
202
237
|
return this.sendCommand(command)
|
|
@@ -204,22 +239,31 @@ export class FastbootDevice {
|
|
|
204
239
|
|
|
205
240
|
async getVar(variable: string): Promise<string> {
|
|
206
241
|
await this.exec(`getvar:${variable}`)
|
|
207
|
-
|
|
242
|
+
|
|
243
|
+
if (this.lastPacket && "message" in this.lastPacket) {
|
|
244
|
+
return this.lastPacket.message ?? ""
|
|
245
|
+
}
|
|
246
|
+
return ""
|
|
208
247
|
}
|
|
209
248
|
|
|
210
249
|
get lastPacket() {
|
|
211
|
-
if (
|
|
250
|
+
if (this.session.packets.length === 0) {
|
|
212
251
|
return null
|
|
252
|
+
} else {
|
|
253
|
+
return this.session.packets[this.session.packets.length - 1]
|
|
213
254
|
}
|
|
214
|
-
return this.session.packets[this.session.packets.length - 1]
|
|
215
255
|
}
|
|
216
256
|
|
|
217
257
|
get isActive() {
|
|
218
|
-
if (
|
|
258
|
+
if (this.session.packets.length === 0) {
|
|
219
259
|
return false
|
|
260
|
+
} else {
|
|
261
|
+
return (
|
|
262
|
+
this.lastPacket &&
|
|
263
|
+
"status" in this.lastPacket &&
|
|
264
|
+
!["FAIL", "OKAY"].includes(this.lastPacket.status)
|
|
265
|
+
)
|
|
220
266
|
}
|
|
221
|
-
|
|
222
|
-
return !["FAIL", "OKAY"].includes(this.lastPacket.status)
|
|
223
267
|
}
|
|
224
268
|
|
|
225
269
|
// send buffer to phone
|
package/src/flasher.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
BlobReader,
|
|
4
4
|
BlobWriter,
|
|
5
5
|
TextWriter,
|
|
6
|
-
|
|
6
|
+
type FileEntry,
|
|
7
7
|
} from "@zip.js/zip.js"
|
|
8
8
|
import type { FastbootClient } from "./client"
|
|
9
9
|
|
|
@@ -21,15 +21,66 @@ type CommandName =
|
|
|
21
21
|
| "continue"
|
|
22
22
|
| "reboot"
|
|
23
23
|
| "reboot-bootloader"
|
|
24
|
+
| "sleep"
|
|
25
|
+
| "oem"
|
|
24
26
|
| "help"
|
|
25
27
|
|
|
28
|
+
type SlotName = "current" | "other" | "a" | "b"
|
|
29
|
+
|
|
30
|
+
type InstructionOptions = {
|
|
31
|
+
wipe?: boolean
|
|
32
|
+
setActive?: "other" | "a" | "b"
|
|
33
|
+
slot?: SlotName
|
|
34
|
+
skipReboot?: boolean
|
|
35
|
+
applyVbmeta?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
type Instruction = {
|
|
27
39
|
command: CommandName
|
|
28
40
|
args: string[]
|
|
29
|
-
options:
|
|
41
|
+
options: InstructionOptions
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const COMMAND_NAMES: ReadonlySet<string> = new Set([
|
|
45
|
+
"update",
|
|
46
|
+
"flashall",
|
|
47
|
+
"flash",
|
|
48
|
+
"flashing",
|
|
49
|
+
"erase",
|
|
50
|
+
"format",
|
|
51
|
+
"getvar",
|
|
52
|
+
"set_active",
|
|
53
|
+
"boot",
|
|
54
|
+
"devices",
|
|
55
|
+
"continue",
|
|
56
|
+
"reboot",
|
|
57
|
+
"reboot-bootloader",
|
|
58
|
+
"sleep",
|
|
59
|
+
"oem",
|
|
60
|
+
"help",
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
function isCommandName(word: string): word is CommandName {
|
|
64
|
+
return COMMAND_NAMES.has(word)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isFileEntry(entry: { directory: boolean }): entry is FileEntry {
|
|
68
|
+
return !entry.directory
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requireArg(
|
|
72
|
+
command: CommandName,
|
|
73
|
+
args: string[],
|
|
74
|
+
index: number,
|
|
75
|
+
): string {
|
|
76
|
+
const value = args[index]
|
|
77
|
+
if (!value) {
|
|
78
|
+
throw new Error(`Missing argument ${index + 1} for ${command}`)
|
|
79
|
+
}
|
|
80
|
+
return value
|
|
30
81
|
}
|
|
31
82
|
|
|
32
|
-
function getEntry(entries:
|
|
83
|
+
function getEntry(entries: FileEntry[], filename: string): FileEntry {
|
|
33
84
|
const entry = entries.find(
|
|
34
85
|
(e) => e.filename.split(/[\\/]/).pop() === filename,
|
|
35
86
|
)
|
|
@@ -45,11 +96,14 @@ function parseInstruction(text: string): Instruction {
|
|
|
45
96
|
text = text.slice(8).trim()
|
|
46
97
|
}
|
|
47
98
|
|
|
48
|
-
let command: CommandName
|
|
49
|
-
const args = []
|
|
50
|
-
const options = {}
|
|
99
|
+
let command: CommandName | undefined
|
|
100
|
+
const args: string[] = []
|
|
101
|
+
const options: InstructionOptions = {}
|
|
51
102
|
|
|
52
|
-
const words = text
|
|
103
|
+
const words = text
|
|
104
|
+
.split(/\s+/)
|
|
105
|
+
.map((x) => x.trim())
|
|
106
|
+
.filter((x) => x !== "")
|
|
53
107
|
|
|
54
108
|
for (const word of words) {
|
|
55
109
|
if (word[0] === "-") {
|
|
@@ -58,15 +112,21 @@ function parseInstruction(text: string): Instruction {
|
|
|
58
112
|
} else if (word === "--set-active=other") {
|
|
59
113
|
options.setActive = "other"
|
|
60
114
|
} else if (word === "--set-active=a" || word === "--set-active=b") {
|
|
61
|
-
|
|
115
|
+
const slot = word.slice(-1)
|
|
116
|
+
if (slot === "a" || slot === "b") {
|
|
117
|
+
options.setActive = slot
|
|
118
|
+
}
|
|
62
119
|
} else if (word === "--slot-other") {
|
|
63
120
|
options.slot = "other"
|
|
64
121
|
} else if (word.slice(0, 6) === "--slot") {
|
|
65
122
|
const slot = word.split("=")[1]
|
|
123
|
+
if (!slot) {
|
|
124
|
+
throw new Error("--slot requires a value")
|
|
125
|
+
}
|
|
66
126
|
if (!["current", "other", "a", "b"].includes(slot)) {
|
|
67
127
|
throw new Error(`unknown slot: ${slot}`)
|
|
68
128
|
}
|
|
69
|
-
options.slot = slot
|
|
129
|
+
options.slot = slot as SlotName
|
|
70
130
|
} else if (word === "--skip-reboot") {
|
|
71
131
|
options.skipReboot = true
|
|
72
132
|
} else if (word === "--apply-vbmeta") {
|
|
@@ -78,10 +138,16 @@ function parseInstruction(text: string): Instruction {
|
|
|
78
138
|
if (command) {
|
|
79
139
|
args.push(word)
|
|
80
140
|
} else {
|
|
141
|
+
if (!isCommandName(word)) {
|
|
142
|
+
throw new Error(`Unknown command: ${word}`)
|
|
143
|
+
}
|
|
81
144
|
command = word
|
|
82
145
|
}
|
|
83
146
|
}
|
|
84
147
|
}
|
|
148
|
+
if (!command) {
|
|
149
|
+
throw new Error("Missing command")
|
|
150
|
+
}
|
|
85
151
|
return { command, args, options }
|
|
86
152
|
}
|
|
87
153
|
|
|
@@ -96,7 +162,7 @@ function parseInstructions(text: string): Instruction[] {
|
|
|
96
162
|
|
|
97
163
|
export class FastbootFlasher {
|
|
98
164
|
client: FastbootClient
|
|
99
|
-
reader: ZipReader
|
|
165
|
+
reader: ZipReader<BlobReader>
|
|
100
166
|
|
|
101
167
|
constructor(client: FastbootClient, blob: Blob) {
|
|
102
168
|
this.client = client
|
|
@@ -106,7 +172,7 @@ export class FastbootFlasher {
|
|
|
106
172
|
// parses and runs flash-all.sh. it ignores all shell commands
|
|
107
173
|
// except fastboot or sleep
|
|
108
174
|
async runFlashAll() {
|
|
109
|
-
const entries
|
|
175
|
+
const entries = (await this.reader.getEntries()).filter(isFileEntry)
|
|
110
176
|
const flashAllSh = await getEntry(entries, "flash-all.sh").getData(
|
|
111
177
|
new TextWriter(),
|
|
112
178
|
)
|
|
@@ -121,16 +187,16 @@ export class FastbootFlasher {
|
|
|
121
187
|
return this.run(instructions)
|
|
122
188
|
}
|
|
123
189
|
|
|
124
|
-
async run(instructions:
|
|
125
|
-
const entries
|
|
190
|
+
async run(instructions: string) {
|
|
191
|
+
const entries = (await this.reader.getEntries()).filter(isFileEntry) // io with factory.zip
|
|
126
192
|
const commands: Instruction[] = parseInstructions(instructions)
|
|
127
193
|
|
|
128
194
|
for (const command of commands) {
|
|
129
195
|
this.client.logger.log(`‣ ${JSON.stringify(command)}`)
|
|
130
196
|
if (command.command === "flash") {
|
|
131
|
-
const partition = command.args
|
|
132
|
-
const filename = command.args
|
|
133
|
-
const slot = command.options.slot
|
|
197
|
+
const partition = requireArg(command.command, command.args, 0)
|
|
198
|
+
const filename = requireArg(command.command, command.args, 1)
|
|
199
|
+
const slot = command.options.slot ?? "current"
|
|
134
200
|
const entry = getEntry(entries, filename)
|
|
135
201
|
const blob = await entry.getData(
|
|
136
202
|
new BlobWriter("application/octet-stream"),
|
|
@@ -152,15 +218,19 @@ export class FastbootFlasher {
|
|
|
152
218
|
}
|
|
153
219
|
await this.client.rebootBootloader()
|
|
154
220
|
} else if (command.command === "update") {
|
|
155
|
-
const
|
|
221
|
+
const zipName = requireArg(command.command, command.args, 0)
|
|
222
|
+
const nestedZipEntry = getEntry(entries, zipName)
|
|
156
223
|
const zipBlob = await nestedZipEntry.getData(
|
|
157
224
|
new BlobWriter("application/zip"),
|
|
158
225
|
)
|
|
159
226
|
const zipReader = new ZipReader(new BlobReader(zipBlob))
|
|
160
|
-
const nestedEntries = await zipReader.getEntries()
|
|
227
|
+
const nestedEntries = (await zipReader.getEntries()).filter(isFileEntry)
|
|
161
228
|
const fastbootInfoFile = nestedEntries.find(
|
|
162
229
|
(e) => e.filename === "fastboot-info.txt",
|
|
163
230
|
)
|
|
231
|
+
if (!fastbootInfoFile) {
|
|
232
|
+
throw new Error("fastboot-info.txt not found in nested zip")
|
|
233
|
+
}
|
|
164
234
|
const fastbootInfoText = await fastbootInfoFile.getData(
|
|
165
235
|
new TextWriter(),
|
|
166
236
|
)
|
|
@@ -182,10 +252,12 @@ export class FastbootFlasher {
|
|
|
182
252
|
throw new Error(`Unknown command`)
|
|
183
253
|
}
|
|
184
254
|
} else if (command.command === "getvar") {
|
|
185
|
-
const
|
|
186
|
-
this.client.
|
|
255
|
+
const varName = requireArg(command.command, command.args, 0)
|
|
256
|
+
const clientVar = await this.client.getVar(varName)
|
|
257
|
+
this.client.logger.log(`getVar(${varName}) => ${clientVar}`)
|
|
187
258
|
} else if (command.command === "erase") {
|
|
188
|
-
|
|
259
|
+
const partition = requireArg(command.command, command.args, 0)
|
|
260
|
+
await this.client.erase(partition)
|
|
189
261
|
} else if (command.command === "sleep") {
|
|
190
262
|
const ms = command.args[0] ? parseInt(command.args[0]) * 1000 : 5000
|
|
191
263
|
await new Promise((resolve) => setTimeout(resolve, ms))
|
package/src/sparse.ts
CHANGED
|
@@ -65,6 +65,12 @@ export interface SparseChunk {
|
|
|
65
65
|
data: Blob | null // to be populated by consumer
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export interface BlobHeader {
|
|
69
|
+
blobSize: number
|
|
70
|
+
totalBytes: number
|
|
71
|
+
isSparse: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
class BlobBuilder {
|
|
69
75
|
private blob: Blob
|
|
70
76
|
private type: string
|
|
@@ -221,6 +227,7 @@ export async function fromRaw(blob: Blob): Promise<Blob> {
|
|
|
221
227
|
chunks.push({
|
|
222
228
|
type: ChunkType.Raw,
|
|
223
229
|
blocks: chunkSize / header.blockSize,
|
|
230
|
+
dataBytes: chunkSize,
|
|
224
231
|
data: blob.slice(0, chunkSize),
|
|
225
232
|
})
|
|
226
233
|
blob = blob.slice(chunkSize)
|
|
@@ -370,7 +377,7 @@ export async function* splitBlob(blob: Blob, splitSize: number) {
|
|
|
370
377
|
// Finish the final split if necessary
|
|
371
378
|
if (
|
|
372
379
|
splitChunks.length > 0 &&
|
|
373
|
-
(splitChunks.length > 1 || splitChunks[0]
|
|
380
|
+
(splitChunks.length > 1 || splitChunks[0]?.type !== ChunkType.Skip)
|
|
374
381
|
) {
|
|
375
382
|
const splitImage = await createImage(header, splitChunks)
|
|
376
383
|
console.debug(
|
|
@@ -383,11 +390,7 @@ export async function* splitBlob(blob: Blob, splitSize: number) {
|
|
|
383
390
|
}
|
|
384
391
|
}
|
|
385
392
|
|
|
386
|
-
export async function parseBlobHeader(blob: Blob): {
|
|
387
|
-
blobSize: number
|
|
388
|
-
totalBytes: number
|
|
389
|
-
isSparse: boolean
|
|
390
|
-
} {
|
|
393
|
+
export async function parseBlobHeader(blob: Blob): Promise<BlobHeader> {
|
|
391
394
|
const FILE_HEADER_SIZE = 28
|
|
392
395
|
const blobSize = blob.size
|
|
393
396
|
let totalBytes = blobSize
|
package/tsconfig.json
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
|
-
|
|
4
|
-
"lib": ["es6", "dom"],
|
|
5
|
-
"module": "es6",
|
|
6
|
-
"sourceMap": true,
|
|
7
|
-
"allowJs": false,
|
|
3
|
+
/* Base Options: */
|
|
8
4
|
"esModuleInterop": true,
|
|
9
|
-
"
|
|
5
|
+
"skipLibCheck": true,
|
|
6
|
+
"target": "es2022",
|
|
7
|
+
"allowJs": true,
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"moduleDetection": "force",
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
|
|
13
|
+
/* Strictness */
|
|
10
14
|
"strict": true,
|
|
11
|
-
"
|
|
15
|
+
"noUncheckedIndexedAccess": true,
|
|
16
|
+
"noImplicitOverride": true,
|
|
17
|
+
|
|
18
|
+
"sourceMap": true,
|
|
19
|
+
"declaration": true,
|
|
20
|
+
"module": "preserve",
|
|
21
|
+
"noEmit": true,
|
|
22
|
+
|
|
23
|
+
"lib": ["es2022", "dom", "dom.iterable"],
|
|
12
24
|
},
|
|
13
25
|
"include": ["src/**/*"],
|
|
14
26
|
"exclude": []
|