Skip to content

Commit

Permalink
Offer option to upload logs to a pastebin
Browse files Browse the repository at this point in the history
When displaying a game log, a new button to directly upload the log file to a
pastebin service is now presented. This button will upload the log file to
`https://0x0.st` and copy the resulting sharable URL into the user's clipboard.
To alleviate privacy concerns, logs are (1) only kept valid for 24 hours and
(2) can be deleted from within Heroic:
In the "Settings" -> "Logs" tab specifically, a new button to view uploaded logs
was added. It lists all logs which are still valid, the user can then open the
sharable URL again or request file deletion

Behind the scenes, two new Zustand states have been added:
- `useGlobalState`, which is my attempt at slowly migrating the current Global
  State to Zustand. It holds global information (specifically, whether the
  log file upload dialog or the upload list dialog are open)
- `useUploadedLogFiles`, holding data on uploaded log files. The Backend pushes
  updates to this using two new Frontend messages
  • Loading branch information
CommandMC committed Aug 24, 2024
1 parent dbe780d commit 0f42c4d
Show file tree
Hide file tree
Showing 18 changed files with 651 additions and 66 deletions.
39 changes: 37 additions & 2 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -665,12 +665,47 @@
},
"library_top_section": "Library Top Section",
"log": {
"copy-to-clipboard": "Copy log content to clipboard",
"descriptiveNames": {
"game-log": "Game log of {{gameTitle}}",
"gog": "GOG log",
"heroic": "General Heroic log",
"legendary": "Epic Games / Legendary log",
"nile": "Amazon / Nile log"
},
"instructions": "Join our Discord and look for the \"#-support\" section. Read the pinned \"Read Me First | Frequently Asked Questions\" thread and follow the instructions to share these logs and any relevant information about your problem.",
"instructions_title": "How to report a problem?",
"join-heroic-discord": "Join our Discord",
"no-file": "No log file found",
"show-in-folder": "Show log file in folder"
"show-in-folder": "Show log file in folder",
"show-uploads": "Show uploaded log files",
"upload": {
"actions": "Actions",
"button": "Upload log file",
"confirm": {
"content": "Do you really want to upload \"{{name}}\"?",
"title": "Upload log file?"
},
"delete": "Request log file deletion",
"done": {
"content": "Uploaded to {{url}} (URL copied to your clipboard)",
"title": "Upload complete"
},
"error": {
"content": "Failed to upload log file. Check Heroic's general log for details",
"title": "Upload failed"
},
"header": "Uploaded log files",
"hours-ago": "Uploaded {{hoursAgo, relativetime(hours)}}",
"minutes-ago": "Uploaded {{minutesAgo, relativetime(minutes)}}",
"no-files": "No log files were uploaded",
"open": "Open log file upload",
"title": "Log title",
"upload-date": "Upload date",
"uploading": {
"content": "Uploading log file...",
"title": "Uploading"
}
}
},
"mangohud": "Enable Mangohud (Mangohud needs to be installed)",
"manualsync": {
Expand Down
26 changes: 25 additions & 1 deletion src/backend/api/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
Tools,
DialogType,
ButtonOptions,
GamepadActionArgs
GamepadActionArgs,
type UploadedLogData
} from 'common/types'
import { NileRegisterData } from 'common/types/nile'

Expand Down Expand Up @@ -208,3 +209,26 @@ export const fetchPlaytimeFromServer = async (
runner: Runner,
appName: string
) => ipcRenderer.invoke('getPlaytimeFromRunner', runner, appName)

export const getUploadedLogFiles = async () =>
ipcRenderer.invoke('getUploadedLogFiles')
export const uploadLogFile = async (name: string, appNameOrRunner: string) =>
ipcRenderer.invoke('uploadLogFile', name, appNameOrRunner)
export const deleteUploadedLogFile = async (url: string) =>
ipcRenderer.invoke('deleteUploadedLogFile', url)

export const logFileUploadedSlot = (
callback: (
e: Electron.IpcRendererEvent,
url: string,
data: UploadedLogData
) => void
) => {
ipcRenderer.on('logFileUploaded', callback)
}

export const logFileUploadDeletedSlot = (
callback: (e: Electron.IpcRendererEvent, url: string) => void
) => {
ipcRenderer.on('logFileUploadDeleted', callback)
}
4 changes: 4 additions & 0 deletions src/backend/electron_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,8 @@ export class TypeCheckedStoreBackend<
public clear() {
this.store.clear()
}

public get raw_store() {
return this.store.store as StoreStructure[Name]
}
}
13 changes: 13 additions & 0 deletions src/backend/logger/ipc_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { ipcMain } from 'electron'
import { existsSync, readFileSync } from 'graceful-fs'
import { showItemInFolder } from '../utils'
import { getLogFile } from './logfile'
import {
uploadLogFile,
deleteUploadedLogFile,
getUploadedLogFiles
} from './uploader'

ipcMain.handle('getLogContent', (event, appNameOrRunner) => {
const logPath = getLogFile(appNameOrRunner)
Expand All @@ -11,3 +16,11 @@ ipcMain.handle('getLogContent', (event, appNameOrRunner) => {
ipcMain.on('showLogFileInFolder', async (e, appNameOrRunner) =>
showItemInFolder(getLogFile(appNameOrRunner))
)

ipcMain.handle('uploadLogFile', async (e, name, appNameOrRunner) =>
uploadLogFile(name, appNameOrRunner)
)
ipcMain.handle('deleteUploadedLogFile', async (e, url) =>
deleteUploadedLogFile(url)
)
ipcMain.handle('getUploadedLogFiles', async () => getUploadedLogFiles())
3 changes: 2 additions & 1 deletion src/backend/logger/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export enum LogPrefix {
Connection = 'Connection',
DownloadManager = 'DownloadManager',
ExtraGameInfo = 'ExtraGameInfo',
Sideload = 'Sideload'
Sideload = 'Sideload',
LogUploader = 'LogUploader'
}

export const RunnerToLogPrefixMap = {
Expand Down
135 changes: 135 additions & 0 deletions src/backend/logger/uploader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { app } from 'electron'
import { readFile } from 'fs/promises'
import path from 'path'
import { z } from 'zod'

import { TypeCheckedStoreBackend } from '../electron_store'
import { getLogFile } from './logfile'
import { logError, logInfo, LogPrefix } from './logger'
import { sendFrontendMessage } from '../main_window'

import type { UploadedLogData } from 'common/types'

const uploadedLogFileStore = new TypeCheckedStoreBackend('uploadedLogs', {
cwd: 'store',
name: 'uploadedLogs',
accessPropertiesByDotNotation: false
})

async function sendRequestToApi(formData: FormData, url = 'https://0x0.st') {
return fetch(url, {
body: formData,
method: 'post',
headers: {
'User-Agent': `HeroicGamesLauncher/${app.getVersion()}`
}
}).catch((err) => {
logError([`Failed to send data to 0x0.st:`, err], LogPrefix.LogUploader)
return null
})
}

/**
* Uploads the log file of a game / runner / Heroic to https://0x0.st
* @param name See {@link UploadedLogData.name}
* @param appNameOrRunner Used to get the log file path. See {@link getLogFile}
* @returns `false` if an error occurred, otherwise the URL to the uploaded file and {@link UploadedLogData}
*/
async function uploadLogFile(
name: string,
appNameOrRunner: string
): Promise<false | [string, UploadedLogData]> {
const fileLocation = getLogFile(appNameOrRunner)
const filename = path.basename(fileLocation)

const fileContents = await readFile(fileLocation)
const fileBlob = new Blob([fileContents])

const formData = new FormData()
formData.set('file', fileBlob, filename)
formData.set('expires', '24')

const response = await sendRequestToApi(formData)
if (!response) return false

const token = response.headers.get('X-Token')
const responseText = (await response.text()).trim()
const maybeUrl = z.string().url().safeParse(responseText)
if (!response.ok || !token || !maybeUrl.success) {
logError(
[
`Failed to upload log file, response error code ${response.status} ${response.statusText}, text:`,
responseText
],
LogPrefix.LogUploader
)
return false
}

const url = maybeUrl.data
const uploadData: UploadedLogData = {
name,
token,
uploadedAt: Date.now()
}
uploadedLogFileStore.set(url, uploadData)
sendFrontendMessage('logFileUploaded', url, uploadData)
logInfo(`Uploaded log file ${name} to ${url}`, LogPrefix.LogUploader)
return [url, uploadData]
}

/**
* Deletes a log file previously uploaded using {@link uploadLogFile}
* @param url The URL to the uploaded log file
* @returns Whether the file was deleted
*/
async function deleteUploadedLogFile(url: string): Promise<boolean> {
const logData = uploadedLogFileStore.get_nodefault(url)
if (!logData) return false

const formData = new FormData()
formData.set('token', logData.token)
formData.set('delete', '')

const response = await sendRequestToApi(formData, url)
if (!response) return false

if (!response.ok) {
logError(
[
`Failed to delete log file upload, response error code ${response.status} ${response.statusText}, text:`,
await response.text()
],
LogPrefix.LogUploader
)
return false
}

uploadedLogFileStore.delete(url)
sendFrontendMessage('logFileUploadDeleted', url)
logInfo([`Deleted log file ${logData.name} (${url})`], LogPrefix.LogUploader)

return true
}

/**
* Returns all log files which are still valid. Also deletes expired
* ones from {@link uploadedLogFileStore}
*/
async function getUploadedLogFiles(): Promise<Record<string, UploadedLogData>> {
const allStoredLogs = uploadedLogFileStore.raw_store
const validUploadedLogs: Record<string, UploadedLogData> = {}
// Filter and delete expired logs
for (const [key, value] of Object.entries(allStoredLogs)) {
const timeDifferenceMs = Date.now() - value.uploadedAt
const timeDifferenceHours = timeDifferenceMs / 1000 / 60 / 60
if (timeDifferenceHours >= 24) {
uploadedLogFileStore.delete(key)
} else {
validUploadedLogs[key] = value
}
}
return validUploadedLogs
}

export { uploadLogFile, deleteUploadedLogFile, getUploadedLogFiles }
10 changes: 9 additions & 1 deletion src/common/typedefs/ipcBridge.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import {
LaunchOption,
DownloadManagerState,
InstallInfo,
WikiInfo
WikiInfo,
UploadedLogData
} from 'common/types'
import { GameOverride, SelectiveDownload } from 'common/types/legendary'
import { GOGCloudSavesLocation } from 'common/types/gog'
Expand Down Expand Up @@ -297,6 +298,13 @@ interface AsyncIPCFunctions {
enabled: boolean
modsToLoad: string[]
}) => Promise<void>

uploadLogFile: (
name: string,
appNameOrRunner: string
) => Promise<false | [string, UploadedLogData]>
deleteUploadedLogFile: (url: string) => Promise<boolean>
getUploadedLogFiles: () => Promise<Record<string, UploadedLogData>>
}

// This is quite ugly & throws a lot of errors in a regular .ts file
Expand Down
9 changes: 9 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,3 +767,12 @@ export interface KnowFixesInfo {
winetricks?: string[]
runInPrefix?: string[]
}

export interface UploadedLogData {
// Descriptive name of the log file (e.g. "Game log of ...")
name: string
// Token to modify the file (used to delete the log file on the server)
token: string
// Time the log file was uploaded (used to know whether it expired)
uploadedAt: number
}
4 changes: 3 additions & 1 deletion src/common/types/electron_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
AppSettings,
WikiInfo,
GameInfo,
WindowProps
WindowProps,
UploadedLogData
} from 'common/types'
import { UserData } from 'common/types/gog'
import { NileUserData } from './nile'
Expand Down Expand Up @@ -97,6 +98,7 @@ export interface StoreStructure {
wikigameinfo: {
[title: string]: WikiInfo
}
uploadedLogs: Record<string, UploadedLogData>
}

export type StoreOptions<T extends Record<string, unknown>> = Store.Options<T>
Expand Down
3 changes: 3 additions & 0 deletions src/common/types/frontend_messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
GameStatus,
RecentGame,
Runner,
UploadedLogData,
WineManagerStatus
} from 'common/types'

Expand Down Expand Up @@ -43,6 +44,8 @@ type FrontendMessages = {
}) => void
progressOfWineManager: (version: string, progress: WineManagerStatus) => void
'installing-winetricks-component': (component: string) => void
logFileUploaded: (url: string, data: UploadedLogData) => void
logFileUploadDeleted: (url: string) => void

[key: `progressUpdate${string}`]: (progress: GameStatus) => void

Expand Down
4 changes: 4 additions & 0 deletions src/frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import ExternalLinkDialog from './components/UI/ExternalLinkDialog'
import WindowControls from './components/UI/WindowControls'
import classNames from 'classnames'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import LogFileUploadDialog from './components/UI/LogFileUploadDialog'
import UploadedLogFilesList from './screens/Settings/sections/LogSettings/components/UploadedLogFilesList'

function Root() {
const {
Expand Down Expand Up @@ -58,6 +60,8 @@ function Root() {
/>
)}
<ExternalLinkDialog />
<LogFileUploadDialog />
<UploadedLogFilesList />
<Outlet />
</main>
<div className="controller">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { ReactNode } from 'react'

interface DialogHeaderProps {
onClose: () => void
onClose?: () => void
children: ReactNode
}

Expand Down
Loading

0 comments on commit 0f42c4d

Please sign in to comment.