diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..7d58441 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,46 @@ +name: deploy pages + +on: + push: + branches: [master] + paths: + - 'tools/flashpack/**' + - '.github/workflows/pages.yml' + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v5 + + - uses: oven-sh/setup-bun@v2 + + - name: build + working-directory: tools/flashpack + run: | + bun install + bun run build + + - name: configure pages + uses: actions/configure-pages@v6 + + - name: upload artifact + uses: actions/upload-pages-artifact@v5 + with: + path: tools/flashpack/dist + + - name: deploy to github pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index e7b0d19..cf650f7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ **/.DS_Store **/*.tar.xz -__pycache__/ \ No newline at end of file +__pycache__/ + +tools/flashpack/dist +tools/flashpack/node_modules diff --git a/tools/flashpack/build.ts b/tools/flashpack/build.ts new file mode 100644 index 0000000..04711ff --- /dev/null +++ b/tools/flashpack/build.ts @@ -0,0 +1,23 @@ +const gitSha = Bun.spawnSync(["git", "rev-parse", "HEAD"]).stdout.toString().trim(); + +const build = await Bun.build({ + entrypoints: ["./src/index.html"], + outdir: "./dist", + sourcemap: "linked", + minify: true, + define: { + "process.env.GIT_SHA": JSON.stringify(gitSha), + }, +}); + +if (!build.success) { + for (const log of build.logs) console.error(log); + process.exit(1); +} + +const outputs = build.outputs.map(({ path, kind, size }) => ({ + path, + kind, + "size (KiB)": (size / 1024).toFixed(1), +})); +console.table(outputs); diff --git a/tools/flashpack/bun.lock b/tools/flashpack/bun.lock new file mode 100644 index 0000000..a35fe95 --- /dev/null +++ b/tools/flashpack/bun.lock @@ -0,0 +1,56 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "flashpack", + "dependencies": { + "@commaai/qdl": "github:commaai/qdl.js", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.0.0", + }, + }, + }, + "trustedDependencies": [ + "@commaai/qdl", + ], + "packages": { + "@commaai/qdl": ["@commaai/qdl@github:commaai/qdl.js#d1e0683", { "dependencies": { "@incognitojam/tiny-struct": "https://npm.jsr.io/~/11/@jsr/incognitojam__tiny-struct/0.1.2.tgz", "arg": "^5.0.2", "crc-32": "^1.2.2", "fast-xml-parser": "^5.0.8", "usb": "^2.15.0" }, "peerDependencies": { "typescript": "^5.7.3" }, "bin": { "simg2img.js": "src/bin/simg2img.js", "qdl.js": "src/bin/qdl.js" } }, "commaai-qdl.js-d1e0683", "sha512-TeDDAfIrnmDbhE+HlMe73GZphxCD/MbQZB6hGOArYvcPb2sSGW9mir1mEFrY5tlz8b4sXzmtOCgckKsFShLEDw=="], + + "@incognitojam/tiny-struct": ["@jsr/incognitojam__tiny-struct@https://npm.jsr.io/~/11/@jsr/incognitojam__tiny-struct/0.1.2.tgz", { "dependencies": { "type-fest": "^4.37.0" } }, "sha512-5cogSpBsKV1gTuvrX72tcx5CuG/Ddi0+RO0ldw76WavR6DD/suCjnmfuDzIuleSZD4UGHBR7njBssyorpqnVgw=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@types/w3c-web-usb": ["@types/w3c-web-usb@1.0.13", "", {}, "sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], + + "fast-xml-parser": ["fast-xml-parser@5.5.9", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g=="], + + "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="], + + "strnum": ["strnum@2.2.2", "", {}, "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "usb": ["usb@2.17.0", "", { "dependencies": { "@types/w3c-web-usb": "^1.0.6", "node-addon-api": "^8.0.0", "node-gyp-build": "^4.5.0" } }, "sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg=="], + } +} diff --git a/tools/flashpack/package.json b/tools/flashpack/package.json new file mode 100644 index 0000000..23f2a29 --- /dev/null +++ b/tools/flashpack/package.json @@ -0,0 +1,18 @@ +{ + "name": "flashpack", + "type": "module", + "scripts": { + "dev": "bun run serve.ts", + "build": "bun run build.ts" + }, + "dependencies": { + "@commaai/qdl": "github:commaai/qdl.js" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.0.0" + }, + "trustedDependencies": [ + "@commaai/qdl" + ] +} diff --git a/tools/flashpack/serve.ts b/tools/flashpack/serve.ts new file mode 100644 index 0000000..76cd0e4 --- /dev/null +++ b/tools/flashpack/serve.ts @@ -0,0 +1,12 @@ +import index from "./src/index.html"; + +const server = Bun.serve({ + static: { + "/": index, + }, + development: true, + fetch() { + return new Response("404!"); + }, +}); +console.info(`Running on http://${server.hostname}:${server.port}`); diff --git a/tools/flashpack/src/app.ts b/tools/flashpack/src/app.ts new file mode 100644 index 0000000..600274d --- /dev/null +++ b/tools/flashpack/src/app.ts @@ -0,0 +1,367 @@ +import { FlashManager, Step, ErrorCode, loadProgrammer } from "./utils/manager"; +import { getManifest } from "./utils/manifest"; + +import portsThree from "./assets/qdl-ports-threex.svg"; +import portsFour from "./assets/qdl-ports-four.svg"; +import comma3X from "./assets/comma3X.webp"; +import commaFour from "./assets/four_screen_on.webp"; + +// -- State -- +let manager: FlashManager | null = null; +let selectedDevice: "comma3" | "comma4" | null = null; + +const isLinux = navigator.platform.includes("Linux"); +const isWindows = navigator.platform.includes("Win") || + (navigator as any).userAgentData?.platform === "Windows"; + +// -- Helpers -- +function $(id: string) { return document.getElementById(id)!; } + +function showStep(id: string) { + document.querySelectorAll(".step").forEach(s => s.classList.remove("active")); + $(id).classList.add("active"); +} + +function getStepLabels(): string[] { + const steps = ["Device"]; + if (isWindows) steps.push("Driver"); + steps.push("Connect"); + if (isLinux && selectedDevice === "comma3") steps.push("Unbind"); + steps.push("Flash"); + return steps; +} + +function updateStepper(current: number) { + const labels = getStepLabels(); + for (const el of document.querySelectorAll("[data-stepper]")) { + el.innerHTML = ""; + const stepper = document.createElement("div"); + stepper.className = "stepper"; + labels.forEach((label, i) => { + if (i > 0) { + const line = document.createElement("div"); + line.className = "stepper-line" + (i <= current ? " done" : ""); + stepper.appendChild(line); + } + const btn = document.createElement("button"); + btn.className = "stepper-btn" + (i === current ? " active" : i < current ? " done" : ""); + btn.textContent = i < current ? `\u2713 ${label}` : label; + if (i < current) btn.onclick = () => navigateTo(label); + else btn.disabled = true; + stepper.appendChild(btn); + }); + el.appendChild(stepper); + } +} + +function navigateTo(label: string) { + const stepMap: Record void> = { + "Device": () => { showStep("step-device"); renderDevicePicker(); }, + "Driver": () => { showStep("step-zadig"); renderZadig(); }, + "Connect": () => { showStep("step-connect"); renderConnect(); }, + "Unbind": () => { showStep("step-unbind"); renderUnbind(); }, + }; + const handler = stepMap[label]; + if (handler) { + handler(); + updateStepper(getStepLabels().indexOf(label)); + } +} + +// -- Steps -- +function renderLanding() { + $("step-landing").innerHTML = ` +
+ 🌟 + πŸ—ΊοΈ + 🌟 +
+

~*~ flashpack ~*~

+

can YOU help me flash vamOS onto my comma device??

+ + + + `; + $("btn-start").onclick = () => { + if (!manager || manager.step !== Step.READY) return; + showStep("step-device"); + renderDevicePicker(); + updateStepper(0); + }; +} + +function renderDevicePicker() { + $("step-device").innerHTML = ` +
+
πŸŽ’
+

which device are you flashing?

+

pick your comma device!

+
+ + +
+ + `; + + function select(device: "comma3" | "comma4") { + selectedDevice = device; + document.querySelectorAll(".device-card").forEach(c => c.classList.remove("selected")); + $(`pick-${device}`).classList.add("selected"); + ($("btn-device-next") as HTMLButtonElement).disabled = false; + } + + $("pick-comma3").onclick = () => select("comma3"); + $("pick-comma4").onclick = () => select("comma4"); + if (selectedDevice) select(selectedDevice); + $("btn-device-next").onclick = () => { + if (isWindows) { + showStep("step-zadig"); + renderZadig(); + updateStepper(getStepLabels().indexOf("Driver")); + } else { + showStep("step-connect"); + renderConnect(); + updateStepper(getStepLabels().indexOf("Connect")); + } + }; +} + +function renderZadig() { + const vendorId = selectedDevice === "comma4" ? "3801" : "05C6"; + $("step-zadig").innerHTML = ` +
+

install USB driver

+

Windows needs a driver to communicate with your device

+
+
    +
  1. 1Download and run Zadig
  2. +
  3. 2Under Device in the menu bar, select Create New Device
  4. +
  5. 3Fill in the form:
    + Name: ${selectedDevice === "comma4" ? "comma four" : "comma 3/3X"}
    + USB ID: ${vendorId} and 9008
  6. +
  7. 4Click Install Driver
  8. +
+
+ + `; + $("btn-zadig-done").onclick = () => { + showStep("step-connect"); + renderConnect(); + updateStepper(getStepLabels().indexOf("Connect")); + }; +} + +function renderConnect() { + const isFour = selectedDevice === "comma4"; + const portsImg = isFour ? portsFour : portsThree; + + const steps = isFour + ? `
  • AUnplug the device
  • +
  • BConnect port 1 to your computer
  • +
  • CConnect port 2 to your computer or a power brick
  • ` + : `
  • AUnplug the device
  • +
  • BWait for the light on the back to fully turn off
  • +
  • CConnect port 1 to your computer
  • +
  • DConnect port 2 to your computer or a power brick
  • `; + + $("step-connect").innerHTML = ` +
    +

    connect ur device!

    +

    follow these steps to prepare your device for flashing

    +
    + port diagram +
      ${steps}
    +
    +

    the device screen will be blank. that's totally normal!

    + + `; + + $("btn-connect-next").onclick = () => { + if (isLinux && selectedDevice === "comma3") { + showStep("step-unbind"); + renderUnbind(); + updateStepper(getStepLabels().indexOf("Unbind")); + } else { + showStep("step-webusb"); + renderWebUSB(); + updateStepper(getStepLabels().indexOf("Flash")); + } + }; +} + +function renderUnbind() { + $("step-unbind").innerHTML = ` +
    +

    unbind from qcserial

    +

    on Linux, devices in QDL mode are bound to the kernel's qcserial driver. run this command in a terminal to unbind it:

    +
    for d in /sys/bus/usb/drivers/qcserial/*-*; do [ -e "$d" ] && echo -n "$(basename $d)" | sudo tee /sys/bus/usb/drivers/qcserial/unbind > /dev/null; done
    + + `; + + $("btn-copy-unbind").onclick = () => { + const cmd = 'for d in /sys/bus/usb/drivers/qcserial/*-*; do [ -e "$d" ] && echo -n "$(basename $d)" | sudo tee /sys/bus/usb/drivers/qcserial/unbind > /dev/null; done'; + navigator.clipboard.writeText(cmd); + $("btn-copy-unbind").textContent = "Copied!"; + setTimeout(() => { $("btn-copy-unbind").textContent = "Copy"; }, 2000); + }; + + $("btn-unbind-done").onclick = () => { + showStep("step-webusb"); + renderWebUSB(); + updateStepper(getStepLabels().length - 1); + }; +} + +function renderWebUSB() { + $("step-webusb").innerHTML = ` +
    +
    πŸ”Œ
    +

    select your device

    +

    click the button below to open the device selector

    + +

    + pick QUSB_BULK_CID from the list! +

    + `; + $("btn-webusb-connect").onclick = () => startFlashing(); +} + +function renderFlash() { + $("step-flash").innerHTML = ` +
    +
    + ⚑ +
    +

    connecting...

    +

    do NOT unplug ur device!!

    +
    +
    +
    +
    + + + + `; + $("btn-retry").onclick = () => location.reload(); +} + +function renderDone() { + $("step-done").innerHTML = ` +
    +
    + πŸŽ‰ + ⭐ + 🎊 +
    +

    we did it!! we did it!!

    +

    your device is rebooting into vamOS!! lo hicimos!!

    + + `; + $("btn-again").onclick = () => location.reload(); +} + +// -- Flash -- +async function startFlashing() { + showStep("step-flash"); + const flashIdx = getStepLabels().indexOf("Flash"); + updateStepper(flashIdx); + renderFlash(); + + function setProgress(pct: number) { + if (pct < 0) { $("progress-text").textContent = ""; return; } + $("progress-fill").style.width = Math.min(pct * 100, 100) + "%"; + $("progress-text").textContent = Math.min(pct * 100, 100).toFixed(0) + "%"; + } + + manager!.callbacks.onStepChange = (step: number) => { + const titles: Record = { + [Step.CONNECTING]: "connecting...", + [Step.REPAIR_PARTITION_TABLES]: "repairing partition tables...", + [Step.ERASE_DEVICE]: "erasing device...", + [Step.FLASH_SYSTEM]: "flashing!! go go go!!", + [Step.FINALIZING]: "almost done...", + }; + if (titles[step]) $("flash-title").textContent = titles[step]; + }; + manager!.callbacks.onMessageChange = (msg: string) => { + if (msg) $("flash-status").textContent = msg; + }; + manager!.callbacks.onProgressChange = setProgress; + manager!.callbacks.onSerialChange = (serial: string) => { + $("flash-serial").textContent = "device serial: " + serial; + $("flash-serial").style.display = "block"; + }; + manager!.callbacks.onErrorChange = (error: number) => { + if (error === ErrorCode.NONE) return; + $("flash-icon").className = "flash-icon icon-red"; + $("flash-icon").innerHTML = '😒'; + $("flash-title").textContent = "oh no!! swiper no swiping!!"; + $("flash-status").textContent = ""; + $("flash-error").style.display = "block"; + $("flash-error").textContent = "something went wrong! try a different cable, USB port, or computer."; + $("btn-retry").style.display = "inline-block"; + }; + + await manager!.start(); + + if (manager!.step === Step.DONE) { + showStep("step-done"); + updateStepper(getStepLabels().length); + renderDone(); + } +} + +// -- Init -- +async function init() { + renderLanding(); + + if (typeof navigator.usb === "undefined") { + $("no-webusb").style.display = "block"; + $("btn-start").style.display = "none"; + return; + } + + try { + const [programmer, { version, manifest }] = await Promise.all([ + loadProgrammer(), + getManifest(), + ]); + + const sha = process.env.GIT_SHA || "master"; + const versionLink = document.getElementById("version-link") as HTMLAnchorElement; + versionLink.href = `https://github.com/commaai/vamOS/tree/${sha}`; + versionLink.textContent = sha.slice(0, 7); + + console.info("[flashpack] Manifest loaded:", manifest.length, "entries"); + manager = new FlashManager(programmer, {}); + await manager.initialize(manifest); + + if (manager.error !== ErrorCode.NONE) { + throw new Error("Initialization failed"); + } + + ($("btn-start") as HTMLButtonElement).disabled = false; + } catch (err: any) { + console.error("[flashpack] Init failed:", err); + const el = $("init-error"); + el.style.display = "block"; + el.textContent = "failed to load: " + (err.message || err); + } +} + +window.addEventListener("beforeunload", (e) => { + if ($("step-flash")?.classList.contains("active") && $("btn-retry")?.style.display !== "inline-block") { + e.preventDefault(); + return (e.returnValue = "Flash in progress!!"); + } +}); + +init(); diff --git a/tools/flashpack/src/assets/comma3X.webp b/tools/flashpack/src/assets/comma3X.webp new file mode 100644 index 0000000..fc0424f Binary files /dev/null and b/tools/flashpack/src/assets/comma3X.webp differ diff --git a/tools/flashpack/src/assets/four_screen_on.webp b/tools/flashpack/src/assets/four_screen_on.webp new file mode 100644 index 0000000..674add9 Binary files /dev/null and b/tools/flashpack/src/assets/four_screen_on.webp differ diff --git a/tools/flashpack/src/assets/qdl-ports-four.svg b/tools/flashpack/src/assets/qdl-ports-four.svg new file mode 100644 index 0000000..d2da770 --- /dev/null +++ b/tools/flashpack/src/assets/qdl-ports-four.svg @@ -0,0 +1,3 @@ +2 +1 + \ No newline at end of file diff --git a/tools/flashpack/src/assets/qdl-ports-threex.svg b/tools/flashpack/src/assets/qdl-ports-threex.svg new file mode 100644 index 0000000..5ae6e20 --- /dev/null +++ b/tools/flashpack/src/assets/qdl-ports-threex.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/tools/flashpack/src/index.html b/tools/flashpack/src/index.html new file mode 100644 index 0000000..6003806 --- /dev/null +++ b/tools/flashpack/src/index.html @@ -0,0 +1,618 @@ + + + + + + ~*~ flashpack ~*~ + + + +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + + + diff --git a/tools/flashpack/src/utils/image.ts b/tools/flashpack/src/utils/image.ts new file mode 100644 index 0000000..321c933 --- /dev/null +++ b/tools/flashpack/src/utils/image.ts @@ -0,0 +1,70 @@ +import { fetchStream } from "./stream"; +import type { ManifestEntry } from "./manifest"; + +type ProgressCallback = (progress: number) => void; + +const MIN_QUOTA_GB = 3; + +export class ImageManager { + root: FileSystemDirectoryHandle | null = null; + + async init() { + if (!this.root) { + this.root = await navigator.storage.getDirectory(); + try { + await (this.root as any).remove({ recursive: true }); + } catch (_) {} + this.root = await navigator.storage.getDirectory(); + console.info("[ImageManager] Initialized"); + } + + const estimate = await navigator.storage.estimate(); + const quotaGB = (estimate.quota || 0) / 1024 ** 3; + if (quotaGB < MIN_QUOTA_GB) { + throw new Error( + `Not enough storage: ${quotaGB.toFixed(1)}GB free, need ${MIN_QUOTA_GB.toFixed(1)}GB`, + ); + } + } + + async downloadImage(image: ManifestEntry, onProgress?: ProgressCallback) { + const fileName = `${image.name}-${image.hash_raw}.img`; + const fileHandle = await this.root!.getFileHandle(fileName, { create: true }); + const writable = await fileHandle.createWritable(); + + try { + if (image.chunks && image.chunks.length > 0) { + let bytesDownloaded = 0; + for (const chunk of image.chunks) { + console.debug(`[ImageManager] Downloading chunk ${chunk.url}`); + const stream = await fetchStream(chunk.url, { mode: "cors" }, { + onProgress: (chunkProgress) => { + onProgress?.((bytesDownloaded + chunkProgress * chunk.size) / image.size); + }, + }); + const reader = stream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + await writable.write(value); + } + bytesDownloaded += chunk.size; + } + await writable.close(); + } else { + console.debug(`[ImageManager] Downloading ${image.name} from ${image.url}`); + const stream = await fetchStream(image.url, { mode: "cors" }, { onProgress }); + await stream.pipeTo(writable); + } + onProgress?.(1); + } catch (e) { + throw new Error(`Error downloading ${image.name}: ${e}`, { cause: e as Error }); + } + } + + async getImage(image: ManifestEntry): Promise { + const fileName = `${image.name}-${image.hash_raw}.img`; + const fileHandle = await this.root!.getFileHandle(fileName, { create: false }); + return fileHandle.getFile(); + } +} diff --git a/tools/flashpack/src/utils/manager.ts b/tools/flashpack/src/utils/manager.ts new file mode 100644 index 0000000..accaf21 --- /dev/null +++ b/tools/flashpack/src/utils/manager.ts @@ -0,0 +1,284 @@ +import { qdlDevice } from "@commaai/qdl"; +import { usbClass } from "@commaai/qdl/usblib"; + +import type { ManifestEntry } from "./manifest"; +import { ImageManager } from "./image"; +import { createSteps, withProgress } from "./progress"; + +const PROGRAMMER_URL = + "https://raw.githubusercontent.com/commaai/flash/master/src/QDL/programmer.bin"; + +export const Step = { + INITIALIZING: 0, + READY: 1, + CONNECTING: 2, + REPAIR_PARTITION_TABLES: 3, + ERASE_DEVICE: 4, + FLASH_SYSTEM: 5, + FINALIZING: 6, + DONE: 7, +} as const; + +export const ErrorCode = { + NONE: 0, + UNKNOWN: -1, + REQUIREMENTS_NOT_MET: 1, + STORAGE_SPACE: 2, + LOST_CONNECTION: 3, + REPAIR_FAILED: 4, + ERASE_FAILED: 5, + FLASH_FAILED: 6, +} as const; + +export interface FlashCallbacks { + onStepChange?: (step: number) => void; + onMessageChange?: (message: string) => void; + onProgressChange?: (progress: number) => void; + onErrorChange?: (error: number) => void; + onConnectionChange?: (connected: boolean) => void; + onSerialChange?: (serial: string) => void; +} + +export class FlashManager { + callbacks: FlashCallbacks; + private device: qdlDevice; + private imageManager: ImageManager; + private manifest: ManifestEntry[] | null = null; + step = Step.INITIALIZING; + error = ErrorCode.NONE; + + constructor(programmer: ArrayBuffer, callbacks: FlashCallbacks = {}) { + this.callbacks = callbacks; + this.device = new qdlDevice(programmer); + this.imageManager = new ImageManager(); + } + + private setStep(step: number) { + this.step = step; + this.callbacks.onStepChange?.(step); + } + + private setMessage(message: string) { + if (message) console.info("[flashpack]", message); + this.callbacks.onMessageChange?.(message); + } + + private setProgress(progress: number) { + this.callbacks.onProgressChange?.(progress); + } + + private setError(error: number) { + this.error = error; + this.callbacks.onErrorChange?.(error); + this.setProgress(-1); + } + + async initialize(manifest: ManifestEntry[]) { + this.setProgress(-1); + this.setMessage(""); + + if (typeof navigator.usb === "undefined") { + this.setError(ErrorCode.REQUIREMENTS_NOT_MET); + return; + } + + try { + await this.imageManager.init(); + } catch (err: any) { + console.error("[flashpack] Failed to initialize image manager:", err); + if (err?.message?.startsWith("Not enough storage")) { + this.setError(ErrorCode.STORAGE_SPACE); + this.setMessage(err.message); + } else { + this.setError(ErrorCode.UNKNOWN); + } + return; + } + + this.manifest = manifest; + console.info("[flashpack] Loaded manifest:", this.manifest.length, "entries"); + this.setStep(Step.READY); + } + + private async connect() { + this.setStep(Step.CONNECTING); + this.setProgress(-1); + + try { + await this.device.connect(new usbClass()); + } catch (err: any) { + if (err.name === "NotFoundError") { + this.setStep(Step.READY); + return; + } + console.error("[flashpack] Connection error:", err); + this.setError(ErrorCode.LOST_CONNECTION); + return; + } + + console.info("[flashpack] Connected"); + this.callbacks.onConnectionChange?.(true); + + try { + const storageInfo = await this.device.getStorageInfo(); + const serial = Number(storageInfo.serial_num).toString(16).padStart(8, "0"); + this.callbacks.onSerialChange?.(serial); + console.info("[flashpack] Serial:", serial); + } catch (err) { + console.warn("[flashpack] Could not read storage info:", err); + } + } + + private async repairPartitionTables() { + this.setStep(Step.REPAIR_PARTITION_TABLES); + this.setProgress(0); + + const gptImages = this.manifest!.filter((e) => !!e.gpt); + if (gptImages.length === 0) { + console.error("[flashpack] No GPT images found"); + this.setError(ErrorCode.REPAIR_FAILED); + return; + } + + try { + for (const [image, onProgress] of withProgress(gptImages, this.setProgress.bind(this))) { + const [onDownload, onRepair] = createSteps([2, 1], onProgress); + this.setMessage(`Downloading ${image.name}`); + await this.imageManager.downloadImage(image, onDownload); + const blob = await this.imageManager.getImage(image); + this.setMessage(`Repairing GPT LUN ${image.gpt!.lun}`); + if (!(await this.device.repairGpt(image.gpt!.lun, blob))) { + throw new Error(`Repairing LUN ${image.gpt!.lun} failed`); + } + onRepair(1.0); + } + } catch (err) { + console.error("[flashpack] Partition table repair failed:", err); + this.setError(ErrorCode.REPAIR_FAILED); + } + } + + private async eraseDevice() { + this.setStep(Step.ERASE_DEVICE); + this.setProgress(-1); + + const luns = Array.from({ length: 6 }, (_, i) => i); + + const [found, persistLun, partition] = await this.device.detectPartition("persist"); + if (!found || luns.indexOf(persistLun) < 0) { + console.error("[flashpack] Could not find persist partition"); + this.setError(ErrorCode.ERASE_FAILED); + return; + } + + try { + const critical = ["mbr", "gpt"]; + for (const lun of luns) { + const preserve = [...critical]; + if (lun === persistLun) preserve.push("persist"); + this.setMessage(`Erasing LUN ${lun}`); + if (!(await this.device.eraseLun(lun, preserve))) { + throw new Error(`Erasing LUN ${lun} failed`); + } + } + } catch (err) { + console.error("[flashpack] Erase failed:", err); + this.setError(ErrorCode.ERASE_FAILED); + } + } + + private async flashSystem() { + this.setStep(Step.FLASH_SYSTEM); + this.setProgress(0); + + // Flash everything except GPTs and persist + const systemImages = this.manifest!.filter((e) => !e.gpt && e.name !== "persist"); + + try { + for await (const [image, onImageProgress] of withProgress( + systemImages, + this.setProgress.bind(this), + (img) => img.size, + )) { + const [onDownload, onFlash] = createSteps( + [1, image.has_ab ? 2 : 1], + onImageProgress, + ); + + this.setMessage(`Downloading ${image.name}`); + await this.imageManager.downloadImage(image, onDownload); + const blob = await this.imageManager.getImage(image); + onDownload(1.0); + + const slots = image.has_ab ? ["_a", "_b"] : [""]; + for (const [slot, onSlotProgress] of withProgress(slots, onFlash)) { + const partitionName = `${image.name}${slot}`; + + // Skip partitions that don't exist on this device + const [found] = await this.device.detectPartition(partitionName); + if (!found) { + console.warn(`[flashpack] Partition ${partitionName} not found, skipping`); + onSlotProgress(1.0); + continue; + } + + this.setMessage(`Flashing ${partitionName}`); + if ( + !(await this.device.flashBlob( + partitionName, + blob, + (progress: number) => onSlotProgress(progress / image.size), + false, + )) + ) { + throw new Error(`Flashing ${partitionName} failed`); + } + onSlotProgress(1.0); + } + } + } catch (err) { + console.error("[flashpack] Flash failed:", err); + this.setError(ErrorCode.FLASH_FAILED); + } + } + + private async finalize() { + this.setStep(Step.FINALIZING); + this.setProgress(-1); + this.setMessage("Setting active slot"); + + if (!(await this.device.setActiveSlot("a"))) { + this.setError(ErrorCode.UNKNOWN); + return; + } + + this.setMessage("Rebooting"); + await this.device.reset(); + this.callbacks.onConnectionChange?.(false); + this.setStep(Step.DONE); + } + + async start() { + if (this.step !== Step.READY) return; + + await this.connect(); + if (this.step === Step.READY || this.error !== ErrorCode.NONE) return; + + await this.repairPartitionTables(); + if (this.error !== ErrorCode.NONE) return; + + await this.eraseDevice(); + if (this.error !== ErrorCode.NONE) return; + + await this.flashSystem(); + if (this.error !== ErrorCode.NONE) return; + + await this.finalize(); + } +} + +export async function loadProgrammer(): Promise { + const res = await fetch(PROGRAMMER_URL); + if (!res.ok) throw new Error(`Failed to fetch programmer: ${res.status}`); + return res.arrayBuffer(); +} diff --git a/tools/flashpack/src/utils/manifest.ts b/tools/flashpack/src/utils/manifest.ts new file mode 100644 index 0000000..d01cbde --- /dev/null +++ b/tools/flashpack/src/utils/manifest.ts @@ -0,0 +1,38 @@ +const REPO = "commaai/vamOS"; +const IMAGES_REPO = "commaai/vamos-images"; +const VERSION_URL = `https://raw.githubusercontent.com/${REPO}/master/userspace/root/VERSION`; + +export interface ChunkInfo { + url: string; + size: number; +} + +export interface ManifestEntry { + name: string; + url?: string; + hash: string; + hash_raw: string; + size: number; + sparse: boolean; + full_check: boolean; + has_ab: boolean; + ondevice_hash: string; + chunks?: ChunkInfo[]; + gpt?: { + lun: number; + start_sector: number; + num_sectors: number; + }; +} + +export async function getManifest(): Promise<{ version: string; manifest: ManifestEntry[] }> { + const versionRes = await fetch(VERSION_URL); + if (!versionRes.ok) throw new Error(`Failed to fetch version: ${versionRes.status}`); + const version = (await versionRes.text()).trim(); + + const manifestUrl = `https://raw.githubusercontent.com/${IMAGES_REPO}/v${version}/manifest.json`; + const res = await fetch(manifestUrl); + if (!res.ok) throw new Error(`Failed to fetch manifest: ${res.status}`); + const manifest = await res.json(); + return { version, manifest }; +} diff --git a/tools/flashpack/src/utils/progress.ts b/tools/flashpack/src/utils/progress.ts new file mode 100644 index 0000000..d9804e4 --- /dev/null +++ b/tools/flashpack/src/utils/progress.ts @@ -0,0 +1,45 @@ +type ProgressCallback = (progress: number) => void; + +export function createSteps( + steps: number[] | number, + onProgress: ProgressCallback, +): ProgressCallback[] { + const stepWeights = typeof steps === "number" ? Array(steps).fill(1) : steps; + const progressParts = Array(stepWeights.length).fill(0); + const totalSize = stepWeights.reduce((total, weight) => total + weight, 0); + + function updateProgress() { + const weightedAverage = stepWeights.reduce( + (acc, weight, idx) => acc + progressParts[idx] * weight, + 0, + ); + onProgress(weightedAverage / totalSize); + } + + return stepWeights.map((_weight, idx) => (progress: number) => { + if (progressParts[idx] !== progress) { + progressParts[idx] = progress; + updateProgress(); + } + }); +} + +export function withProgress( + steps: T[], + onProgress: ProgressCallback, + getStepWeight?: (step: T) => number, +): [T, ProgressCallback][] { + const callbacks = createSteps( + steps.map( + getStepWeight || + ((step: any) => + typeof step === "number" + ? step + : typeof step !== "string" + ? step.size || step.length || 1 + : 1), + ), + onProgress, + ); + return steps.map((step, idx) => [step, callbacks[idx]]); +} diff --git a/tools/flashpack/src/utils/stream.ts b/tools/flashpack/src/utils/stream.ts new file mode 100644 index 0000000..8f9a609 --- /dev/null +++ b/tools/flashpack/src/utils/stream.ts @@ -0,0 +1,69 @@ +type ProgressCallback = (progress: number) => void; + +const getContentLength = (response: Response): number => { + const total = response.headers.get("Content-Length"); + if (total) return parseInt(total, 10); + throw new Error("Content-Length not found in response headers"); +}; + +interface FetchStreamOptions { + maxRetries?: number; + retryDelay?: number; + onProgress?: ProgressCallback; +} + +export async function fetchStream( + url: string | URL, + requestOptions: RequestInit = {}, + options: FetchStreamOptions = {}, +): Promise> { + const maxRetries = options.maxRetries || 3; + const retryDelay = options.retryDelay || 1000; + + const fetchRange = async (startByte: number, signal: AbortSignal) => { + const headers: Record = { + ...(requestOptions.headers as Record || {}), + }; + if (startByte > 0) headers["range"] = `bytes=${startByte}-`; + const response = await fetch(url, { ...requestOptions, headers, signal }); + if (!response.ok || (response.status !== 206 && response.status !== 200)) { + throw new Error(`Fetch error: ${response.status}`); + } + return response; + }; + + const abortController = new AbortController(); + let startByte = 0; + let contentLength: number | null = null; + + return new ReadableStream({ + async pull(stream) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await fetchRange(startByte, abortController.signal); + if (contentLength === null) contentLength = getContentLength(response); + const reader = response.body!.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) { stream.close(); return; } + startByte += value.byteLength; + stream.enqueue(value); + options.onProgress?.(startByte / contentLength!); + } + } catch (err) { + console.warn(`Attempt ${attempt + 1} failed:`, err); + if (attempt === maxRetries) { + abortController.abort(); + stream.error(new Error("Max retries reached", { cause: err as Error })); + return; + } + await new Promise((res) => setTimeout(res, retryDelay)); + } + } + }, + cancel(reason) { + console.warn("Stream canceled:", reason); + abortController.abort(); + }, + }); +} diff --git a/tools/flashpack/tsconfig.json b/tools/flashpack/tsconfig.json new file mode 100644 index 0000000..8c332e1 --- /dev/null +++ b/tools/flashpack/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true + } +}