Skip to content

Commit

Permalink
Optimize UX by removing fastboot initialization step
Browse files Browse the repository at this point in the history
This refactor eliminates the visible INITIALIZING to READY state
transition, allowing users to interact with the page immediately after
load.

The manifest is now fetched silently in the background, typically taking
only 10-30ms. If a user interacts before the fetch completes, the
system waits for readiness before proceeding.

These changes eliminate flashing that occurs on page load.
  • Loading branch information
trentrand committed Jul 14, 2024
1 parent 7607d13 commit 3aa6def
Show file tree
Hide file tree
Showing 4 changed files with 99 additions and 93 deletions.
Binary file removed bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion src/app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import fastbootPorts from '../assets/fastboot-ports.svg'
import zadigCreateNewDevice from '../assets/zadig_create_new_device.png'
import zadigForm from '../assets/zadig_form.png'

const Flash = lazy(() => import('./Flash'));
const Flash = lazy(() => import('./Flash.client'));

export default function App() {
const version = import.meta.env.VITE_PUBLIC_GIT_SHA || 'dev'
Expand Down
41 changes: 22 additions & 19 deletions src/app/Flash.jsx → src/app/Flash.client.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useCallback } from 'react'
'use client';

import { useCallback, useEffect } from 'react'

import { Step, Error, useFastboot } from '@/utils/fastboot'

Expand All @@ -16,11 +18,6 @@ import systemUpdate from '@/assets/system_update_c3.svg'


const steps = {
[Step.INITIALIZING]: {
status: 'Initializing...',
bgColor: 'bg-gray-400 dark:bg-gray-700',
icon: cloud,
},
[Step.READY]: {
status: 'Ready',
description: 'Tap the button above to begin',
Expand Down Expand Up @@ -189,10 +186,6 @@ export default function Flash() {
serial,
} = useFastboot()

const handleContinue = useCallback(() => {
onContinue?.()
}, [onContinue])

const handleRetry = useCallback(() => {
onRetry?.()
}, [onRetry])
Expand All @@ -214,18 +207,24 @@ export default function Flash() {
}

// warn the user if they try to leave the page while flashing
if (Step.DOWNLOADING <= step && step <= Step.ERASING) {
window.addEventListener("beforeunload", beforeUnloadListener, { capture: true })
} else {
window.removeEventListener("beforeunload", beforeUnloadListener, { capture: true })
}
useEffect(() => {
if (Step.DOWNLOADING <= step && step <= Step.ERASING) {
window.addEventListener("beforeunload", beforeUnloadListener, { capture: true })
} else {
window.removeEventListener("beforeunload", beforeUnloadListener, { capture: true })
}

return () => {
window.removeEventListener("beforeunload", beforeUnloadListener, { capture: true })
}
}, [step])

return (
<div id="flash" className="relative flex flex-col gap-8 justify-center items-center h-full">
<div
className={`p-8 rounded-full ${bgColor}`}
style={{ cursor: onContinue ? 'pointer' : 'default' }}
onClick={handleContinue}
style={{ cursor: 'pointer' }}
onClick={onContinue}
>
<img
src={icon}
Expand All @@ -238,8 +237,12 @@ export default function Flash() {
<div className="w-full max-w-3xl px-8 transition-opacity duration-300" style={{ opacity: progress === -1 ? 0 : 1 }}>
<LinearProgress value={progress * 100} barColor={bgColor} />
</div>
<span className={`text-3xl dark:text-white font-mono font-light`}>{title}</span>
<span className={`text-xl dark:text-white px-8 max-w-xl`}>{description}</span>
<span className={`text-3xl dark:text-white font-mono font-light`}>
{title}
</span>
<span className={`text-xl dark:text-white px-8 max-w-xl`}>
{description}
</span>
{error && (
<button
className="px-4 py-2 rounded-md bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 transition-colors"
Expand Down
149 changes: 76 additions & 73 deletions src/utils/fastboot.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'

import { FastbootDevice, setDebugLevel } from 'android-fastboot'
import * as Comlink from 'comlink'
Expand All @@ -17,14 +17,13 @@ import { withProgress } from '@/utils/progress'
setDebugLevel(2)

export const Step = {
INITIALIZING: 0,
READY: 1,
CONNECTING: 2,
DOWNLOADING: 3,
UNPACKING: 4,
FLASHING: 6,
ERASING: 7,
DONE: 8,
READY: 0,
CONNECTING: 1,
DOWNLOADING: 2,
UNPACKING: 3,
FLASHING: 4,
ERASING: 5,
DONE: 6,
}

export const Error = {
Expand Down Expand Up @@ -87,15 +86,15 @@ function isRecognizedDevice(deviceInfo) {
}

export function useFastboot() {
const [step, _setStep] = useState(Step.INITIALIZING)
const [step, setStep] = useState(Step.READY)
const [message, _setMessage] = useState('')
const [progress, setProgress] = useState(0)
const [error, _setError] = useState(Error.NONE)
const [error, setError] = useState(Error.NONE)
const [isInitialized, setIsInitialized] = useState(false)

const [connected, setConnected] = useState(false)
const [serial, setSerial] = useState(null)

const [onContinue, setOnContinue] = useState(null)
const [onRetry, setOnRetry] = useState(null)

const imageWorker = useImageWorker()
Expand All @@ -104,83 +103,87 @@ export function useFastboot() {
/** @type {React.RefObject<Image[]>} */
const manifest = useRef(null)

function setStep(step) {
_setStep(step)
}
const initializePromise = useRef(null);

function setMessage(message = '') {
if (message) console.info('[fastboot]', message)
_setMessage(message)
}

function setError(error) {
_setError(error)
}
const initialize = useCallback(async () => {
if (isInitialized) return true

useEffect(() => {
setProgress(-1)
setMessage()
if (initializePromise.current) return initializePromise.current

if (error) return
if (!imageWorker.current) {
console.debug('[fastboot] Waiting for image worker')
return
}
initializePromise.current = (async () => {
// Check browser support
if (typeof navigator.usb === 'undefined') {
console.error('[fastboot] WebUSB not supported')
setError(Error.REQUIREMENTS_NOT_MET)
return false
}

switch (step) {
case Step.INITIALIZING: {
// Check that the browser supports WebUSB
if (typeof navigator.usb === 'undefined') {
console.error('[fastboot] WebUSB not supported')
setError(Error.REQUIREMENTS_NOT_MET)
break
}
if (typeof Worker === 'undefined') {
console.error('[fastboot] Web Workers not supported')
setError(Error.REQUIREMENTS_NOT_MET)
return false
}

// Check that the browser supports Web Workers
if (typeof Worker === 'undefined') {
console.error('[fastboot] Web Workers not supported')
setError(Error.REQUIREMENTS_NOT_MET)
break
}
if (typeof Storage === 'undefined') {
console.error('[fastboot] Storage API not supported')
setError(Error.REQUIREMENTS_NOT_MET)
return false
}

// Check that the browser supports Storage API
if (typeof Storage === 'undefined') {
console.error('[fastboot] Storage API not supported')
setError(Error.REQUIREMENTS_NOT_MET)
break
}
if (!imageWorker.current) {
console.debug('[fastboot] Waiting for image worker')
return false
}

// TODO: change manifest once alt image is in release
imageWorker.current?.init()
.then(() => download(config.manifests['master']))
.then(blob => blob.text())
.then(text => {
manifest.current = createManifest(text)
try {
await imageWorker.current?.init()
const blob = await download(config.manifests['master'])
const text = await blob.text()
manifest.current = createManifest(text)

// sanity check
if (manifest.current.length === 0) {
throw 'Manifest is empty'
}
if (manifest.current.length === 0) {
throw new Error('Manifest is empty')
}

console.debug('[fastboot] Loaded manifest', manifest.current)
setStep(Step.READY)
})
.catch((err) => {
console.error('[fastboot] Initialization error', err)
setError(Error.UNKNOWN)
})
break
console.debug('[fastboot] Loaded manifest', manifest.current)
setIsInitialized(true)
return true
} catch (err) {
console.error('[fastboot] Initialization error', err)
setError(Error.UNKNOWN)
return false
} finally {
initializePromise.current = null
}
})()

case Step.READY: {
// wait for user interaction (we can't use WebUSB without user event)
setOnContinue(() => () => {
setOnContinue(null)
setStep(Step.CONNECTING)
})
break
}
return initializePromise.current
}, [imageWorker, isInitialized])

useEffect(() => {
initialize()
}, [initialize])

// wait for user interaction (we can't use WebUSB without user event)
const handleContinue = useCallback(async () => {
const shouldContinue = await initialize()
if (!shouldContinue) return

setStep(Step.CONNECTING)
}, [initialize])

useEffect(() => {
setProgress(-1)
setMessage()

if (error) return

switch (step) {
case Step.CONNECTING: {
fastboot.current.waitForConnect()
.then(() => {
Expand Down Expand Up @@ -362,7 +365,7 @@ export function useFastboot() {
connected,
serial,

onContinue,
onContinue: handleContinue,
onRetry,
}
}

0 comments on commit 3aa6def

Please sign in to comment.