diff --git a/assets/locales/en.json b/assets/locales/en.json index 0cc80df56..a2981f14a 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -225,5 +225,14 @@ "message": "Ongoing operation is taking more resources than expected. Do you wish to abort, and forcefully reload the interface?", "forceReload": "Yes, reload", "doNothing": "Do nothing" + }, + "migrationDialog": { + "title": "IPFS Desktop Migration", + "message": "One moment! IPFS Desktop needs to run the latest data store migrations:", + "closeAndContinue": "Continue in the background" + }, + "migrationFailedDialog": { + "title": "IPFS Desktop Migration Has Failed", + "message": "IPFS has encountered an error and migration could not be completed:" } } diff --git a/package-lock.json b/package-lock.json index 4813bdc6b..0958b9d23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "electron-updater": "4.6.1", "fix-path": "3.0.0", "fs-extra": "^10.0.0", - "go-ipfs": "0.11.0", + "go-ipfs": "0.12.0", "i18next": "^21.6.10", "i18next-electron-language-detector": "0.0.10", "i18next-fs-backend": "1.1.4", @@ -5757,13 +5757,12 @@ } }, "node_modules/go-ipfs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/go-ipfs/-/go-ipfs-0.11.0.tgz", - "integrity": "sha512-l6uZTeEZYQQpKsoI8DXV5j1UDiq7xTIwNyp5lGJmY1cR9LoWHR4lIhbSpc2DfvOv5Ly/+BC/CLvo7vIFmdmyGQ==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/go-ipfs/-/go-ipfs-0.12.0.tgz", + "integrity": "sha512-rZbU4PsXbqzdqgH57G48MvLveV4i8StKPUHr49Kepm/z+/qwhg8jzHQVpiip/+28qd2gy3Cgiw1MremegHRvPw==", "hasInstallScript": true, "dependencies": { "cachedir": "^2.3.0", - "go-platform": "^1.0.0", "got": "^11.7.0", "gunzip-maybe": "^1.4.2", "hasha": "^5.2.2", @@ -5962,14 +5961,6 @@ "lowercase-keys": "^2.0.0" } }, - "node_modules/go-platform": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/go-platform/-/go-platform-1.0.0.tgz", - "integrity": "sha1-sF/2uSdAB9JGsWQjXwP39qWWJsc=", - "bin": { - "go-platform": "cli.js" - } - }, "node_modules/got": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/got/-/got-12.0.1.tgz", @@ -17510,12 +17501,11 @@ } }, "go-ipfs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/go-ipfs/-/go-ipfs-0.11.0.tgz", - "integrity": "sha512-l6uZTeEZYQQpKsoI8DXV5j1UDiq7xTIwNyp5lGJmY1cR9LoWHR4lIhbSpc2DfvOv5Ly/+BC/CLvo7vIFmdmyGQ==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/go-ipfs/-/go-ipfs-0.12.0.tgz", + "integrity": "sha512-rZbU4PsXbqzdqgH57G48MvLveV4i8StKPUHr49Kepm/z+/qwhg8jzHQVpiip/+28qd2gy3Cgiw1MremegHRvPw==", "requires": { "cachedir": "^2.3.0", - "go-platform": "^1.0.0", "got": "^11.7.0", "gunzip-maybe": "^1.4.2", "hasha": "^5.2.2", @@ -17656,11 +17646,6 @@ } } }, - "go-platform": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/go-platform/-/go-platform-1.0.0.tgz", - "integrity": "sha1-sF/2uSdAB9JGsWQjXwP39qWWJsc=" - }, "got": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/got/-/got-12.0.1.tgz", diff --git a/package.json b/package.json index 475bd47c3..0b3020ed6 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "electron-updater": "4.6.1", "fix-path": "3.0.0", "fs-extra": "^10.0.0", - "go-ipfs": "0.11.0", + "go-ipfs": "0.12.0", "i18next": "^21.6.10", "i18next-electron-language-detector": "0.0.10", "i18next-fs-backend": "1.1.4", diff --git a/src/daemon/daemon.js b/src/daemon/daemon.js index ef87f9ae5..693ed7b72 100644 --- a/src/daemon/daemon.js +++ b/src/daemon/daemon.js @@ -2,8 +2,9 @@ const Ctl = require('ipfsd-ctl') const i18n = require('i18next') const { showDialog } = require('../dialogs') const logger = require('../common/logger') -const { applyDefaults, migrateConfig, checkCorsConfig, checkPorts, configExists, rmApiFile, apiFileExists } = require('./config') const { getCustomBinary } = require('../custom-ipfs-binary') +const { applyDefaults, migrateConfig, checkCorsConfig, checkPorts, configExists, rmApiFile, apiFileExists } = require('./config') +const showMigrationPrompt = require('./migration-prompt') function cannotConnectDialog (addr) { showDialog({ @@ -57,29 +58,110 @@ async function spawn ({ flags, path }) { return { ipfsd, isRemote: false } } -module.exports = async function (opts) { - const { ipfsd, isRemote } = await spawn(opts) - if (!isRemote) await checkPorts(ipfsd) +function listenToIpfsLogs (ipfsd, callback) { + let stdout, stderr + + const listener = data => { + callback(data.toString()) + } + + const interval = setInterval(() => { + if (!ipfsd.subprocess) { + return + } + + stdout = ipfsd.subprocess.stdout + stderr = ipfsd.subprocess.stderr + + stdout.on('data', listener) + stderr.on('data', listener) + + clearInterval(interval) + }, 20) + + const stop = () => { + clearInterval(interval) + + if (stdout) stdout.removeListener('data', listener) + if (stderr) stderr.removeListener('data', listener) + } + + return stop +} + +async function startIpfsWithLogs (ipfsd) { + let err, id, migrationPrompt + let isMigrating, isErrored, isFinished + let logs = '' + + const stopListening = listenToIpfsLogs(ipfsd, data => { + logs += data.toString() + + isMigrating = isMigrating || logs.toLowerCase().includes('migration') + isErrored = isErrored || logs.toLowerCase().includes('error') + isFinished = isFinished || logs.toLowerCase().includes('daemon is ready') + + if (!isMigrating) { + return + } + + if (!migrationPrompt) { + logger.info('[daemon] ipfs data store is migrating') + migrationPrompt = showMigrationPrompt(logs, isErrored, isFinished) + return + } + + if (isErrored || isFinished) { + // forced show on error or when finished, + // because user could close it to run in background + migrationPrompt.loadWindow(logs, isErrored, isFinished) + } else { // update progress if the window is still around + migrationPrompt.update(logs) + } + }) try { await ipfsd.start() - const { id } = await ipfsd.api.id() - logger.info(`[daemon] PeerID is ${id}`) - logger.info(`[daemon] Repo is at ${ipfsd.path}`) - } catch (err) { - if (!err.message.includes('ECONNREFUSED') && !err.message.includes('ERR_CONNECTION_REFUSED')) { - throw err + const idRes = await ipfsd.api.id() + id = idRes.id + } catch (e) { + err = e + } finally { + // stop monitoring daemon output - we only care about migration phase + stopListening() + if (isErrored) { // save daemon output to error.log + logger.error(logs) + } + } + + return { + err, id, logs + } +} + +module.exports = async function (opts) { + const { ipfsd, isRemote } = await spawn(opts) + if (!isRemote) { + await checkPorts(ipfsd) + } + + let errLogs = await startIpfsWithLogs(ipfsd) + + if (errLogs.err) { + if (!errLogs.err.message.includes('ECONNREFUSED') && !errLogs.err.message.includes('ERR_CONNECTION_REFUSED')) { + return { ipfsd, err: errLogs.err, logs: errLogs.logs } } if (!configExists(ipfsd)) { cannotConnectDialog(ipfsd.apiAddr.toString()) - throw err + return { ipfsd, err: errLogs.err, logs: errLogs.logs } } logger.info('[daemon] removing api file') rmApiFile(ipfsd) - await ipfsd.start() + + errLogs = await startIpfsWithLogs(ipfsd) } - return ipfsd + return { ipfsd, err: errLogs.err, logs: errLogs.logs, id: errLogs.id } } diff --git a/src/daemon/index.js b/src/daemon/index.js index 108afda59..052fb0ded 100644 --- a/src/daemon/index.js +++ b/src/daemon/index.js @@ -43,24 +43,29 @@ module.exports = async function (ctx) { const config = store.get('ipfsConfig') updateStatus(STATUS.STARTING_STARTED) - try { - ipfsd = await createDaemon(config) - const { id } = await ipfsd.api.id() - - // Update the path if it was blank previously. - // This way we use the default path when it is - // not set. - if (!config.path || typeof config.path !== 'string') { - config.path = ipfsd.path - store.set('ipfsConfig', config) - } + const res = await createDaemon(config) - log.end() - updateStatus(STATUS.STARTING_FINISHED, id) - } catch (err) { - log.fail(err) + if (res.err) { + log.fail(res.err) updateStatus(STATUS.STARTING_FAILED) + return } + + ipfsd = res.ipfsd + + logger.info(`[daemon] PeerID is ${res.id}`) + logger.info(`[daemon] Repo is at ${ipfsd.path}`) + + // Update the path if it was blank previously. + // This way we use the default path when it is + // not set. + if (!config.path || typeof config.path !== 'string') { + config.path = ipfsd.path + store.set('ipfsConfig', config) + } + + log.end() + updateStatus(STATUS.STARTING_FINISHED, res.id) } const stopIpfs = async () => { diff --git a/src/daemon/migration-prompt.js b/src/daemon/migration-prompt.js new file mode 100644 index 000000000..7ee199ab3 --- /dev/null +++ b/src/daemon/migration-prompt.js @@ -0,0 +1,132 @@ +const { BrowserWindow } = require('electron') +const i18n = require('i18next') +const crypto = require('crypto') +const dock = require('../utils/dock') +const { styles, getBackgroundColor } = require('../dialogs/prompt/styles') +const { generateErrorIssueUrl } = require('../dialogs/errors') +const { IS_MAC } = require('../common/consts') + +const template = (logs, script, title, message, buttons) => { + if (IS_MAC) { + buttons.reverse() + } + + return ` + +
+ +${message}
+${logs}+ + + + +` +} + +const inProgressTemplate = (logs, id, done) => { + const title = i18n.t('migrationDialog.title') + const message = done ? i18n.t('ipfsIsRunning') : i18n.t('migrationDialog.message') + const buttons = [``] + const script = `const { ipcRenderer } = require('electron') + + ipcRenderer.on('${id}', (event, logs) => { + document.getElementById('logs').innerText = logs + scrollToBottom('logs') + })` + return template(logs, script, title, message, buttons) +} + +const errorTemplate = (logs) => { + const title = i18n.t('migrationFailedDialog.title') + const message = i18n.t('migrationFailedDialog.message') + const buttons = [ + ``, + `` + ] + + const script = ` + const { shell } = require('electron') + + function openIssue () { + shell.openExternal('${generateErrorIssueUrl(new Error(logs))}') + } + ` + return template(logs, script, title, message, buttons) +} + +let window + +module.exports = (logs, error = false, done = false) => { + // Generate random id + const id = crypto.randomBytes(16).toString('hex') + + const loadWindow = (logs, error = false, done = false) => { + if (!window) { + window = new BrowserWindow({ + show: false, + width: 800, + height: 438, + useContentSize: true, + resizable: false, + autoHideMenuBar: true, + fullscreenable: false, + backgroundColor: getBackgroundColor(), + webPreferences: { + nodeIntegration: true, + contextIsolation: false + } + }) + window.on('close', () => { + dock.hide() + window = null + }) + window.once('ready-to-show', () => { + dock.show() + window.show() + }) + } + const page = error ? errorTemplate(logs) : inProgressTemplate(logs, id, done) + const data = `data:text/html;base64,${Buffer.from(page, 'utf8').toString('base64')}` + window.loadURL(data) + } + + loadWindow(logs, error, done) + + return { + update: logs => { + if (window) { + window.webContents.send(id, logs) + return true + } + return false + }, + loadWindow + } +} diff --git a/src/dialogs/errors.js b/src/dialogs/errors.js index bac4f973e..74bff566f 100644 --- a/src/dialogs/errors.js +++ b/src/dialogs/errors.js @@ -21,7 +21,7 @@ ${e.stack} let hasErrored = false -const newIssueUrl = (e) => `https://github.com/ipfs-shipyard/ipfs-desktop/issues/new?labels=need%2Ftriage&template=bug_report.md&title=[gui%20error%20report]&body=${encodeURI(issueTemplate(e))}`.substring(0, 1999) +const generateErrorIssueUrl = (e) => `https://github.com/ipfs-shipyard/ipfs-desktop/issues/new?labels=need%2Ftriage&template=bug_report.md&title=[gui%20error%20report]&body=${encodeURI(issueTemplate(e))}`.substring(0, 1999) function criticalErrorDialog (e) { if (hasErrored) return @@ -41,7 +41,7 @@ function criticalErrorDialog (e) { if (option === 0) { app.relaunch() } else if (option === 2) { - shell.openExternal(newIssueUrl(e)) + shell.openExternal(generateErrorIssueUrl(e)) } app.exit(1) @@ -75,7 +75,7 @@ function recoverableErrorDialog (e, options) { const option = dialog(cfg) if (option === 1) { - shell.openExternal(newIssueUrl(e)) + shell.openExternal(generateErrorIssueUrl(e)) } else if (option === 2) { shell.openPath(path.join(app.getPath('userData'), 'combined.log')) } @@ -83,5 +83,6 @@ function recoverableErrorDialog (e, options) { module.exports = Object.freeze({ criticalErrorDialog, - recoverableErrorDialog + recoverableErrorDialog, + generateErrorIssueUrl }) diff --git a/src/dialogs/prompt/index.js b/src/dialogs/prompt/index.js index b1f140e67..bde6e10b5 100644 --- a/src/dialogs/prompt/index.js +++ b/src/dialogs/prompt/index.js @@ -1,23 +1,9 @@ -const { BrowserWindow, ipcMain, nativeTheme } = require('electron') +const { BrowserWindow, ipcMain } = require('electron') const crypto = require('crypto') const { IS_MAC } = require('../../common/consts') const dock = require('../../utils/dock') const makePage = require('./template') - -const pallette = { - default: { - background: '#ECECEC', - color: '#262626', - inputBackground: '#ffffff', - defaultBackground: '#007AFF' - }, - dark: { - background: '#323232', - color: '#ffffff', - inputBackground: '#656565', - defaultBackground: '#0A84FF' - } -} +const { getBackgroundColor } = require('./styles') function generatePage ({ message, defaultValue = '', buttons }, id) { buttons = buttons.map((txt, i) => ``) @@ -26,7 +12,7 @@ function generatePage ({ message, defaultValue = '', buttons }, id) { buttons.reverse() } - const page = makePage({ pallette, message, defaultValue, buttons, id }) + const page = makePage({ message, defaultValue, buttons, id }) return `data:text/html;base64,${Buffer.from(page, 'utf8').toString('base64')}` } @@ -44,9 +30,7 @@ module.exports = async function showPrompt (options) { resizable: false, autoHideMenuBar: true, fullscreenable: false, - backgroundColor: nativeTheme.shouldUseDarkColors - ? pallette.dark.background - : pallette.default.background, + backgroundColor: getBackgroundColor(), webPreferences: { nodeIntegration: true, contextIsolation: false diff --git a/src/dialogs/prompt/styles.js b/src/dialogs/prompt/styles.js new file mode 100644 index 000000000..aee1675ec --- /dev/null +++ b/src/dialogs/prompt/styles.js @@ -0,0 +1,93 @@ +const { nativeTheme } = require('electron') + +const pallette = { + default: { + background: '#ECECEC', + color: '#262626', + inputBackground: '#ffffff', + defaultBackground: '#007AFF' + }, + dark: { + background: '#323232', + color: '#ffffff', + inputBackground: '#656565', + defaultBackground: '#0A84FF' + } +} + +const styles = ` +:root { + --background: ${pallette.default.background}; + --color: ${pallette.default.color}; + --input-background: ${pallette.default.inputBackground}; + --default-background: ${pallette.default.defaultBackground}; +} +* { + box-sizing: border-box; +} +body, html { + margin: 0; + padding: 0; + font-size: 14px; + overflow: hidden; +} +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-family: system-ui; + line-height: 1; + padding: 0.75rem; + color: var(--color); + background: var(--background); +} +p, input, button { + font-size: 1rem; +} +p { + margin: 0; +} +input, button { + border-radius: 0.2rem; + border: 1px solid rgba(0, 0, 0, 0.1); + background: var(--input-background); + color: var(--color); +} +input { + display: block; + width: 100%; + margin: 0.5rem 0; + padding: 0.15rem; + outline: 0; +} +#buttons { + text-align: right; +} +button { + margin-left: 0.5rem; + padding: 0.25rem 0.5rem; + font-size: 1rem; + outline: 0; + cursor: pointer; +} +button.default { + background: var(--default-background); + color: #ffffff; +} +@media (prefers-color-scheme: dark) { + :root { + --background: ${pallette.dark.background}; + --color: ${pallette.dark.color}; + --input-background: ${pallette.dark.inputBackground}; + --default-background: ${pallette.dark.defaultBackground}; + } +} +` + +const getBackgroundColor = () => nativeTheme.shouldUseDarkColors + ? pallette.dark.background + : pallette.default.background + +module.exports = { + pallette, styles, getBackgroundColor +} diff --git a/src/dialogs/prompt/template.js b/src/dialogs/prompt/template.js index 0f7be1023..12dfc6b87 100644 --- a/src/dialogs/prompt/template.js +++ b/src/dialogs/prompt/template.js @@ -1,4 +1,6 @@ -module.exports = ({ pallette, message, defaultValue, buttons, id }) => (` +const { styles } = require('./styles') + +module.exports = ({ message, defaultValue, buttons, id }) => (` @@ -9,72 +11,7 @@ module.exports = ({ pallette, message, defaultValue, buttons, id }) => (`${buttons.join('\n')}