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 = '
${config.message}
+ + + + + ` + + 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}
- - - - - -`) 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