diff --git a/assets/icons/ipfs.svg b/assets/icons/ipfs.svg new file mode 100644 index 000000000..8d6a879e1 --- /dev/null +++ b/assets/icons/ipfs.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/locales/en.json b/assets/locales/en.json index b4f1d836a..1bd94b87e 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -33,6 +33,7 @@ "yes": "Yes", "no": "No", "close": "Close", + "continue": "Continue", "ok": "OK", "cancel": "Cancel", "enable": "Enable", @@ -173,6 +174,7 @@ "appPreferences": "App Preferences", "launchOnStartup": "Launch at Login", "openWebUIAtLaunch": "Open Web UI at Launch", + "askWhenOpeningIpfsURIs": "Ask How to Open IPFS Address", "pubsub": "Enable PubSub", "namesysPubsub": "Enable IPNS over PubSub", "automaticGC": "Automatic Garbage Collection", @@ -220,6 +222,17 @@ "title": "Private Network IPFS Repository", "message": "The repository at “{ path }” is part of a private network, which is not supported by IPFS Desktop." }, + "migrationFailedDialog": { + "title": "IPFS Desktop Migration Has Failed", + "message": "IPFS has encountered an error and migration could not be completed:" + }, + "protocolHandlerDialog": { + "title": "Opening IPFS address", + "message": "How would you like IPFS Desktop to open ipfs:// and ipns:// addresses?", + "openInBrowser": "Open in my default browser", + "openInIpfsDesktop": "Open in IPFS Desktop", + "rememberThisChoice": "Remember this choice for all IPFS addresses" + }, "invalidRepositoryDialog": { "title": "Invalid IPFS Repository or Configuration File", "message": "The repository at “{ path }” or its configuration is invalid. The “config” file must be a valid JSON.\n\nBefore starting IPFS Desktop again, please fix the configuration file or rename the old repository to “.ipfs.backup”. Please note that by renaming the old repository, IPFS Desktop will generate a new repository" diff --git a/package-lock.json b/package-lock.json index 5dec0618a..4e7a9f3e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,8 @@ "it-last": "^1.0.6", "multiaddr": "10.0.1", "multiaddr-to-uri": "8.0.0", + "multiformats": "^9.6.4", + "node-fetch": "^2.6.7", "portfinder": "^1.0.28", "untildify": "^4.0.0", "v8-compile-cache": "^2.3.0", @@ -7667,9 +7669,9 @@ } }, "node_modules/multiformats": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.5.4.tgz", - "integrity": "sha512-MFT8e8BOLX7OZKfSBGm13FwYvJVI6MEcZ7hujUCpyJwvYyrC1anul50A0Ee74GdeJ77aYTO6YU1vO+oF8NqSIw==" + "version": "9.6.4", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.4.tgz", + "integrity": "sha512-fCCB6XMrr6CqJiHNjfFNGT0v//dxOBMrOMqUIzpPc/mmITweLEyhvMpY9bF+jZ9z3vaMAau5E8B68DW77QMXkg==" }, "node_modules/murmurhash3js-revisited": { "version": "3.0.0", @@ -17585,9 +17587,9 @@ } }, "multiformats": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.5.4.tgz", - "integrity": "sha512-MFT8e8BOLX7OZKfSBGm13FwYvJVI6MEcZ7hujUCpyJwvYyrC1anul50A0Ee74GdeJ77aYTO6YU1vO+oF8NqSIw==" + "version": "9.6.4", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.4.tgz", + "integrity": "sha512-fCCB6XMrr6CqJiHNjfFNGT0v//dxOBMrOMqUIzpPc/mmITweLEyhvMpY9bF+jZ9z3vaMAau5E8B68DW77QMXkg==" }, "murmurhash3js-revisited": { "version": "3.0.0", diff --git a/package.json b/package.json index f19489e41..b6e8d164c 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,8 @@ "it-last": "^1.0.6", "multiaddr": "10.0.1", "multiaddr-to-uri": "8.0.0", + "multiformats": "^9.6.4", + "node-fetch": "^2.6.7", "portfinder": "^1.0.28", "untildify": "^4.0.0", "v8-compile-cache": "^2.3.0", diff --git a/src/common/config-keys.js b/src/common/config-keys.js index 729e11833..ad536d12d 100644 --- a/src/common/config-keys.js +++ b/src/common/config-keys.js @@ -10,7 +10,8 @@ const CONFIG_KEYS = { OPEN_WEBUI_LAUNCH: 'openWebUIAtLaunch', MONOCHROME_TRAY_ICON: 'monochromeTrayIcon', EXPERIMENT_PUBSUB: 'experiments.pubsub', - EXPERIMENT_PUBSUB_NAMESYS: 'experiments.pubsubNamesys' + EXPERIMENT_PUBSUB_NAMESYS: 'experiments.pubsubNamesys', + ASK_OPENING_IPFS_URIS: 'askWhenOpeningIpfsURIs' } module.exports = CONFIG_KEYS diff --git a/src/common/consts.js b/src/common/consts.js index 62faf3434..2b9ee2d5e 100644 --- a/src/common/consts.js +++ b/src/common/consts.js @@ -1,4 +1,6 @@ const os = require('os') +const path = require('path') +const fs = require('fs-extra') const packageJson = require('../../package.json') module.exports = Object.freeze({ @@ -10,5 +12,6 @@ module.exports = Object.freeze({ GO_IPFS_VERSION: packageJson.dependencies['go-ipfs'], COUNTLY_KEY: process.env.NODE_ENV === 'development' ? '6b00e04fa5370b1ce361d2f24a09c74254eee382' - : '47fbb3db3426d2ae32b3b65fe40c564063d8b55d' + : '47fbb3db3426d2ae32b3b65fe40c564063d8b55d', + IPFS_LOGO_URI: 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(__dirname, '../../assets/icons/ipfs.svg')).toString('base64') }) diff --git a/src/dialogs/prompt/html.js b/src/dialogs/prompt/html.js new file mode 100644 index 000000000..cd9ab92b1 --- /dev/null +++ b/src/dialogs/prompt/html.js @@ -0,0 +1,122 @@ +const { IS_MAC, IPFS_LOGO_URI } = require('../../common/consts') +const { styles } = require('./styles') + +/** + * Generates an HTML string with the given button labels. + * The order of the buttons is inverted on macOS to match the behavior + * of the OS. + * + * @param {string[]} labels + * @returns {string} + */ +function getButtonComponentsHtml (labels) { + const buttons = labels.map((txt, i) => ``) + + if (IS_MAC) { + buttons.reverse() + } + + return buttons.join('\n') +} + +/** + * @typedef InputConfiguration + * @type {object} + * @property {string} type + * @property {string} name + * @property {string} defaultValue + * @property {string | null} label + * @property {string[] | null} labels + */ + +/** + * Generates an HTML string with the given configurations. + * + * @param {InputConfiguration[]} inputs + * @returns {string} + */ +function getInputComponentsHtml (inputs) { + return inputs.map(({ type, name, label, defaultValue, labels = {} }) => { + let str = '
' + + switch (type) { + case 'checkbox': + str += '
' + str += `` + str += `` + str += '
' + break + case 'radio': + str += '
' + for (const key in labels) { + str += '
' + str += `` + str += `` + str += '
' + } + str += '
' + + break + case 'text': + str += `` + } + + str += '
' + return str + }).join('\n') +} + +/** + * Generates a base64 encoded URI with the HTML content of a prompt window. + * + * @param {PromptConfiguration} config + * @param {string} id + * @returns {string} + */ +function getPromptEncodedHtml (config, id) { + const buttons = getButtonComponentsHtml(config.buttons) + const inputs = getInputComponentsHtml(config.inputs) + + const html = ` + + + + + + +

${config.message}

+
+ ${inputs} +
${buttons}
+
+ + + + ` + + return `data:text/html;base64,${Buffer.from(html, 'utf8').toString('base64')}` +} + +module.exports = { + getPromptEncodedHtml +} diff --git a/src/dialogs/prompt/index.js b/src/dialogs/prompt/index.js index bde6e10b5..24146b1f2 100644 --- a/src/dialogs/prompt/index.js +++ b/src/dialogs/prompt/index.js @@ -1,32 +1,38 @@ 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 { getBackgroundColor } = require('./styles') +const { getPromptEncodedHtml } = require('./html') -function generatePage ({ message, defaultValue = '', buttons }, id) { - buttons = buttons.map((txt, i) => ``) - - if (IS_MAC) { - buttons.reverse() - } - - const page = makePage({ message, defaultValue, buttons, id }) - return `data:text/html;base64,${Buffer.from(page, 'utf8').toString('base64')}` -} +/** + * @typedef PromptConfiguration + * @type {object} + * @property {string} title + * @property {string} message + * @property {InputConfiguration[]} inputs + * @property {string[]} buttons + * @property {object} window + */ -module.exports = async function showPrompt (options) { - options = Object.assign({}, { +/** + * Displays a prompt to the user according to the given configuration. + * + * @param {PromptConfiguration} config + * @returns + */ +module.exports = async function showPrompt (config) { + config = Object.assign({}, { window: {}, showDock: true - }, options) + }, config) const window = new BrowserWindow({ - title: options.title, + title: config.title, show: false, width: 350, height: 330, + useContentSize: true, resizable: false, autoHideMenuBar: true, fullscreenable: false, @@ -35,7 +41,7 @@ module.exports = async function showPrompt (options) { nodeIntegration: true, contextIsolation: false }, - ...options.window + ...config.window }) // Generate random id @@ -44,20 +50,20 @@ module.exports = async function showPrompt (options) { return new Promise(resolve => { ipcMain.once(id, (_, data) => { window.destroy() - if (options.showDock) dock.hide() + if (config.showDock) dock.hide() resolve(data) }) window.on('close', () => { - if (options.showDock) dock.hide() + if (config.showDock) dock.hide() resolve({ input: '', button: null }) }) window.once('ready-to-show', () => { - if (options.showDock) dock.show() + if (config.showDock) dock.show() window.show() }) - window.loadURL(generatePage(options, id)) + window.loadURL(getPromptEncodedHtml(config, id)) }) } diff --git a/src/dialogs/prompt/styles.js b/src/dialogs/prompt/styles.js index aee1675ec..207406438 100644 --- a/src/dialogs/prompt/styles.js +++ b/src/dialogs/prompt/styles.js @@ -60,9 +60,27 @@ input { padding: 0.15rem; outline: 0; } +div.group { + margin: 0.5rem 0; +} +div.inline input, +div.inline label { + display: inline-block; + width: auto; + vertical-align: middle; +} +input[type=radio], +input[type=checkbox] { + margin-right: 0.25rem; +} #buttons { text-align: right; } +#logo { + width: 4rem; + margin: 0 auto .5rem; + display: block; +} button { margin-left: 0.5rem; padding: 0.25rem 0.5rem; diff --git a/src/dialogs/prompt/template.js b/src/dialogs/prompt/template.js deleted file mode 100644 index 12dfc6b87..000000000 --- a/src/dialogs/prompt/template.js +++ /dev/null @@ -1,35 +0,0 @@ -const { styles } = require('./styles') - -module.exports = ({ message, defaultValue, buttons, id }) => (` - - - - - -

${message}

- -
${buttons.join('\n')}
- - - -`) diff --git a/src/i18n.js b/src/i18n.js index c23090c7b..3e54255d4 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -6,7 +6,7 @@ const Backend = require('i18next-fs-backend') const store = require('./common/store') const ipcMainEvents = require('./common/ipc-main-events') -module.exports = async function () { +const setupI18n = async () => { await i18n .use(ICU) .use(Backend) @@ -34,3 +34,11 @@ module.exports = async function () { ipcMain.emit(ipcMainEvents.LANG_UPDATED, lang) }) } + +let result = null + +module.exports = async () => { + if (result) return result + result = await setupI18n() + return result +} diff --git a/src/protocol-handlers.js b/src/protocol-handlers.js deleted file mode 100644 index 49595cb90..000000000 --- a/src/protocol-handlers.js +++ /dev/null @@ -1,58 +0,0 @@ -const { app, shell } = require('electron') -const toUri = require('multiaddr-to-uri') - -function openLink (protocol, part, base) { - shell.openExternal(`${base}/${protocol}/${part}`) - return true -} - -function parseAddr (addr) { - return toUri(addr.toString().includes('/http') ? addr : addr.encapsulate('/http')) -} - -async function parseUrl (url, ctx) { - const ipfsd = ctx.getIpfsd ? await ctx.getIpfsd(true) : null - let base = 'https://ipfs.io' - - if (ipfsd && ipfsd.gatewayAddr) { - base = parseAddr(ipfsd.gatewayAddr) - } - - if (url.startsWith('ipfs://')) { - return openLink('ipfs', url.slice(7), base) - } else if (url.startsWith('ipns://')) { - return openLink('ipns', url.slice(7), base) - } else if (url.startsWith('dweb:/ipfs/')) { - return openLink('ipfs', url.slice(11), base) - } else if (url.startsWith('dweb:/ipns/')) { - return openLink('ipns', url.slice(11), base) - } - - return false -} - -async function argvHandler (argv, ctx) { - let handled = false - - for (const arg of argv) { - if (await parseUrl(arg, ctx)) { - handled = true - } - } - - return handled -} - -module.exports = function (ctx) { - // Handle if the app started running now, and a link - // was sent to be handled. - argvHandler(process.argv, ctx) - - // Handle URLs in macOS - app.on('open-url', (event, url) => { - event.preventDefault() - parseUrl(url, ctx) - }) -} - -module.exports.argvHandler = argvHandler diff --git a/src/protocol-handlers/index.js b/src/protocol-handlers/index.js new file mode 100644 index 000000000..072dc3e22 --- /dev/null +++ b/src/protocol-handlers/index.js @@ -0,0 +1,133 @@ +const { app, shell, ipcMain } = require('electron') +const i18n = require('i18next') +const createToggler = require('../utils/create-toggler') +const store = require('../common/store') +const { showPrompt } = require('../dialogs') +const setupI18n = require('../i18n') +const { parseUrl, getGatewayUrl } = require('./urls') +const { ASK_OPENING_IPFS_URIS: CONFIG_KEY } = require('../common/config-keys') + +const CONFIG_KEY_ACTION = 'openIpfsURIsAction' + +const ACTION_OPTIONS = { + OPEN_IN_BROWSER: 'openInBrowser', + OPEN_IN_IPFS_DESKTOP: 'openInIpfsDesktop' +} + +const DEFAULT_ACTION = ACTION_OPTIONS.OPEN_IN_BROWSER + +async function getAction () { + const askWhenOpeningUri = store.get(CONFIG_KEY, true) + if (!askWhenOpeningUri) { + return store.get(CONFIG_KEY_ACTION, DEFAULT_ACTION) + } + + const { button, input } = await showPrompt({ + title: i18n.t('protocolHandlerDialog.title'), + message: i18n.t('protocolHandlerDialog.message'), + inputs: [ + { + type: 'radio', + name: 'action', + defaultValue: DEFAULT_ACTION, + labels: { + [ACTION_OPTIONS.OPEN_IN_BROWSER]: i18n.t('protocolHandlerDialog.openInBrowser'), + [ACTION_OPTIONS.OPEN_IN_IPFS_DESKTOP]: i18n.t('protocolHandlerDialog.openInIpfsDesktop') + } + }, + { + type: 'checkbox', + name: 'remember', + defaultValue: 'checked', + label: i18n.t('protocolHandlerDialog.rememberThisChoice') + } + ], + buttons: [ + i18n.t('continue'), + i18n.t('cancel') + ], + window: { + width: 500, + height: 218 + } + }) + + if (button === 1) { + // User canceled. + return + } + + const { remember, action } = input + if (remember === 'on') { + store.set(CONFIG_KEY, false) + store.set(CONFIG_KEY_ACTION, action) + ipcMain.emit('configUpdated') + } + + return action +} + +/** + * @returns {Promise} whether or not the URL was handled. + */ +async function handleUrl (url, ctx) { + const parsed = parseUrl(url) + if (!parsed) { + return false + } + + const action = await getAction() + + if (action === ACTION_OPTIONS.OPEN_IN_BROWSER) { + const url = await getGatewayUrl(ctx, parsed) + shell.openExternal(url) + return true + } + + if (action === ACTION_OPTIONS.OPEN_IN_IPFS_DESKTOP) { + ctx.launchWebUI(`/${parsed.protocol}/${parsed.hostname}${parsed.path}`, { focus: true }) + return true + } + + return false +} + +async function argvHandler (argv, ctx) { + let handled = false + + for (const arg of argv) { + if (await handleUrl(arg, ctx)) { + handled = true + } + } + + return handled +} + +module.exports = async function (ctx) { + await app.whenReady() + await setupI18n(ctx) // Ensure i18n is ready for the dialog. + + // By default, ask. We need to change this to ensure the + // tray option shows a 'tick'. + if (store.get(CONFIG_KEY, null) === null) { + store.set(CONFIG_KEY, true) + ipcMain.emit('configUpdated') + } + + createToggler(CONFIG_KEY, () => true) + + // Handle if the app started running now, and a link + // was sent to be handled. + argvHandler(process.argv, ctx) + + // Handle URLs in macOS + app.on('open-url', (event, url) => { + event.preventDefault() + parseUrl(url, ctx) + }) +} + +module.exports.argvHandler = argvHandler + +module.exports.CONFIG_KEY = CONFIG_KEY diff --git a/src/protocol-handlers/urls.js b/src/protocol-handlers/urls.js new file mode 100644 index 000000000..0bb2b1f20 --- /dev/null +++ b/src/protocol-handlers/urls.js @@ -0,0 +1,155 @@ +const toUri = require('multiaddr-to-uri') +const { CID } = require('multiformats/cid') +const fetch = require('node-fetch') + +const DEFAULT_GATEWAY = 'https://dweb.link' +const LOCAL_HOSTNAMES = ['127.0.0.1', '[::1]', '0.0.0.0', '[::]'] + +/** + * @typedef ParsedUrl + * @type {object} + * @property {string} protocol + * @property {string} hostname + * @property {string} path + */ + +/** + * Parses an IPFS/IPNS/dWeb URL to be handled by IPFS Desktop. + * + * @param {string} url + * @returns {ParsedUrl|null} + */ +function parseUrl (url) { + let protocol = null + let hostname = null + let path = '/' + + if (url.startsWith('ipfs://')) { + protocol = 'ipfs' + hostname = url.slice(7) + } else if (url.startsWith('ipns://')) { + protocol = 'ipns' + hostname = url.slice(7) + } else if (url.startsWith('dweb:/ipfs/')) { + protocol = 'ipfs' + hostname = url.slice(11) + } else if (url.startsWith('dweb:/ipns/')) { + protocol = 'ipns' + hostname = url.slice(11) + } else { + return null + } + + if (hostname.includes('/')) { + const [first, ...rest] = hostname.split('/') + hostname = first + path = '/' + rest.join('/') + } + + return { protocol, hostname, path } +} + +async function getPublicGatewayUrl (ctx) { + if (!ctx.webui) { + // Best effort. If the Web UI window wasn't created yet, we just return the default + // gateway. + return DEFAULT_GATEWAY + } + + /** @type {string|null} */ + let publicGatewayUrl + + try { + publicGatewayUrl = await ctx.webui.webContents + .executeJavaScript('JSON.parse(localStorage.getItem("ipfsPublicGateway"))') || 'https://dweb.link' + } catch (_) { + publicGatewayUrl = 'https://dweb.link' + } + + return publicGatewayUrl +} + +async function getPrivateGatewayUrl (ctx) { + const ipfsd = ctx.getIpfsd ? await ctx.getIpfsd(true) : null + if (!ipfsd || !ipfsd.api) { + return DEFAULT_GATEWAY + } + + let gateway = await ipfsd.api.config.get('Addresses.Gateway') + if (Array.isArray(gateway)) { + if (gateway.length >= 1) { + gateway = gateway[0] + } else { + return DEFAULT_GATEWAY + } + } + + return toUri(gateway) +} + +const checkIfGatewayUrlIsAccessible = async (url) => { + try { + const { status } = await fetch( + `${url}/ipfs/bafkqae2xmvwgg33nmuqhi3zajfiemuzahiwss` + ) + return status === 200 + } catch (e) { + return false + } +} + +// Separate test is necessary to see if subdomain mode is possible, +// because some browser+OS combinations won't resolve them: +// https://github.com/ipfs/go-ipfs/issues/7527 +const checkIfSubdomainGatewayUrlIsAccessible = async (url) => { + try { + url = new URL(url) + url.hostname = `bafkqae2xmvwgg33nmuqhi3zajfiemuzahiwss.ipfs.${url.hostname}` + const { status } = await fetch(url.toString()) + return status === 200 + } catch (e) { + return false + } +} + +/** + * Get the gateway URL. Logic borrowed from Web UI. + * Please check https://github.com/ipfs/ipfs-webui/pull/1591. + * + * @param {object} ctx + * @param {ParsedUrl} parsedUrl + * @returns + */ +async function getGatewayUrl (ctx, { protocol, hostname, path }) { + const publicGateway = await getPublicGatewayUrl(ctx) + const privateGateway = await getPrivateGatewayUrl(ctx) + + const gw = new URL(privateGateway) + if (LOCAL_HOSTNAMES.includes(gw.hostname)) { + gw.hostname = 'localhost' + const localUrl = gw.toString().replace(/\/+$/, '') // no trailing slashes + if (await checkIfSubdomainGatewayUrlIsAccessible(localUrl)) { + if (protocol === 'ipns') { + hostname = hostname.replaceAll('.', '-') + gw.hostname = `${hostname}.ipns.localhost` + } else { + const cid = CID.parse(hostname) + gw.hostname = `${cid.toV1().toString()}.ipfs.localhost` + } + + gw.pathname = path + return gw.toString().replace(/\/+$/, '') + } + } + + if (await checkIfGatewayUrlIsAccessible(privateGateway)) { + return `${privateGateway}/${protocol}/${hostname}${path}` + } + + return `${publicGateway}/${protocol}/${hostname}${path}` +} + +module.exports = { + parseUrl, + getGatewayUrl +} diff --git a/src/tray.js b/src/tray.js index b20aa58d5..b9f996ea4 100644 --- a/src/tray.js +++ b/src/tray.js @@ -107,6 +107,7 @@ function buildMenu (ctx) { }, buildCheckbox(CONFIG_KEYS.AUTO_LAUNCH, 'settings.launchOnStartup'), buildCheckbox(CONFIG_KEYS.OPEN_WEBUI_LAUNCH, 'settings.openWebUIAtLaunch'), + buildCheckbox(CONFIG_KEYS.ASK_OPENING_IPFS_URIS, 'settings.askWhenOpeningIpfsURIs'), buildCheckbox(CONFIG_KEYS.AUTO_GARBAGE_COLLECTOR, 'settings.automaticGC'), buildCheckbox(CONFIG_KEYS.SCREENSHOT_SHORTCUT, 'settings.takeScreenshotShortcut'), ...(IS_MAC ? [] : [buildCheckbox(CONFIG_KEYS.MONOCHROME_TRAY_ICON, 'settings.monochromeTrayIcon')]),