Please take this with a grain of salt and note that I’m not trying to reinvent anything here, nor am I saying that this is better than the alternatives which might already be available out there. I’m simply trying to solve my own need and maybe provide something useful to the FOSS community at the same time.
On iOS, there’s this feature which I think is called “link preview”, which is pretty self-explanatory, as it allows you to open a popup inside the current app to preview a link, by long pressing it.
I’m sure this is not new and is probably available on other platforms as well, but iOS is what I use and I really like the idea. I found out about it maybe less than a year ago, I don’t know when it was implemented, but it’s not relevant.
I don’t use it that much, because the phone screen is pretty small as it is, but it kinda stuck with me as a good idea, at least in principle. So, last month, while I browsing an e-commerce store, a random though passed through my mind: it’d be pretty nice to be able to preview some of these products to save some tab-switching. 💡
I know how to create browser extensions and I was able to visualise that this would be a fairly simple project to undergo, so I said to myself that I’d do it just to see how it works. Hence, for the past two weeks I’ve been working on what I call “Prevue Popup” (because “Prevue” was already taken in the Chrome Web Store.)
What is Prevue Popup
Here’s prepared a short video demo previewing the main functionality:
It’s a popup which opens inside the current page, containing an iframe of the link you want to preview. You can open it on either side and you can switch sides at any point. This is customisable from the options page, but by default it will open on the side of the screen opposite to the position of your cursor. So, if your link is on the right side of the page, it will open on the left, and vice-versa.
You can resize it horizontally by dragging on the margin of the address bar and it will remember the current width the next time it opens.
There’s also an option to “pin” the tab, which will move it to the side and will keep it open, just in case you want to come back to it later.
You can exit out of it by pressing Escape, or by clicking outside of it, or by scrolling outside of it. The option to close it when scrolling outside of the popup is disabled by default, because I think most people will not expect it to behave like that, and I don’t want to close it prematurely. You can enable it from the options page nonetheless.
My main challenge, from an UX perspective, was to create some ways to trigger the popup to open, without messing with the current page’s events too much, or with the possible keyboard shortcuts defined in the page, and so on, but, at the same time, I wanted to make it as frictionless as possible, that’s why I excluded the click event entirely.
That being said, I was able to come up with the following triggers:
- When holding ALT and hovering over a link
- When holding CTRL (on Linux and Windows) and hovering over a link
- When holding CMD (on Mac) and hovering over a link
- When holding SHIFT and hovering over a link
- When dragging a link for a short amount of time (under a second)
I started with all of those enabled by default to test them out in real world scenarios. I was able to write the backbone for the extension in a day, so I had a working version pretty quick.
After that first day, I started to notice all the problems around Content Security Policy, as this extension uses an <iframe> HTML element inside a page to sometimes open external links, this was a very important problem to solve and if I wouldn’t be able to solve this, extension would be pretty much pointless. For now let’s focus a bit more on the triggers, but I will describe how I did this later in the article, as I want to keep the technical jargon of this introductory section as low as possible.
I was able to figure out pretty quick — just by using the extension with my normal use case — that the CMD/CTRL options were a no go, because they’re frequently used to copy/paste stuff. So, I wasn’t going to annoy all the users with randomly opening the popup every time they press CTRL/CMD. I’ve turned that into an optional parameter in the options page. It’s disabled it by default but still available. Same story goes for SHIFT, because it’s frequently used to write using upper case letters. So, I’ve disabled it by default as well.
Now, after around two weeks of using the extension in real world scenarios, between me and a couple friends, I think the pretty stabile combination of default enabled/disabled values is the following:
- Disabled: CTRL + hover
- Disabled: CMD + hover
- Disabled: SHIFT + hover
- Enabled: ALT + hover
- Enabled: Dragging a link (for less than a second)
After around a week, I noticed that images should be treated a bit differently, so I’ve changed the logic around image handling. Basically, when you open an image in the popup, it will have an option to “zoom” when the image is larger than the popup. Zoom in this scenario actually means displaying the image at full size, instead of limiting it to the width and height of the popup itself.
I’ve tried to come up with a nice UX of zooming and panning, but I expect the current implementation to divide the users, as it tends to feel a little bit glitchy, if you don’t completely understand how it works. When you first open the image, it will limit its dimensions to the popup’s size constraints, but if you click on it to zoom it to 100%, it will activate this zoom panning effect I was talking about. Simply move the cursor on the image and you’ll notice the effect.
The way it works is by determining where the cursor currently is and moving the image inside the popup based on it. The same effect is being used on eBay right now to preview images inside the main small preview. The image zooms in and it moves around based on the position of you cursor inside that small square.
I think it’s very quick and useful in most scenarios, but I’m also considering disabling it by default and adding normal scrollbars if there will be enough complaints about it. I didn’t want to implement it as an option yet, because the options page already feels pretty stuffed.
Regarding the options page, all of the options are being stored via the Chrome Sync storage API, so if you’re logged into your browser and use it on multiple computers, expect it to work the same on all of them.
That’s basically it. If you find it interesting and want to try it out, you can install it from the official Chrome Web Store. If you notice anything strange or have a suggestion on how to improve it, I’d love to hear from you. You can write me here on this article on CodePicky, or using the contact form from the extension’s options page.
If you’re not using Google Chrome or Chromium, there should be extensions for your own browser of choice which allow you to install extensions form the Chrome store directly. The extensions API is largely the same across all browsers based on Chromium (basically all of them, except for Firefox and Safari). Although, I have not tested this in any other browser, so, I cannot guarantee that this will work, at least not 100%.
How it Works
I’d like to try to list all the technical problems I’ve encountered along the way, because they’ve served me as a very nice learning experience for creating browser extensions, which the technically inclined readers of this blog might enjoy.
The Chrome Extensions API are currently undergoing a migration from Manifest V2 to V3. I believe sometime in the third quarter of 2023, V2 will not be accepted anymore in the Web Store. This means that all new extensions should be developed directly on V3.
Now, that’s all good because I like being up to date with the latest versions of the tools I use, but this is pretty bad news from a documentation perspective. And don’t get me wrong, the Chrome Extensions API documentation is solid from a technical standpoint, but in terms of how to put stuff together to actually do a certain thing, you still rely pretty much on Stack Overflow, GitHub Issues, and other such amazing communities.
As you can image, with any major version migration, it’ll take a few years before the answers to certain questions will start to be answered following the current version’s guidelines.
One specific problem I had in the beginning was that I didn’t know how to modify or remove headers from URLs. I needed to be able to do this, because in order to be able to iframe an external domain onto the current website, I needed to disable the CSP headers when they were present.
Now, obviously the first option would be to disable them for all websites without any condition. And you can do that, yes, fairly easily I might add, but you’re also disregarding a couple very important browser protection mechanisms, and I didn’t really want to do that.
So, the next best thing was to try to disable those protections only when an URL is being displayed in the my extension’s popup. In order to do that with Manifest V2, you would’ve used the chrome.webRequest and chrome.webRequestBlocking APIs. But those have been replaced in V3 with chrome.declarativeNetRequest, which is a very different API. Essentially, now you have to define your rules either as JSON or dynamically through the background script (which is now a service worker, BTW.)
The main difference is that before, you could stop a request, run some code to decide if you want to alter it, and then let it continue. Now, you can block it, but you can’t run code anymore, you only have the option to block it if it follows a certain URL pattern. My point here is that the information on how to do stuff like this is either for V2 or non-existent.
Right from the beginning, the main problem was actually displaying the website inside an iframe HTML element. I’ve just mentioned a bit earlier that I had to disable CSP headers to be able to display the website inside the iframe. In addition to those, I’ve also disabled the X-Frame-Options header.
Those headers are not set on most sites, but if they are on some, it ruins the experience of the extension. Therefore, in order to increase the chances of the extension to work on as many websites as possible, I had to add that in.
Now, in order to do that, I’ve gone through multiple approaches and headaches.
The first was to add at the end of the iframed URL a string which would allow me to declare a rule via the declarativeNetRequest API to remove those headers from such links. That worked pretty well as I was adding them at the end of the URLs in the following form:
If the URL contained a hashtag, I would add
If the URL did not contain a hashtag, I would add
Again, this worked pretty well in most (most is a key word here, as you’ll later realise when I’ll refer to solving the short URLs problem) cases I’ve tried, but I just couldn’t live with the thought that this might break apps which are highly dependent on # routing. One of the biggest examples being Google Analytics. I’ve seen that used extensively and I cannot guarantee that the “:iframed-by-prevue” part will be ignored on all sites which rely on hashtag values.
The first alternative I came up with was to do a little bit of back and forth with the background script to dynamically disable those headers right before opening a link and then re-enabling them afterwards.
This is how it worked on every link trigger:
- The Prevue Popup script, which is injected into every web page, instructs the background script to disable X-Frame-Options and a couple CSP related headers for all incoming URLs;
- The background responds when it’s done;
- The in-page script adds the link to the iframe and displays the popup;
- Then it instructs the background script again to delete the declarativeNetRequest rule which it created a few moments ago, which basically re-enables X-Frame-Options and CSP on all URLs.
It seemed like it worked pretty well in practice, even through it introduced a couple milliseconds of delay before opening the URL. For what it’s worth, I didn’t find this to be annoying.
The URL Shorteners Edge Case
When trying it on Twitter, I noticed that it wasn’t working with the tweet links, because the background script is instructed to disable the security headers from the t.co short link which Twitter automatically creates for every tweeted URL.
In order to fix that, I had to add another instruction for the background page to check if the given URL redirects to another URL. Obviously this creates an additional request behind the scenes, to see if the final URL is different than the give one, or if it contains a location header.
Unfortunately, this adds some significant delay when your internet connection is slow or the website is slow.
Anyway, it was loading fine for me on my shiny new laptop and my top speed internet connection. But when I pushed it to the store and asked some people to test it out, it was loading with more than 5 seconds of delay for some of them.
So back to the drawing board on that.
After spending half a day trying to understand the new declarativeNetRequest API, reading the documentation page over and over and trying different small things, I came up with the following approach:
- I created an extension page called iframe.html which is defined as a “web_accessible_resource” in the manifest. I had to do that to be able to iframe that extension page from the context of the website the user is on;
- In iframe.html I have an <iframe> element and a <script> element which loads iframe.js.
- iframe.js fetches from the URL everything that comes after the “?” symbol and decodes that from base64 using the built in function
- Inside the Prevue script, which is embedded into every web page, when the user wants to open a link, I set the popup’s iframe SRC value to
- In background.js, I declared a single declarativeNetRequest rule instructing the browser to remove all HTTP CSP and X-Frame-Options headers from all the sub-frames of URLs matching:
It might not be immediately obvious to you what exactly I’m doing, because (1) it’s following the guidelines of Manifest V3, which, as I was saying, it’s pretty new, or (2) because it’s insane.
I’m creating an iframe inside another iframe and telling the browser to disable all the CSP and X-frame-Options headers for all the frames inside the main frame. And I’m doing all that just to be able to create that declarativeNetRequest only once, instead of doing it every time before and after showing the popup.
Finding the Final URL
All of that solves actually opening a short URL inside the popup, but it doesn’t update the URL in the popup. To solve that, there’s a small injected script which checks if it’s inside an iframe, and if it is, it reports its own URL (window.location.href) to the background script, which in turn reports the URL to the same tab ID it came from.
This has the effect of communicating from the final iframe to the main page which opened the main Prevue popup. When that URL is being received by the main page, it checks to see if it’s different than what it displayed as the URL of the popup, and when it is, it updates it.
If you’re thinking that all of this seems unnecessarily hackish, I’m with you. Although, the reasoning behind all of these restrictions is actually pretty solid and it’s all for the security of the end user, so I’m all for it. It’s just hard to do this right form the extension context.
Later Edit: Had to Change the Framing Logic Once Again
For the sake of understanding how much of a difference Manifest V3 makes, I’m going to leave all the previous information on how I thought I would make this work.
So, as I was saying, I was trying to create the final URL iframe inside a page of the extension, and then use that as the popup iframe URL. The idea was to be able to disable the CPS and X-Frame-Options only below that extension page. Two things here though:
Turns out I was just having some caching issues and this was working only on my local machine. When I pushed to the store and my friends tried it, it didn’t work anymore. I’ve tried it myself from the store and indeed it was not working.
The problem was two fold: (1) you can’t actually specify the rules like that, “disable headers under this url”, and (2) you can’t create rules for the chrome-extension protocol. I’ve tried creating a redirect rule just to test this. Matching a normal domain like youtube.com over https worked fine. Matching a page under chrome-extension://, didn’t.
The new solution I found was to disable CPS and X-Frame-Options on all sites and play with the state of that rule dynamically.
I combined an older attempt with this new one and ended up with the following logic:
- Before opening the popup, I tell the background script to enable that rule which removes the CSP and X-Frame-Options headers from all URLs;
- I open the iframe;
- I set a 10 second delay until I tell the background script to disable the rule.
It’s hackish. I don’t like it. But at least it works.
It’s been more than three weeks since I’ve started working on this and I’ve lost count of how many annoyances I’ve encountered along the way of trying to develop this using the new Manifest V3 APIs.
Breaking Google’s reCAPTCHA
At some point, I managed to break reCAPTCHA’s functionality through the extension. I still don’t know why, but I suspect it was because I was removing the CSP headers. Anyway, this probably took an entire day to figure out.
Reloading the Extension
Kind of a strange problem was that after you’ve changed an option from the options page, you’d have to manually refresh every single open page to reload the injected scripts which make Prevue Popup work.
To solve this, I had to change the extension in two crucial ways:
- I had to make sure that I remove all of the event listeners before re-attaching them again. This makes sure that the browser doesn’t pile up unnecessary listeners. The old listeners don’t work anymore, the browser makes sure to invalidate them, but it still fill up the console with errors regarding this.
- I’m actually re-injecting the code in all tabs every time you save an option and every time the extension updates itself.
This seems a bit too much, but it works very well and it doesn’t require you do to anything else after you change the options. Also, because I took care of the first problem, it doesn’t pile up any event listeners or injected HTML code.
The code of the extension has been made available on GitHub under the GNU GPL v3 licence. Feel free to fork it and contribute to it.
Later Edit: The version for Firefox is now available.