Skip to content

Commit 44d1811

Browse files
committed
feat: linkify only officially supported custom protocols
- Support `dweb:` address scheme: closes #280 - Disabled support for unsupported schemes, as described in #283 (comment) - Improve performance on complex and dynamic pages: closes #231
1 parent afe1f5e commit 44d1811

File tree

3 files changed

+135
-67
lines changed

3 files changed

+135
-67
lines changed

add-on/src/lib/common.js

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ function registerListeners () {
4646
browser.webRequest.onBeforeRequest.addListener(onBeforeRequest, {urls: ['<all_urls>']}, ['blocking'])
4747
browser.storage.onChanged.addListener(onStorageChange)
4848
browser.tabs.onUpdated.addListener(onUpdatedTab)
49+
browser.runtime.onMessage.addListener(onRuntimeMessage)
50+
browser.runtime.onConnect.addListener(onRuntimeConnect)
4951
}
5052

5153
// REDIRECT
@@ -258,7 +260,18 @@ function readDnslinkTxtRecordFromApi (fqdn) {
258260
})
259261
}
260262

261-
// PORTS
263+
// RUNTIME MESSAGES (one-off messaging)
264+
// ===================================================================
265+
// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/sendMessage
266+
267+
function onRuntimeMessage (request, sender) {
268+
// console.log((sender.tab ? 'Message from a content script:' + sender.tab.url : 'Message from the extension'), request)
269+
if (request.isIpfsPath) {
270+
return Promise.resolve({isIpfsPath: window.IsIpfs.path(request.isIpfsPath)})
271+
}
272+
}
273+
274+
// PORTS (connection-based messaging)
262275
// ===================================================================
263276
// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/connect
264277
// Make a connection between different contexts inside the add-on,
@@ -268,15 +281,15 @@ function readDnslinkTxtRecordFromApi (fqdn) {
268281
const browserActionPortName = 'browser-action-port'
269282
var browserActionPort
270283

271-
browser.runtime.onConnect.addListener(port => {
284+
function onRuntimeConnect (port) {
272285
// console.log('onConnect', port)
273286
if (port.name === browserActionPortName) {
274287
browserActionPort = port
275288
browserActionPort.onMessage.addListener(handleMessageFromBrowserAction)
276289
browserActionPort.onDisconnect.addListener(() => { browserActionPort = null })
277290
sendStatusUpdateToBrowserAction()
278291
}
279-
})
292+
}
280293

281294
function handleMessageFromBrowserAction (message) {
282295
// console.log('In background script, received message from browser action', message)
@@ -407,15 +420,22 @@ function isIpfsPageActionsContext (url) {
407420
async function onUpdatedTab (tabId, changeInfo, tab) {
408421
if (tab && tab.url) {
409422
if (state.linkify && changeInfo.status === 'complete') {
410-
console.log(`Running linkfyDOM for ${tab.url}`)
423+
console.log(`[ipfs-companion] Running linkfyDOM for ${tab.url}`)
411424
try {
425+
await browser.tabs.executeScript(tabId, {
426+
file: '/src/lib/npm/browser-polyfill.min.js',
427+
matchAboutBlank: false,
428+
allFrames: true,
429+
runAt: 'document_start'
430+
})
412431
await browser.tabs.executeScript(tabId, {
413432
file: '/src/lib/linkifyDOM.js',
414433
matchAboutBlank: false,
415-
allFrames: true
434+
allFrames: true,
435+
runAt: 'document_idle'
416436
})
417437
} catch (error) {
418-
console.error(`Unable to linkify DOM at '${tab.url}' due to ${error}`)
438+
console.error(`Unable to linkify DOM at '${tab.url}' due to`, error)
419439
}
420440
}
421441
}

add-on/src/lib/linkifyDOM.js

Lines changed: 108 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* plain text with IPFS addresses with clickable links.
77
* Loosely based on https://github.com/mdn/webextensions-examples/blob/master/emoji-substitution/substitute.js
88
* Note that this is a quick&dirty PoC and may slow down browsing experience.
9+
* Test page: http://bit.ly/2fgkF4E
910
* TODO: measure & improve performance
1011
*/
1112

@@ -14,10 +15,19 @@
1415
return
1516
}
1617

18+
// Limit contentType to "text/plain" or "text/html"
19+
if (document.contentType !== undefined && document.contentType !== 'text/plain' && document.contentType !== 'text/html') {
20+
return
21+
}
22+
1723
// linkify lock
1824
window.ipfsLinkifiedDOM = true
25+
window.ipfsLinkifyValidationCache = new Map()
26+
27+
const urlRE = /(?:\s+|^)(\/ip(?:f|n)s\/|dweb:\/ip(?:f|n)s\/|ipns:\/\/|ipfs:\/\/)([^\s+"<>]+)/g
1928

20-
const urlRE = /(?:\s+|^)(?:\/ip(f|n)s\/|fs:|ipns:|ipfs:)[^\s+"<>]+/g
29+
// Chrome compatibility
30+
// var browser = browser || chrome
2131

2232
// tags we will scan looking for un-hyperlinked IPFS addresses
2333
const allowedParents = [
@@ -28,117 +38,155 @@
2838
's', 'strong', 'sub', 'sup', 'td', 'th', 'tt', 'u', 'var'
2939
]
3040

31-
const textNodeXpath = '//text()[(parent::' + allowedParents.join(' or parent::') + ') and ' +
32-
"(contains(., 'ipfs') or contains(., 'ipns')) ]"
41+
const textNodeXpath = './/text()[' +
42+
"(contains(., '/ipfs/') or contains(., '/ipns/') or contains(., 'ipns:/') or contains(., 'ipfs:/')) and " +
43+
'not(ancestor::a) and not(ancestor::script) and not(ancestor::style) and ' +
44+
'(parent::' + allowedParents.join(' or parent::') + ') ' +
45+
']'
3346

34-
linkifyContainer(document.body)
47+
function init () {
48+
linkifyContainer(document.body)
3549

36-
// body.appendChild(document.createTextNode('fooo /ipfs/QmTAsnXoWmLZQEpvyZscrReFzqxP3pvULfGVgpJuayrp1w bar'))
37-
new MutationObserver(function (mutations) {
38-
for (let mutation of mutations) {
39-
if (mutation.type === 'childList') {
40-
for (let addedNode of mutation.addedNodes) {
41-
if (addedNode.nodeType === Node.TEXT_NODE) {
42-
linkifyTextNode(addedNode)
43-
} else {
44-
linkifyContainer(addedNode)
50+
// body.appendChild(document.createTextNode('fooo /ipfs/QmTAsnXoWmLZQEpvyZscrReFzqxP3pvULfGVgpJuayrp1w bar'))
51+
new MutationObserver(function (mutations) {
52+
mutations.forEach(function (mutation) {
53+
if (mutation.type === 'childList') {
54+
for (let addedNode of mutation.addedNodes) {
55+
if (addedNode.nodeType === Node.TEXT_NODE) {
56+
setTimeout(() => linkifyTextNode(addedNode), 0)
57+
} else {
58+
setTimeout(() => linkifyContainer(addedNode), 0)
59+
}
4560
}
4661
}
47-
}
48-
if (mutation.type === 'characterData') {
49-
linkifyTextNode(mutation.target)
50-
}
51-
}
52-
}).observe(document.body, {
53-
characterData: true,
54-
childList: true,
55-
subtree: true
56-
})
62+
if (mutation.type === 'characterData') {
63+
setTimeout(() => linkifyTextNode(mutation.target), 0)
64+
}
65+
})
66+
}).observe(document.body, {
67+
characterData: true,
68+
childList: true,
69+
subtree: true
70+
})
71+
}
5772

5873
function linkifyContainer (container) {
59-
// console.log('linkifyContainer', container)
60-
if (!container.nodeType) {
74+
if (!container || !container.nodeType) {
6175
return
6276
}
6377
if (container.className && container.className.match(/\blinkifiedIpfsAddress\b/)) {
6478
// prevent infinite recursion
6579
return
6680
}
67-
const xpathResult = document.evaluate(textNodeXpath, container, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null)
81+
const xpathResult = document.evaluate(textNodeXpath, container, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
6882
let i = 0
69-
function continuation () {
83+
async function continuation () {
7084
let node = null
7185
let counter = 0
7286
while ((node = xpathResult.snapshotItem(i++))) {
7387
const parent = node.parentNode
74-
if (!parent) continue
88+
// Skip if no longer in visible DOM
89+
if (!parent || !document.body.contains(node)) continue
90+
// Skip already linkified nodes
91+
if (parent.className && parent.className.match(/\blinkifiedIpfsAddress\b/)) continue
7592
// Skip styled <pre> -- often highlighted by script.
7693
if (parent.tagName === 'PRE' && parent.className) continue
7794
// Skip forms, textareas
7895
if (parent.isContentEditable) continue
79-
linkifyTextNode(node)
80-
if (++counter > 50) {
81-
return setTimeout(continuation, 0)
96+
await linkifyTextNode(node)
97+
if (++counter > 10) {
98+
return setTimeout(continuation, 100)
8299
}
83100
}
84101
}
85-
setTimeout(continuation, 0)
102+
window.requestAnimationFrame(continuation)
86103
}
87104

88-
function normalizeHref (href) {
89-
// console.log(href)
90-
// convert various variants to regular URL at the public gateway
91-
if (href.startsWith('ipfs:')) {
92-
href = href.replace('ipfs:', '/ipfs/')
105+
function textToIpfsResource (match) {
106+
let root = match[1]
107+
let path = match[2]
108+
109+
// skip trailing dots and commas
110+
path = path.replace(/[.,]*$/, '')
111+
112+
// convert various protocol variants to regular URL at the public gateway
113+
if (root === 'ipfs://') {
114+
root = '/ipfs/'
115+
} else if (root === 'ipns://') {
116+
root = '/ipns/'
117+
} else if (root === 'dweb:/ipfs/') {
118+
root = '/ipfs/'
119+
} else if (root === 'dweb:/ipns/') {
120+
root = '/ipns/'
93121
}
94-
if (href.startsWith('ipns:')) {
95-
href = href.replace('ipns:', '/ipns/')
122+
return validIpfsResource(root + path)
123+
}
124+
125+
async function validIpfsResource (path) {
126+
// validation is expensive, caching result improved performance
127+
// on page that have multiple copies of the same path
128+
if (window.ipfsLinkifyValidationCache.has(path)) {
129+
return window.ipfsLinkifyValidationCache.get(path)
96130
}
97-
if (href.startsWith('fs:')) {
98-
href = href.replace('fs:', '')
131+
try {
132+
// Callback wrapped in promise -- Chrome compatibility
133+
const checkResult = await browser.runtime.sendMessage({isIpfsPath: path})
134+
if (checkResult.isIpfsPath) {
135+
// TODO: use customizable public gateway
136+
window.ipfsLinkifyValidationCache.set(path, 'https://ipfs.io' + path)
137+
} else {
138+
window.ipfsLinkifyValidationCache.set(path, null)
139+
}
140+
} catch (error) {
141+
window.ipfsLinkifyValidationCache.set(path, null)
142+
console.error('isIpfsPath.error for ' + path, error)
99143
}
100-
href = 'https://ipfs.io/' + href // for now just point to public gw, we will switch to custom protocol when https://github.com/ipfs/ipfs-companion/issues/164 is closed
101-
href = href.replace(/([^:]\/)\/+/g, '$1') // remove redundant slashes
102-
return href
144+
return window.ipfsLinkifyValidationCache.get(path)
103145
}
104146

105-
function linkifyTextNode (node) {
106-
// console.log('linkifyTextNode', node)
147+
async function linkifyTextNode (node) {
107148
let link
108149
let match
109150
const txt = node.textContent
110151
let span = null
111152
let point = 0
112153
while ((match = urlRE.exec(txt))) {
154+
link = await textToIpfsResource(match)
113155
if (span == null) {
114-
// Create a span to hold the new text with links in it.
156+
// Create a span to hold the new text with links in it.
115157
span = document.createElement('span')
116158
span.className = 'linkifiedIpfsAddress'
117159
}
118-
// get the link without trailing dots and commas
119-
link = match[0].replace(/[.,]*$/, '')
120-
const replaceLength = link.length
121-
// put in text up to the link
122-
span.appendChild(document.createTextNode(txt.substring(point, match.index)))
123-
// create a link and put it in the span
124-
const a = document.createElement('a')
125-
a.className = 'linkifiedIpfsAddress'
126-
a.appendChild(document.createTextNode(link))
127-
a.setAttribute('href', normalizeHref(link.trim()))
128-
span.appendChild(a)
129-
// track insertion point
160+
const replaceLength = match[0].length
161+
if (link) {
162+
// put in text up to the link
163+
span.appendChild(document.createTextNode(txt.substring(point, match.index)))
164+
// create a link and put it in the span
165+
const a = document.createElement('a')
166+
a.className = 'linkifiedIpfsAddress'
167+
a.appendChild(document.createTextNode(match[0]))
168+
a.setAttribute('href', link)
169+
span.appendChild(a)
170+
} else {
171+
// wrap text in span to exclude it from future processing
172+
span.appendChild(document.createTextNode(match[0]))
173+
}
174+
// track insertion point
130175
point = match.index + replaceLength
131176
}
132177
if (span) {
133178
// take the text after the last link
134179
span.appendChild(document.createTextNode(txt.substring(point, txt.length)))
180+
span.normalize()
135181
// replace the original text with the new span
136182
try {
137183
node.parentNode.replaceChild(span, node)
138184
} catch (e) {
139185
console.error(e)
140-
console.log(node)
186+
// console.log(node)
141187
}
142188
}
143189
}
190+
191+
init()
144192
}(window.ipfsLinkifiedDOM))

add-on/src/options/options.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@
150150
<div>
151151
<label for="linkify">
152152
<dl>
153-
<dt>Clickable IPFS Addresses</dt>
153+
<dt>Linkify IPFS Addresses</dt>
154154
<dd>Turn plaintext <code>/ipfs/</code> paths into clickable links</dd>
155155
</dl>
156156
</label>

0 commit comments

Comments
 (0)