From a2d699a5bffa020d1b3979cb301bd3c0e20b0538 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Tue, 16 Sep 2025 10:25:41 +0100 Subject: [PATCH 1/8] Trigger a go build --- src/commands/game/ship.tsx | 4 ++-- src/types/api.ts | 2 ++ src/types/index.ts | 2 +- src/utils/query/useShip.ts | 4 +++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/game/ship.tsx b/src/commands/game/ship.tsx index 26b7166..8b62f16 100644 --- a/src/commands/game/ship.tsx +++ b/src/commands/game/ship.tsx @@ -41,8 +41,8 @@ export default class GameShip extends BaseGameCommand { required: false, }), platform: Flags.string({ - description: 'The platform to ship the game to. This can be "android" or "ios"', - options: ['android', 'ios'], + description: 'The platform to ship the game to. This can be "android", "ios", or "go"', + options: ['android', 'ios', 'go'], required: false, }), skipPublish: Flags.boolean({ diff --git a/src/types/api.ts b/src/types/api.ts index b888269..587c70f 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -28,6 +28,7 @@ export type SelfWithJWT = { export enum Platform { ANDROID = 'ANDROID', IOS = 'IOS', + GO = 'GO', } export enum GameEngine { @@ -178,6 +179,7 @@ export enum BuildType { AAB = 'AAB', APK = 'APK', IPA = 'IPA', + GO = 'GO', } export interface Build { diff --git a/src/types/index.ts b/src/types/index.ts index 4b25ea4..90812e6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -24,7 +24,7 @@ export type ShipGameFlags = { download?: string downloadAPK?: string follow?: boolean - platform?: 'android' | 'ios' + platform?: 'android' | 'ios' | 'go' skipPublish?: boolean verbose?: boolean useDemoCredentials?: boolean diff --git a/src/utils/query/useShip.ts b/src/utils/query/useShip.ts index 2d6db06..a4f812d 100644 --- a/src/utils/query/useShip.ts +++ b/src/utils/query/useShip.ts @@ -36,8 +36,10 @@ export async function ship({command, log = () => {}, shipFlags}: ShipOptions): P const hasConfiguredIos = Boolean(project.details?.iosBundleId) const hasConfiguredAndroid = Boolean(project.details?.androidPackageName) + const hasOnePlatformConfigured = hasConfiguredAndroid || hasConfiguredIos + const isGo = finalFlags?.platform === 'go' - if (!isUsingDemoCredentials && !hasConfiguredAndroid && !hasConfiguredIos) { + if (!isGo && !isUsingDemoCredentials && !hasOnePlatformConfigured) { throw new Error( 'No Android or iOS configuration found. Please run `shipthis game wizard android` or `shipthis game wizard ios` to configure your game.', ) From 880316b07ef594209a1ef96f460452f0bf56a685 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Fri, 17 Oct 2025 12:28:16 +0100 Subject: [PATCH 2/8] Fix getPatformName for go builds --- src/utils/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/utils/index.ts b/src/utils/index.ts index 68c9dbb..6f6671b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -146,6 +146,10 @@ export function getPlatformName(platform: Platform): string { return 'Android' } + case Platform.GO: { + return 'Go' + } + default: { throw new Error(`Unknown platform: ${platform}`) } From 64ea5bf1eae3f355c7c31b3f24f1579b45537ce5 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Wed, 22 Oct 2025 11:02:17 +0100 Subject: [PATCH 3/8] ship command refactor and prototype go command showing the qr code --- src/api/index.ts | 19 ++++ src/commands/game/go.tsx | 33 ++++++ src/commands/game/ship.tsx | 23 +--- src/components/Go.tsx | 59 +++++++++++ src/components/Ship/KeyboardShortcuts.tsx | 40 +++++++ src/components/Ship/ShipResult.tsx | 46 ++++++++ src/components/Ship/index.tsx | 100 ++++++++++++++++++ .../ConnectGoogle/GoogleAuthQRCode.tsx | 2 +- src/components/common/QRCodeTerminal.tsx | 4 +- src/components/index.tsx | 2 +- src/utils/hooks/index.ts | 2 + src/utils/hooks/useProjectJobListener.ts | 69 ++++++++++++ src/utils/hooks/useStartShipOnMount.ts | 27 +++++ test/commands/game/go.test.ts | 14 +++ 14 files changed, 418 insertions(+), 22 deletions(-) create mode 100644 src/commands/game/go.tsx create mode 100644 src/components/Go.tsx create mode 100644 src/components/Ship/KeyboardShortcuts.tsx create mode 100644 src/components/Ship/ShipResult.tsx create mode 100644 src/components/Ship/index.tsx create mode 100644 src/utils/hooks/useProjectJobListener.ts create mode 100644 src/utils/hooks/useStartShipOnMount.ts create mode 100644 test/commands/game/go.test.ts diff --git a/src/api/index.ts b/src/api/index.ts index 3a90da2..3002dde 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -142,6 +142,25 @@ export async function getJob(jobId: string, projectId: string): Promise { return castJobDates(data) } +const MAX_RETRIES = 3 +const RETRY_DELAY_MS = 5000 + +// Returns the builds for a job, retrying if none are found +// The retry is because of possible race condition when a job completes +export async function getJobBuildsRetry(jobId: string, projectId: string, retries = MAX_RETRIES): Promise { + let job: Job | null = null + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + job = await getJob(jobId, projectId) + if (job.builds && job.builds.length > 0) break + if (attempt < MAX_RETRIES) await new Promise((res) => setTimeout(res, RETRY_DELAY_MS)) + } + + if (!job?.builds || job.builds.length === 0) throw new Error('No builds found for this job after multiple attempts') + + return job.builds +} + // Returns a url with an OTP - when visited it authenticates the user export async function getSingleUseUrl(destination: string) { // Call the API to generate an OTP diff --git a/src/commands/game/go.tsx b/src/commands/game/go.tsx new file mode 100644 index 0000000..e30cdce --- /dev/null +++ b/src/commands/game/go.tsx @@ -0,0 +1,33 @@ +import {BaseGameCommand} from '@cli/baseCommands/index.js' +import {Go} from '@cli/components/Go.js' +import {CommandGame} from '@cli/components/index.js' + +import {render} from 'ink' + +export default class GameGo extends BaseGameCommand { + static override args = {} + static override description = 'Preview your game in the ShipThis Go app.' + static override examples = ['<%= config.bin %> <%= command.id %>'] + static override flags = { + ...BaseGameCommand.flags, + } + + public async run(): Promise { + await this.ensureWeAreInAProjectDir() + const gameId = this.getGameId() + if (!gameId) { + this.error('No game ID found') + } + + const handleComplete = () => process.exit(0) + const handleError = (error: any) => { + this.error(`Error generating go build: ${error}`) + } + + render( + + + , + ) + } +} diff --git a/src/commands/game/ship.tsx b/src/commands/game/ship.tsx index 8b62f16..14d1bf9 100644 --- a/src/commands/game/ship.tsx +++ b/src/commands/game/ship.tsx @@ -1,7 +1,7 @@ import {Flags} from '@oclif/core' import {render} from 'ink' -import {downloadBuildById, getJob} from '@cli/api/index.js' +import {downloadBuildById, getJobBuildsRetry} from '@cli/api/index.js' import {BaseGameCommand} from '@cli/baseCommands/baseGameCommand.js' import {CommandGame, Ship} from '@cli/components/index.js' import {Job} from '@cli/types/api.js' @@ -73,26 +73,13 @@ export default class GameShip extends BaseGameCommand { this.error('No game ID found') } - const MAX_RETRIES = 3 - const RETRY_DELAY_MS = 5000 - - const handleComplete = async ([originalJob]: Job[]) => { + const handleComplete = async ([job]: Job[]) => { if (!this.flags.download && !this.flags.downloadAPK) return process.exit(0) - - let job: Job | null = null - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - job = await getJob(originalJob.id, gameId) - if (job.builds && job.builds.length > 0) break - if (attempt < MAX_RETRIES) await new Promise((res) => setTimeout(res, RETRY_DELAY_MS)) - } - - if (!job?.builds || job.builds.length === 0) this.error('No builds found for this job after multiple attempts') - + // Use a retry mechanism to get the builds, as they may not be immediately available + const builds = await getJobBuildsRetry(job.id, gameId) const {platform} = this.flags const type = platform === 'android' ? (this.flags.downloadAPK ? 'APK' : 'AAB') : 'IPA' - - const build = job.builds.find((b) => b.buildType === type) + const build = builds.find((b) => b.buildType === type) if (!build) this.error(`No build found for type ${type}`) const filename = this.flags.download || this.flags.downloadAPK diff --git a/src/components/Go.tsx b/src/components/Go.tsx new file mode 100644 index 0000000..f2cf496 --- /dev/null +++ b/src/components/Go.tsx @@ -0,0 +1,59 @@ +import {useContext, useState} from 'react' + +import {getShortUUID, useProjectJobListener, useStartShipOnMount} from '@cli/utils/index.js' +import {getJobBuildsRetry} from '@cli/api/index.js' + +import {CommandContext, GameContext, JobProgress, QRCodeTerminal} from './index.js' + +interface Props { + onComplete: () => void + onError: (error: any) => void +} + +export const Go = ({onComplete, onError}: Props): JSX.Element| null => { + const {command} = useContext(CommandContext) + const {gameId} = useContext(GameContext) + if (!command || !gameId) return null + return +} + +interface GoCommandProps extends Props { + command: any + gameId: string +} + +const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX.Element | null=> { + const flags = {follow: false, platform: 'go'} + + const {jobs: startedJobs} = useStartShipOnMount(command, flags, onError) + + const handleJobCompleted = async (job: any) => { + const [goBuild] = await getJobBuildsRetry(job.id, command.getGameId()) + setQRCodeData(getShortUUID(goBuild.id)) + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + await sleep(500) + onComplete() + } + + const handleJobFailed = (job: any) => { + onError(new Error(`Go job failed: ${job.id}`)) + } + + const {jobsById} = useProjectJobListener({ + projectId: gameId, + onJobCompleted: handleJobCompleted, + onJobFailed: handleJobFailed, + }) + + const [qrCodeData, setQRCodeData] = useState(null) + + if (qrCodeData) { + return + } + + if (startedJobs && startedJobs?.length > 0) { + return + } + + return null +} diff --git a/src/components/Ship/KeyboardShortcuts.tsx b/src/components/Ship/KeyboardShortcuts.tsx new file mode 100644 index 0000000..e965db5 --- /dev/null +++ b/src/components/Ship/KeyboardShortcuts.tsx @@ -0,0 +1,40 @@ +import {Dispatch, SetStateAction} from 'react' +import {Text} from 'ink' +import open from 'open' + +import {getShortAuthRequiredUrl} from '@cli/api/index.js' +import {Job} from '@cli/types/api.js' +import {useSafeInput} from '@cli/utils/index.js' + +interface KeyboardShortcutsProps { + onToggleJobLogs: Dispatch> + gameId?: string + jobs?: Job[] | null +} + +export const KeyboardShortcuts = ({onToggleJobLogs, gameId, jobs}: KeyboardShortcutsProps) => { + useSafeInput(async (input) => { + if (!gameId) return + const i = input.toLowerCase() + switch (i) { + case 'l': { + onToggleJobLogs((prev) => !prev) + break + } + + case 'b': { + const dashUrl = jobs?.length === 1 ? `/games/${gameId}/job/${jobs[0].id}` : `/games/${gameId}` + const url = await getShortAuthRequiredUrl(dashUrl) + await open(url) + break + } + } + }) + + return ( + <> + Press L to show and hide the job logs. + Press B to open the ShipThis dashboard in your browser. + + ) +} diff --git a/src/components/Ship/ShipResult.tsx b/src/components/Ship/ShipResult.tsx new file mode 100644 index 0000000..0af0a83 --- /dev/null +++ b/src/components/Ship/ShipResult.tsx @@ -0,0 +1,46 @@ +import {Box} from 'ink' + +import {WEB_URL} from '@cli/constants/config.js' +import {Job, ShipGameFlags} from '@cli/types/index.js' +import {getShortUUID} from '@cli/utils/index.js' + +import {Markdown, JobLogTail} from '@cli/components/index.js' + +interface ShipResultProps { + gameId: string + failedJobs: Job[] | null + gameFlags: ShipGameFlags | null +} + +export const ShipResult = ({gameId, failedJobs, gameFlags}: ShipResultProps) => { + return ( + failedJobs && ( + <> + {failedJobs.length === 0 && ( + + )} + {failedJobs.length > 0 && ( + <> + + + {failedJobs.map((fj) => ( + + ))} + + + )} + + ) + ) +} diff --git a/src/components/Ship/index.tsx b/src/components/Ship/index.tsx new file mode 100644 index 0000000..93def9c --- /dev/null +++ b/src/components/Ship/index.tsx @@ -0,0 +1,100 @@ +import {Box, Text} from 'ink' +import {useContext, useEffect, useState} from 'react' + +import {CommandContext, GameContext, JobFollow, JobLogTail, JobProgress, JobStatusTable} from '@cli/components/index.js' +import {Job, JobStatus, ShipGameFlags} from '@cli/types/index.js' +import {useProjectJobListener, useStartShipOnMount} from '@cli/utils/hooks/index.js' + +import {KeyboardShortcuts} from './KeyboardShortcuts.js' +import {ShipResult} from './ShipResult.js' + +interface Props { + onComplete: (completedJobs: Job[]) => void + onError: (error: any) => void +} + +export const Ship = ({onComplete, onError}: Props): JSX.Element | null => { + const {command} = useContext(CommandContext) + const {gameId} = useContext(GameContext) + if (!command || !gameId) return null + return +} + +interface ShipCommandProps extends Props { + command: any + gameId: string +} + +const ShipCommand = ({onComplete, onError, command, gameId}: ShipCommandProps) => { + const [showLog, setShowLog] = useState(false) + const flags = command && (command.getFlags() as ShipGameFlags) + const {jobs: startedJobs, shipLog} = useStartShipOnMount(command, flags, onError) + const [failedJobs, setFailedJobs] = useState([]) + const [successJobs, setSuccessJobs] = useState([]) + const [isComplete, setIsComplete] = useState(false) + + const handleJobCompleted = (job: Job) => setSuccessJobs((prev) => [...prev, job]) + const handleJobFailed = (job: Job) => setFailedJobs((prev) => [...prev, job]) + + const {jobsById} = useProjectJobListener({ + projectId: gameId, + onJobCompleted: handleJobCompleted, + onJobFailed: handleJobFailed, + }) + + useEffect(() => { + // Detect when all jobs done and trigger onComplete or onError + const totalCompleted = successJobs.length + failedJobs.length + if (startedJobs && totalCompleted === startedJobs.length) { + setIsComplete(true) + setTimeout(() => { + const didFail = failedJobs.length > 0 + if (didFail) { + onError(new Error('One or more jobsById failed.')) + } else { + onComplete(successJobs) + } + }, 500) + } + }, [successJobs, failedJobs, startedJobs]) + + // Use startedJobs just for the ids - the "live" objects come from jobsById + const inProgressJobs = startedJobs + ?.map((startedJob) => jobsById[startedJob.id]) + .filter((job) => job !== undefined) + .filter((job) => job && [JobStatus.PENDING, JobStatus.PROCESSING].includes(job.status)) + + if (flags?.follow) { + if (startedJobs && startedJobs.length > 0) { + return + } + return <> + } + + return ( + + {startedJobs === null && {shipLog}} + {inProgressJobs && + inProgressJobs.map((job) => ( + + + + + + {showLog && ( + + + + )} + + ))} + {jobsById && !isComplete && ( + <> + + Please wait while ShipThis builds your game... + + )} + {isComplete && } + + ) +} diff --git a/src/components/android/ConnectGoogle/GoogleAuthQRCode.tsx b/src/components/android/ConnectGoogle/GoogleAuthQRCode.tsx index 908c353..1a805b1 100644 --- a/src/components/android/ConnectGoogle/GoogleAuthQRCode.tsx +++ b/src/components/android/ConnectGoogle/GoogleAuthQRCode.tsx @@ -29,5 +29,5 @@ export const GoogleAuthQRCode = ({gameId, helpPage}: GoogleAuthQRCodeProps) => { handleLoad() }, []) - return <>{url && } + return <>{url && } } diff --git a/src/components/common/QRCodeTerminal.tsx b/src/components/common/QRCodeTerminal.tsx index e14d837..f043dc1 100644 --- a/src/components/common/QRCodeTerminal.tsx +++ b/src/components/common/QRCodeTerminal.tsx @@ -3,11 +3,11 @@ import qrcode from 'qrcode' import {useEffect, useState} from 'react' // A QR code that can be displayed in the terminal -export const QRCodeTerminal = ({url}: {url: string}) => { +export const QRCodeTerminal = ({data}: {data: string}) => { const [code, setCode] = useState(null) const handleLoad = async () => { - const codeString = await qrcode.toString(url, {errorCorrectionLevel: 'L', small: true, type: 'terminal'}) + const codeString = await qrcode.toString(data, {errorCorrectionLevel: 'L', small: true, type: 'terminal'}) setCode(codeString) } diff --git a/src/components/index.tsx b/src/components/index.tsx index c0dffcb..6412a0b 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -5,7 +5,7 @@ export * from './JobLogTail.js' export * from './JobStatusTable.js' export * from './ProjectCredentialsTable.js' -export * from './Ship.js' +export * from './Ship/index.js' export * from './UserCredentialsTable.js' export * from './android/index.js' export * from './apple/index.js' diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index c33f8b5..ad18ad5 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -2,6 +2,8 @@ export * from './useAndroidServiceAccount.js' export * from './useGoogleStatusWatching.js' export * from './useJobLogTail.js' export * from './useJobWatching.js' +export * from './useProjectJobListener.js' export * from './useResponsive.js' export * from './useSafeInput.js' +export * from './useStartShipOnMount.js' export * from './useWebSocket.js' \ No newline at end of file diff --git a/src/utils/hooks/useProjectJobListener.ts b/src/utils/hooks/useProjectJobListener.ts new file mode 100644 index 0000000..d94dc7b --- /dev/null +++ b/src/utils/hooks/useProjectJobListener.ts @@ -0,0 +1,69 @@ +import {useRef, useState} from 'react' + +import {Job, JobStatus} from '@cli/types' +import {castJobDates} from '@cli/utils/dates.js' +import {WebSocketListener, useWebSocket} from './useWebSocket.js' + +export interface ProjectJobListenerProps { + projectId: string + onJobCreated?: (j: Job) => void + onJobUpdated?: (j: Job) => void + onJobCompleted?: (j: Job) => void + onJobFailed?: (j: Job) => void +} + +type JobsById = Record +type JobStatusById = Record + +export interface ProjectJobListenerResult { + jobsById: JobsById +} + +// Listens for all job updates related to a specific project. +// Does not need a jobId +export function useProjectJobListener({ + projectId, + onJobCreated, + onJobUpdated, + onJobCompleted, + onJobFailed, +}: ProjectJobListenerProps): ProjectJobListenerResult { + const [jobsById, setJobsById] = useState({}) + const prevJobStatuses = useRef({}) + + const handleJobUpdate = (job: Job) => { + const completed = new Set([JobStatus.COMPLETED, JobStatus.FAILED]) + const wasRunning = !completed.has(prevJobStatuses.current[job.id]) + if (completed.has(job.status) && wasRunning) { + if (job.status === JobStatus.FAILED) { + onJobFailed && onJobFailed(job) + } else { + onJobCompleted && onJobCompleted(job) + } + } + prevJobStatuses.current[job.id] = job.status + } + + const jobCreatedListener: WebSocketListener = { + async eventHandler(pattern: string, rawJob: any) { + const job = castJobDates(rawJob) + setJobsById((prev) => ({...prev, [job.id]: job})) + if (onJobCreated) onJobCreated(job) + }, + getPattern: () => [`project.${projectId}:job:created`], + } + + const jobUpdatedListener: WebSocketListener = { + async eventHandler(pattern: string, rawJob: any) { + const job = castJobDates(rawJob) + setJobsById((prev) => ({...prev, [job.id]: job})) + if (onJobUpdated) onJobUpdated(job) + handleJobUpdate(job) + }, + getPattern: () => [`project.${projectId}:job:updated`], + } + + useWebSocket([jobCreatedListener, jobUpdatedListener]) + + return {jobsById} +} diff --git a/src/utils/hooks/useStartShipOnMount.ts b/src/utils/hooks/useStartShipOnMount.ts new file mode 100644 index 0000000..6630980 --- /dev/null +++ b/src/utils/hooks/useStartShipOnMount.ts @@ -0,0 +1,27 @@ +import {useEffect, useState} from 'react' +import {Job} from '@cli/types/index.js' +import {useShip} from '@cli/utils/index.js' + +export const useStartShipOnMount = (command: any, flags: any, onError: (e: any) => void) => { + const shipMutation = useShip() + + const [shipLog, setShipLog] = useState('') + const [jobs, setJobs] = useState(null) + + // Start the command on mount + const handleStartOnMount = async () => { + if (!command) throw new Error('No command in context') + const logFn = flags?.follow ? console.log : setShipLog + const startedJobs = await shipMutation.mutateAsync({command, log: logFn, shipFlags: flags}) + setJobs(startedJobs) + } + + useEffect(() => { + handleStartOnMount().catch(onError) + }, []) + + return { + jobs, + shipLog, + } +} \ No newline at end of file diff --git a/test/commands/game/go.test.ts b/test/commands/game/go.test.ts new file mode 100644 index 0000000..aac4337 --- /dev/null +++ b/test/commands/game/go.test.ts @@ -0,0 +1,14 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' + +describe('game:go', () => { + it('runs game:go cmd', async () => { + const {stdout} = await runCommand('game:go') + expect(stdout).to.contain('hello world') + }) + + it('runs game:go --name oclif', async () => { + const {stdout} = await runCommand('game:go --name oclif') + expect(stdout).to.contain('hello oclif') + }) +}) From 703950077a5a21add96c9369b8d1fa3371f191bd Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Fri, 24 Oct 2025 15:47:28 +0100 Subject: [PATCH 4/8] Only look at jobs we started --- src/components/Go.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Go.tsx b/src/components/Go.tsx index f2cf496..c6b0d8a 100644 --- a/src/components/Go.tsx +++ b/src/components/Go.tsx @@ -28,6 +28,7 @@ const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX. const {jobs: startedJobs} = useStartShipOnMount(command, flags, onError) const handleJobCompleted = async (job: any) => { + if (job.id !== startedJobs?.[0].id) return const [goBuild] = await getJobBuildsRetry(job.id, command.getGameId()) setQRCodeData(getShortUUID(goBuild.id)) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -36,6 +37,7 @@ const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX. } const handleJobFailed = (job: any) => { + if (job.id !== startedJobs?.[0].id) return onError(new Error(`Go job failed: ${job.id}`)) } From c79b0aedb86b573723f58bc57cc5ad1027333ab7 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Fri, 7 Nov 2025 15:40:28 +0000 Subject: [PATCH 5/8] Fixing bug where QR code was never shown for Go job --- src/components/Go.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/Go.tsx b/src/components/Go.tsx index c6b0d8a..4ff450d 100644 --- a/src/components/Go.tsx +++ b/src/components/Go.tsx @@ -4,6 +4,7 @@ import {getShortUUID, useProjectJobListener, useStartShipOnMount} from '@cli/uti import {getJobBuildsRetry} from '@cli/api/index.js' import {CommandContext, GameContext, JobProgress, QRCodeTerminal} from './index.js' +import { Job, Platform } from '@cli/types/api.js' interface Props { onComplete: () => void @@ -27,8 +28,8 @@ const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX. const {jobs: startedJobs} = useStartShipOnMount(command, flags, onError) - const handleJobCompleted = async (job: any) => { - if (job.id !== startedJobs?.[0].id) return + const handleJobCompleted = async (job: Job) => { + if (job.type != Platform.GO) return const [goBuild] = await getJobBuildsRetry(job.id, command.getGameId()) setQRCodeData(getShortUUID(goBuild.id)) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -37,7 +38,7 @@ const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX. } const handleJobFailed = (job: any) => { - if (job.id !== startedJobs?.[0].id) return + if (job.type != Platform.GO) return onError(new Error(`Go job failed: ${job.id}`)) } From 7017129b51f21c608365303fb76304a3f52fec1c Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Wed, 12 Nov 2025 15:01:10 +0000 Subject: [PATCH 6/8] Prototype of showing log messages coming from the phone --- src/components/Go.tsx | 29 ++++++++++++++++------ src/utils/hooks/useGoRuntimeLogListener.ts | 17 +++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 src/utils/hooks/useGoRuntimeLogListener.ts diff --git a/src/components/Go.tsx b/src/components/Go.tsx index 4ff450d..684d730 100644 --- a/src/components/Go.tsx +++ b/src/components/Go.tsx @@ -4,14 +4,15 @@ import {getShortUUID, useProjectJobListener, useStartShipOnMount} from '@cli/uti import {getJobBuildsRetry} from '@cli/api/index.js' import {CommandContext, GameContext, JobProgress, QRCodeTerminal} from './index.js' -import { Job, Platform } from '@cli/types/api.js' +import {Job, Platform} from '@cli/types/api.js' +import {useGoRuntimeLogListener} from '@cli/utils/hooks/useGoRuntimeLogListener.js' interface Props { onComplete: () => void onError: (error: any) => void } -export const Go = ({onComplete, onError}: Props): JSX.Element| null => { +export const Go = ({onComplete, onError}: Props): JSX.Element | null => { const {command} = useContext(CommandContext) const {gameId} = useContext(GameContext) if (!command || !gameId) return null @@ -23,18 +24,27 @@ interface GoCommandProps extends Props { gameId: string } -const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX.Element | null=> { +const LogListener = ({projectId, buildId}: {projectId: string; buildId: string}) => { + useGoRuntimeLogListener({projectId, buildId}) + return null +} + +const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX.Element | null => { const flags = {follow: false, platform: 'go'} + const [buildId, setBuildId] = useState(null) + const [qrCodeData, setQRCodeData] = useState(null) + const {jobs: startedJobs} = useStartShipOnMount(command, flags, onError) const handleJobCompleted = async (job: Job) => { if (job.type != Platform.GO) return const [goBuild] = await getJobBuildsRetry(job.id, command.getGameId()) + setBuildId(goBuild.id) setQRCodeData(getShortUUID(goBuild.id)) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) await sleep(500) - onComplete() + //onComplete() } const handleJobFailed = (job: any) => { @@ -48,10 +58,13 @@ const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX. onJobFailed: handleJobFailed, }) - const [qrCodeData, setQRCodeData] = useState(null) - - if (qrCodeData) { - return + if (qrCodeData && buildId) { + return ( + <> + + + + ) } if (startedJobs && startedJobs?.length > 0) { diff --git a/src/utils/hooks/useGoRuntimeLogListener.ts b/src/utils/hooks/useGoRuntimeLogListener.ts new file mode 100644 index 0000000..a47ddbe --- /dev/null +++ b/src/utils/hooks/useGoRuntimeLogListener.ts @@ -0,0 +1,17 @@ +import {WebSocketListener, useWebSocket} from './useWebSocket.js' + +export interface GoRuntimeLogListenerProps { + projectId: string + buildId: string +} + +export function useGoRuntimeLogListener({projectId, buildId}: GoRuntimeLogListenerProps) { + const listener: WebSocketListener = { + async eventHandler(pattern: string, rawLog: any) { + console.log(`[Go Runtime Log] ${JSON.stringify(rawLog)}`) + }, + getPattern: () => [`project.${projectId}:build.${buildId}:runtime-log`], + } + + useWebSocket([listener]) +} From 09754c51ca1bf1c9cb0bc09ab6d96fc1dd561409 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Thu, 13 Nov 2025 15:48:33 +0000 Subject: [PATCH 7/8] Better formatting of runtime logs. Typing logs. Fixing ShipResult bug --- src/components/Go.tsx | 50 ++++++++++++++++++---- src/components/Ship/ShipResult.tsx | 1 + src/types/api.ts | 16 +++++++ src/utils/hooks/index.ts | 1 + src/utils/hooks/useGoRuntimeLogListener.ts | 35 +++++++++++++-- src/utils/index.ts | 14 +++++- 6 files changed, 104 insertions(+), 13 deletions(-) diff --git a/src/components/Go.tsx b/src/components/Go.tsx index 684d730..4dff688 100644 --- a/src/components/Go.tsx +++ b/src/components/Go.tsx @@ -1,11 +1,19 @@ import {useContext, useState} from 'react' +import {Box, Text} from 'ink' -import {getShortUUID, useProjectJobListener, useStartShipOnMount} from '@cli/utils/index.js' +import {TruncatedText} from './common/TruncatedText.js' +import { + getRuntimeLogLevelColor, + getShortTime, + getShortUUID, + useGoRuntimeLogListener, + useProjectJobListener, + useStartShipOnMount, +} from '@cli/utils/index.js' import {getJobBuildsRetry} from '@cli/api/index.js' -import {CommandContext, GameContext, JobProgress, QRCodeTerminal} from './index.js' +import {CommandContext, GameContext, JobProgress, QRCodeTerminal, Title} from './index.js' import {Job, Platform} from '@cli/types/api.js' -import {useGoRuntimeLogListener} from '@cli/utils/hooks/useGoRuntimeLogListener.js' interface Props { onComplete: () => void @@ -25,8 +33,27 @@ interface GoCommandProps extends Props { } const LogListener = ({projectId, buildId}: {projectId: string; buildId: string}) => { - useGoRuntimeLogListener({projectId, buildId}) - return null + const {tail, messages} = useGoRuntimeLogListener({projectId, buildId}) + + return ( + <> + + {messages.map((log, i) => { + const messageColor = getRuntimeLogLevelColor(log.level) + return ( + + + {getShortTime(log.sentAt)} + + + {log.message} + + + ) + })} + + + ) } const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX.Element | null => { @@ -60,15 +87,22 @@ const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX. if (qrCodeData && buildId) { return ( - <> + + Go Build QR Code + {`Go build ID: ${getShortUUID(buildId)}`} - + ) } if (startedJobs && startedJobs?.length > 0) { - return + return ( + + Generating Go build, please wait... + + + ) } return null diff --git a/src/components/Ship/ShipResult.tsx b/src/components/Ship/ShipResult.tsx index 0af0a83..596e8ae 100644 --- a/src/components/Ship/ShipResult.tsx +++ b/src/components/Ship/ShipResult.tsx @@ -22,6 +22,7 @@ export const ShipResult = ({gameId, failedJobs, gameFlags}: ShipResultProps) => templateVars={{ gameBuildsUrl: `${WEB_URL}games/${getShortUUID(gameId)}/builds`, wasPublished: !gameFlags?.skipPublish, + usedDemoCredentials: !!gameFlags?.useDemoCredentials, }} /> )} diff --git a/src/types/api.ts b/src/types/api.ts index 587c70f..06b3559 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -271,3 +271,19 @@ export interface TermsResponse { changes: AgreementVersion[] current: AgreementVersion[] } + +export enum RuntimeLogLevel { + VERBOSE = 'VERBOSE', + DEBUG = 'DEBUG', + INFO = 'INFO', + WARNING = 'WARNING', + ERROR = 'ERROR', +} + +export interface RuntimeLogEntry { + buildId: string + level: RuntimeLogLevel + message: string + details?: any + sentAt: DateTime +} \ No newline at end of file diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index ad18ad5..a32bcc3 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -1,5 +1,6 @@ export * from './useAndroidServiceAccount.js' export * from './useGoogleStatusWatching.js' +export * from './useGoRuntimeLogListener.js' export * from './useJobLogTail.js' export * from './useJobWatching.js' export * from './useProjectJobListener.js' diff --git a/src/utils/hooks/useGoRuntimeLogListener.ts b/src/utils/hooks/useGoRuntimeLogListener.ts index a47ddbe..61763a8 100644 --- a/src/utils/hooks/useGoRuntimeLogListener.ts +++ b/src/utils/hooks/useGoRuntimeLogListener.ts @@ -1,17 +1,44 @@ +import {useState} from 'react' +import {DateTime} from 'luxon' + import {WebSocketListener, useWebSocket} from './useWebSocket.js' +import {RuntimeLogEntry} from '@cli/types/api.js' -export interface GoRuntimeLogListenerProps { +interface Props { projectId: string buildId: string + tailLength?: number +} + +interface Response { + messages: RuntimeLogEntry[] + tail: RuntimeLogEntry[] } -export function useGoRuntimeLogListener({projectId, buildId}: GoRuntimeLogListenerProps) { +// Listens for Go runtime logs for a build via WebSocket and gives a tail +export function useGoRuntimeLogListener({projectId, buildId, tailLength = 10}: Props): Response { + const [messages, setMessages] = useState([]) + const [tail, setTail] = useState([]) + const listener: WebSocketListener = { - async eventHandler(pattern: string, rawLog: any) { - console.log(`[Go Runtime Log] ${JSON.stringify(rawLog)}`) + async eventHandler(_: string, rawLog: any) { + const log: RuntimeLogEntry = { + ...rawLog, + sentAt: DateTime.fromISO(rawLog.sentAt), + } + setMessages((prev) => [...prev, log]) + setTail((prev) => { + const next = [...prev, log] + if (next.length > tailLength) next.shift() + return next + }) + //console.log(log.message) }, + getPattern: () => [`project.${projectId}:build.${buildId}:runtime-log`], } useWebSocket([listener]) + + return {messages, tail} } diff --git a/src/utils/index.ts b/src/utils/index.ts index 6f6671b..5081672 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -6,7 +6,7 @@ import {fileURLToPath} from 'node:url' import readlineSync from 'readline-sync' -import {JobStage, JobStatus, LogLevel, Platform, ScalarDict} from '@cli/types' +import {JobStage, JobStatus, LogLevel, Platform, RuntimeLogLevel, ScalarDict} from '@cli/types' export * from './dates.js' export * from './dictionary.js' @@ -85,6 +85,18 @@ export function getJobStatusColor(status: JobStatus) { } } +// For the "Go" runtime logs +const RUNTIME_LOG_LEVEL_COLORS: Record = { + [RuntimeLogLevel.VERBOSE]: '#E8E8FF', + [RuntimeLogLevel.DEBUG]: '#E6F0FF', + [RuntimeLogLevel.INFO]: '#E6FFE6', + [RuntimeLogLevel.WARNING]: '#FFF6CC', + [RuntimeLogLevel.ERROR]: '#FFD6D6', +} + +export const getRuntimeLogLevelColor = (level: RuntimeLogLevel) => + RUNTIME_LOG_LEVEL_COLORS[level] + export async function getFileHash(filename: string): Promise { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256') From 365281c64d6ea24fa1cbd517d6bdb035990b0b89 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Wed, 10 Dec 2025 15:44:04 +0000 Subject: [PATCH 8/8] Much better performance rendering runtime logs with ref and queue of updates --- src/components/Go.tsx | 6 +-- src/types/api.ts | 1 + src/utils/hooks/useGoRuntimeLogListener.ts | 47 +++++++++++++++++----- src/utils/hooks/useWebSocket.ts | 21 ++++++---- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/components/Go.tsx b/src/components/Go.tsx index 4dff688..bcacd6b 100644 --- a/src/components/Go.tsx +++ b/src/components/Go.tsx @@ -33,7 +33,7 @@ interface GoCommandProps extends Props { } const LogListener = ({projectId, buildId}: {projectId: string; buildId: string}) => { - const {tail, messages} = useGoRuntimeLogListener({projectId, buildId}) + const {messages} = useGoRuntimeLogListener({projectId, buildId}) return ( <> @@ -87,7 +87,7 @@ const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX. if (qrCodeData && buildId) { return ( - + Go Build QR Code {`Go build ID: ${getShortUUID(buildId)}`} @@ -98,7 +98,7 @@ const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX. if (startedJobs && startedJobs?.length > 0) { return ( - + Generating Go build, please wait... diff --git a/src/types/api.ts b/src/types/api.ts index 06b3559..43e7aa1 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -286,4 +286,5 @@ export interface RuntimeLogEntry { message: string details?: any sentAt: DateTime + sequence: number // will be min of 1 } \ No newline at end of file diff --git a/src/utils/hooks/useGoRuntimeLogListener.ts b/src/utils/hooks/useGoRuntimeLogListener.ts index 61763a8..693f084 100644 --- a/src/utils/hooks/useGoRuntimeLogListener.ts +++ b/src/utils/hooks/useGoRuntimeLogListener.ts @@ -1,4 +1,4 @@ -import {useState} from 'react' +import {useEffect, useRef, useState} from 'react' import {DateTime} from 'luxon' import {WebSocketListener, useWebSocket} from './useWebSocket.js' @@ -8,6 +8,8 @@ interface Props { projectId: string buildId: string tailLength?: number + maxMessages?: number + flushIntervalMs?: number } interface Response { @@ -15,24 +17,26 @@ interface Response { tail: RuntimeLogEntry[] } -// Listens for Go runtime logs for a build via WebSocket and gives a tail -export function useGoRuntimeLogListener({projectId, buildId, tailLength = 10}: Props): Response { +// Saves incoming runtime log entries to a ref queue, and flushes them to state +// at a regular interval. This reduces the number of re-renders. +export function useGoRuntimeLogListener({ + projectId, + buildId, + tailLength = 10, + maxMessages = 500, + flushIntervalMs = 100, // 10fps +}: Props): Response { const [messages, setMessages] = useState([]) const [tail, setTail] = useState([]) + const queueRef = useRef([]) + const listener: WebSocketListener = { async eventHandler(_: string, rawLog: any) { - const log: RuntimeLogEntry = { + queueRef.current.push({ ...rawLog, sentAt: DateTime.fromISO(rawLog.sentAt), - } - setMessages((prev) => [...prev, log]) - setTail((prev) => { - const next = [...prev, log] - if (next.length > tailLength) next.shift() - return next }) - //console.log(log.message) }, getPattern: () => [`project.${projectId}:build.${buildId}:runtime-log`], @@ -40,5 +44,26 @@ export function useGoRuntimeLogListener({projectId, buildId, tailLength = 10}: P useWebSocket([listener]) + useEffect(() => { + const id = setInterval(() => { + const queued = queueRef.current + if (queued.length === 0) return + + queueRef.current = [] + + setMessages((prev) => { + const next = [...prev, ...queued] + return next.length > maxMessages ? next.slice(-maxMessages) : next + }) + + setTail((prev) => { + const next = [...prev, ...queued] + return next.length > tailLength ? next.slice(-tailLength) : next + }) + }, flushIntervalMs) + + return () => clearInterval(id) + }, [tailLength, maxMessages, flushIntervalMs]) + return {messages, tail} } diff --git a/src/utils/hooks/useWebSocket.ts b/src/utils/hooks/useWebSocket.ts index 9dcedf6..0c4a24f 100644 --- a/src/utils/hooks/useWebSocket.ts +++ b/src/utils/hooks/useWebSocket.ts @@ -1,4 +1,4 @@ -import {useEffect} from 'react' +import {useEffect, useRef} from 'react' import {io} from 'socket.io-client' import {getAuthToken} from '@cli/api/index.js' @@ -13,6 +13,7 @@ export interface WebSocketListener { export function useWebSocket(listeners: WebSocketListener[] = []) { const log = false ? console.debug : () => {} + const socketRef = useRef | null>(null) useEffect(() => { if (listeners.length === 0) { @@ -20,11 +21,13 @@ export function useWebSocket(listeners: WebSocketListener[] = []) { return } - const token = getAuthToken() - const socket = io(WS_URL, { - auth: {token}, - forceNew: true, - }) + if (!socketRef.current) { + const token = getAuthToken() + socketRef.current = io(WS_URL, { + auth: {token}, + }) + } + const socket = socketRef.current socket.on('connect', () => log('Connected to WebSocket')) @@ -43,10 +46,12 @@ export function useWebSocket(listeners: WebSocketListener[] = []) { bindSocket(pattern) } + }, []) + useEffect(() => { return () => { - log('Disconnecting from WebSocket') - socket.disconnect() + socketRef.current?.disconnect() + socketRef.current = null } }, []) }