17 | By default, the extension button displays the number of annotations found
18 | on the pages you visit. Disable this if you find the badge distracting or
19 | if you do not wish the extension to make requests to the Hypothesis
20 | service for this data.
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import { babel } from '@rollup/plugin-babel';
2 | import commonjs from '@rollup/plugin-commonjs';
3 | import json from '@rollup/plugin-json';
4 | import { nodeResolve } from '@rollup/plugin-node-resolve';
5 |
6 | export default {
7 | input: 'src/background/index.ts',
8 | output: {
9 | file: 'build/extension.bundle.js',
10 | format: 'iife',
11 |
12 | // Global variable used for entry point exports. This is not actually used,
13 | // but it suppresses a warning from Rollup about accessing exports of an
14 | // IIFE bundle.
15 | name: 'hypothesis',
16 | },
17 | plugins: [
18 | babel({
19 | babelHelpers: 'bundled',
20 | exclude: 'node_modules/**',
21 | extensions: ['.js', '.ts'],
22 | }),
23 | nodeResolve({ extensions: ['.js', '.ts'] }),
24 | commonjs(),
25 | json(),
26 | ],
27 | };
28 |
--------------------------------------------------------------------------------
/.github/workflows/update-client.yml:
--------------------------------------------------------------------------------
1 | name: Update client
2 | on:
3 | workflow_dispatch:
4 | jobs:
5 | update-client:
6 | runs-on: ubuntu-latest
7 | outputs:
8 | ref: ${{ steps.update-client.outputs.ref }}
9 | steps:
10 | - name: Checkout
11 | uses: actions/checkout@v4
12 | - name: Cache the node_modules dir
13 | uses: actions/cache@v4
14 | with:
15 | path: node_modules
16 | key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock') }}
17 | - name: Install
18 | run: yarn install --immutable
19 | - name: Update client
20 | id: update-client
21 | run: |
22 | git config --global user.name "Hypothesis GitHub Actions"
23 | git config --global user.email "hypothesis@users.noreply.github.com"
24 | tools/update-client
25 | echo "ref=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
26 |
--------------------------------------------------------------------------------
/settings/chrome-staging.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildType": "staging",
3 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqjbEOhG+ZCl2Bl17m2ltNC+3uw0Fqv3Dzuja5vLnH1MLBRQG7L77pXtKCZgVgFJ2K+Kn0L0OqnMDcKEi5pUpNTi39b8twp1imDsoLO+L5XgpKYBtUgfR+T8OO2INjEgz0LDth0l26WmHNS377KZjSTsfPWNnLozXHHkETgug1lt9VzgcvSboiyZuwk23xHmiqnVpZtuqVAv4HdqFofHiNQn2fF7awsQxEYYNfuSk0Jp33XJkkadyrJ/dQ7vVFi0F0O//Oyaw3s4TD58frABxznusmKkjHZorJUrm2OaYbn/7TSUcG5fReQC08fXiMsFGUKxK01HfAwdmVUAmASL+NwIDAQAB",
4 |
5 | "apiUrl": "https://staging.hypothes.is/api/",
6 | "authDomain": "staging.hypothes.is",
7 | "bouncerUrl": "https://staging.hyp.is/",
8 | "serviceUrl": "https://staging.hypothes.is/",
9 |
10 | "oauthClientId": "ca0ef8b2-bc24-11ee-b317-433c505e7e42",
11 |
12 | "sentryPublicDSN": "https://934d4f62912b47d8bb03c28ae6670cf8@app.getsentry.com/69811",
13 |
14 | "browserIsChrome": true,
15 | "appType": "chrome-extension"
16 | }
17 |
--------------------------------------------------------------------------------
/tools/update-client:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | CURRENT_BRANCH=$(git branch --show-current)
6 |
7 | if [ "$CURRENT_BRANCH" != "main" ]; then
8 | echo "This command should only be run on the main branch"
9 | exit 1
10 | fi
11 |
12 | # Update Hypothesis client and set the version of the extension to match the
13 | # client release.
14 | yarn add hypothesis --dev
15 |
16 | NEW_CLIENT_VERSION=$(node -p 'require("./package.json").devDependencies.hypothesis.match(/[0-9.]+/)[0]')
17 | TAG_NAME="v$NEW_CLIENT_VERSION"
18 |
19 | yarn version "$NEW_CLIENT_VERSION"
20 | git commit -a -m "Update Hypothesis client to $NEW_CLIENT_VERSION"
21 | git tag "$TAG_NAME"
22 |
23 | # Push the new commit to the source branch as well as the tag. Make the push
24 | # atomic so that both will fail if the source branch has been updated since
25 | # the current checkout.
26 | git push --atomic origin HEAD:main "$TAG_NAME"
27 |
--------------------------------------------------------------------------------
/src/vendor/pdfjs/web/images/annotation-comment.svg:
--------------------------------------------------------------------------------
1 |
2 |
17 |
--------------------------------------------------------------------------------
/vitest.config.js:
--------------------------------------------------------------------------------
1 | import { SummaryReporter } from '@hypothesis/frontend-testing/vitest';
2 | import { playwright } from '@vitest/browser-playwright';
3 | import { defineConfig } from 'vitest/config';
4 | import { excludeFromCoverage } from './rollup-tests.config.js';
5 |
6 | export default defineConfig({
7 | test: {
8 | globals: true,
9 | reporters: [new SummaryReporter()],
10 |
11 | browser: {
12 | provider: playwright(),
13 | enabled: true,
14 | headless: true,
15 | screenshotFailures: false,
16 | instances: [{ browser: 'chromium' }],
17 | viewport: { width: 1024, height: 768 },
18 | },
19 |
20 | include: [
21 | // Test bundle
22 | './build/tests.bundle.js',
23 | ],
24 |
25 | coverage: {
26 | enabled: true,
27 | provider: 'istanbul',
28 | reportsDirectory: './coverage',
29 | reporter: ['json', 'html'],
30 | include: ['src/**/*.{ts,tsx}'],
31 | exclude: excludeFromCoverage,
32 | },
33 | },
34 | });
35 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import { runTests } from '@hypothesis/frontend-build/tests';
3 |
4 | import { spawn } from 'node:child_process';
5 |
6 | import * as gulp from 'gulp';
7 |
8 | function build(cb) {
9 | const make = spawn('make', ['build'], { stdio: 'inherit' });
10 | make.on('close', code => {
11 | if (code !== 0) {
12 | cb(new Error(`make exited with status ${code}`));
13 | } else {
14 | cb(null);
15 | }
16 | });
17 | }
18 |
19 | function watchClient() {
20 | gulp.watch('node_modules/hypothesis', { events: 'all' }, build);
21 | }
22 |
23 | function watchSrc() {
24 | gulp.watch('src', { events: 'all' }, build);
25 | }
26 |
27 | export const watch = gulp.parallel(build, watchClient, watchSrc);
28 |
29 | // Unit and integration testing tasks.
30 | gulp.task('test', () =>
31 | runTests({
32 | bootstrapFile: 'tests/bootstrap.js',
33 | vitestConfig: 'vitest.config.js',
34 | rollupConfig: 'rollup-tests.config.js',
35 | testsPattern: 'tests/**/*-test.js',
36 | }),
37 | );
38 |
--------------------------------------------------------------------------------
/src/vendor/pdfjs/web/images/secondaryToolbarButton-spreadEven.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/vendor/pdfjs/web/images/annotation-paragraph.svg:
--------------------------------------------------------------------------------
1 |
2 |
17 |
--------------------------------------------------------------------------------
/tools/chrome-webstore-refresh-token:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | # This script is a helper for generating the refresh token used to automate
6 | # upload and publishing of Chrome extensions to the Chrome Web Store.
7 | #
8 | # 1. Read the guide at https://github.com/DrewML/chrome-webstore-upload/blob/master/How%20to%20generate%20Google%20API%20keys.md
9 | #
10 | # The Client ID and Client Secret can be found/generated under the 'Client
11 | # Chrome Extension' project in the 'hypothes.is' organization at
12 | # https://console.developers.google.com
13 | #
14 | # 2. After the step in the above guide that gives you an auth code in the
15 | # browser, run this script with the auth code to get a refresh token:
16 | #
17 | # ./tools/chrome-webstore-refresh-token $AUTH_CODE
18 | #
19 | # 3. Copy the 'refresh_token' value and update the corresponding environment
20 | # secrets in GitHub
21 |
22 | AUTH_CODE=$1
23 |
24 | if [ -z "$AUTH_CODE" ]; then
25 | echo "Auth code not specified."
26 | fi
27 |
28 | curl "https://accounts.google.com/o/oauth2/token" -d "client_id=$CHROME_WEBSTORE_CLIENT_ID&client_secret=$CHROME_WEBSTORE_CLIENT_SECRET&code=$AUTH_CODE&grant_type=authorization_code&redirect_uri=urn:ietf:wg:oauth:2.0:oob"
29 |
30 |
--------------------------------------------------------------------------------
/src/vendor/pdfjs/web/images/annotation-note.svg:
--------------------------------------------------------------------------------
1 |
2 |
43 |
--------------------------------------------------------------------------------
/tools/template-context-app.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Outputs a JSON object representing the appropriate template context for the
5 | * `app.html` file.
6 | */
7 |
8 | import * as fs from 'node:fs';
9 | import * as path from 'node:path';
10 |
11 | function appSettings(settings) {
12 | const result = {};
13 | result.apiUrl = settings.apiUrl;
14 | result.assetRoot = '/client/';
15 | result.authDomain = settings.authDomain;
16 | result.serviceUrl = settings.serviceUrl;
17 | result.release = settings.version;
18 | result.appType = settings.appType || '';
19 |
20 | if (settings.sentryPublicDSN) {
21 | result.raven = {
22 | dsn: settings.sentryPublicDSN,
23 | release: settings.version,
24 | };
25 | }
26 |
27 | if (settings.oauthClientId) {
28 | result.oauthClientId = settings.oauthClientId;
29 | }
30 |
31 | return result;
32 | }
33 |
34 | if (process.argv.length !== 3) {
35 | console.error('Usage: %s ', path.basename(process.argv[1]));
36 | process.exit(1);
37 | }
38 |
39 | const settings = JSON.parse(
40 | fs.readFileSync(path.join(process.cwd(), process.argv[2])),
41 | );
42 |
43 | console.log(
44 | JSON.stringify({
45 | settings: JSON.stringify(appSettings(settings)),
46 | }),
47 | );
48 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import hypothesisBase from 'eslint-config-hypothesis/base';
2 | import hypothesisTS from 'eslint-config-hypothesis/ts';
3 | import globals from 'globals';
4 | import { globalIgnores, defineConfig } from 'eslint/config';
5 |
6 | export default defineConfig(
7 | globalIgnores([
8 | '.yalc/**/*',
9 | '.yarn/**/*',
10 | 'build/**/*',
11 | 'dist/**/*',
12 | '**/vendor/**/*.js',
13 | '**/coverage/**/*',
14 | 'docs/_build/*',
15 | // TODO - Lint these files
16 | 'rollup*.config.js',
17 | ]),
18 |
19 | hypothesisBase,
20 | hypothesisTS,
21 |
22 | {
23 | languageOptions: {
24 | globals: {
25 | chrome: false,
26 | },
27 | },
28 | },
29 |
30 | // Entry points which get loaded as non-module scripts.
31 | {
32 | files: ['src/unload-client.js'],
33 | rules: {
34 | strict: ['error', 'global'],
35 | },
36 | languageOptions: {
37 | parserOptions: {
38 | sourceType: 'script',
39 | },
40 | },
41 | },
42 |
43 | // ESM scripts which run in Node
44 | {
45 | files: ['tools/*.js'],
46 | rules: {
47 | 'no-console': 'off',
48 | },
49 | languageOptions: {
50 | globals: {
51 | ...globals.node,
52 | },
53 | },
54 | },
55 | );
56 |
--------------------------------------------------------------------------------
/src/background/messages.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Activate the extension on the tab sending the message.
3 | *
4 | * The extension can optionally redirect the tab to a new URL first, and can
5 | * also configure the client to focus on a specific annotation or group.
6 | */
7 | export type ActivateMessage = {
8 | type: 'activate';
9 |
10 | /** URL to navigate tab to, before activating extension. */
11 | url?: string;
12 |
13 | /**
14 | * Fragment indicating the annotations or groups that the client should
15 | * focus on after loading.
16 | *
17 | * The format of this is the same as the `#annotations:` and related fragments
18 | * understood by the client.
19 | */
20 | query?: string;
21 | };
22 |
23 | /**
24 | * Query whether the extension is installed and what features it supports.
25 | */
26 | export type PingMessage = {
27 | type: 'ping';
28 |
29 | /**
30 | * List of features to test for.
31 | *
32 | * If a feature is supported, it will be present in a `features` array
33 | * in the response. Note this field is missing from the response of older
34 | * extension versions.
35 | */
36 | queryFeatures?: string[];
37 | };
38 |
39 | /**
40 | * Type of a request sent to the extension from an external website,
41 | * such as the bouncer (hyp.is) service.
42 | */
43 | export type ExternalMessage = PingMessage | ActivateMessage;
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2013-2020 Hypothes.is Project and contributors
2 |
3 | Redistribution and use in source and binary forms, with or without
4 | modification, are permitted provided that the following conditions are met:
5 |
6 | 1. Redistributions of source code must retain the above copyright notice, this
7 | list of conditions and the following disclaimer.
8 | 2. Redistributions in binary form must reproduce the above copyright notice,
9 | this list of conditions and the following disclaimer in the documentation
10 | and/or other materials provided with the distribution.
11 |
12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
13 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
14 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
15 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
16 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
17 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
18 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
19 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
20 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
21 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22 |
--------------------------------------------------------------------------------
/tools/render-boot-template.js:
--------------------------------------------------------------------------------
1 | import { readFileSync, writeFileSync } from 'node:fs';
2 |
3 | /**
4 | * Replace placeholders in the client's boot script with real URLs.
5 | *
6 | * Placeholders are single or double-quoted string literals of the form
7 | * `"__VARIABLE_NAME__"`.
8 | */
9 | export function renderBootTemplate(src, dest) {
10 | const getURLCode = path => `chrome.runtime.getURL("${path}")`;
11 |
12 | const assetRoot = getURLCode('/client/');
13 | const notebookAppUrl = getURLCode('/client/notebook.html');
14 | const profileAppUrl = getURLCode('/client/profile.html');
15 | const sidebarAppUrl = getURLCode('/client/app.html');
16 |
17 | const replacements = {
18 | __ASSET_ROOT__: assetRoot,
19 | __NOTEBOOK_APP_URL__: notebookAppUrl,
20 | __PROFILE_APP_URL__: profileAppUrl,
21 | __SIDEBAR_APP_URL__: sidebarAppUrl,
22 | };
23 | const template = readFileSync(src, { encoding: 'utf8' });
24 | const bootScript = template.replaceAll(
25 | /"(__[A-Z_0-9]+__)"|'(__[A-Z_0-9]+__)'/g,
26 | (match, doubleQuoted, singleQuoted) => {
27 | const name = doubleQuoted || singleQuoted;
28 | if (!Object.hasOwn(replacements, name)) {
29 | throw new Error(`Unknown placeholder "${name}" in boot template`);
30 | }
31 | return replacements[name];
32 | },
33 | );
34 | writeFileSync(dest, bootScript);
35 | }
36 |
37 | const src = process.argv[2];
38 | const dest = process.argv[3];
39 | renderBootTemplate(src, dest);
40 |
--------------------------------------------------------------------------------
/src/vendor/pdfjs/web/images/annotation-key.svg:
--------------------------------------------------------------------------------
1 |
2 |
12 |
--------------------------------------------------------------------------------
/src/vendor/pdfjs/web/images/loading.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/background/help-page.ts:
--------------------------------------------------------------------------------
1 | import { chromeAPI } from './chrome-api';
2 | import {
3 | BlockedSiteError,
4 | LocalFileError,
5 | NoFileAccessError,
6 | RestrictedProtocolError,
7 | } from './errors';
8 |
9 | /**
10 | * A controller for displaying help pages. These are bound to extension
11 | * specific errors (found in errors.js) but can also be triggered manually.
12 | */
13 | export class HelpPage {
14 | /**
15 | * Accepts an instance of errors.ExtensionError and displays an appropriate
16 | * help page if one exists.
17 | *
18 | * @param tab - The tab where the error occurred
19 | * @param error - The error to display, usually an instance of {@link ExtensionError}
20 | */
21 | showHelpForError(tab: chrome.tabs.Tab, error: Error) {
22 | let section;
23 | if (error instanceof LocalFileError) {
24 | section = 'local-file';
25 | } else if (error instanceof NoFileAccessError) {
26 | section = 'no-file-access';
27 | } else if (error instanceof RestrictedProtocolError) {
28 | section = 'restricted-protocol';
29 | } else if (error instanceof BlockedSiteError) {
30 | section = 'blocked-site';
31 | } else {
32 | section = 'other-error';
33 | }
34 |
35 | const url = new URL(chromeAPI.runtime.getURL('/help/index.html'));
36 | if (error) {
37 | url.searchParams.append('message', error.message);
38 | }
39 | url.hash = section;
40 |
41 | chromeAPI.tabs.create({
42 | index: tab.index + 1,
43 | url: url.toString(),
44 | openerTabId: tab.id,
45 | });
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/docs/troubleshooting.md:
--------------------------------------------------------------------------------
1 | Troubleshooting
2 | ---------------
3 |
4 | Here are some errors you might encounter while developing the extension, and
5 | some explanations and solutions for them.
6 |
7 | ### Mixed Content errors in the console
8 |
9 | The extension fails to load and you see `Mixed Content` errors in the console.
10 | When using the extension on sites served over HTTPS, the extension must be
11 | configured to use a HTTPS `serviceUrl` in its settings file.
12 |
13 | ### Insecure Response errors in the console
14 |
15 | You've built the extension with an HTTPS `serviceUrl`, the extension fails to
16 | load and you see `net::ERR_INSECURE_RESPONSE` errors in the console. You need to
17 | open (or whatever `serviceUrl` you provided) and tell
18 | the browser to allow access to the site even though the certificate isn't known.
19 |
20 | ### Empty Response errors in the console
21 |
22 | The extension fails to load and you see `GET http://localhost:5000/...
23 | net::ERR_EMPTY_RESPONSE` errors in the console. This can happen if you're
24 | running an HTTPS-only service but you've built the extension with an HTTP
25 | `serviceUrl`. Either run the service on HTTP or rebuild the extension with the
26 | correct settings.
27 |
28 | ### Connection Refused errors in the console
29 |
30 | The extension fails to load and you see `GET https://localhost:5000/...
31 | net::ERR_CONNECTION_REFUSED` errors in the console. This can happen if you're
32 | running an HTTP-only service but you've built the extension with an HTTPS
33 | `serviceUrl`. Either run the service on HTTPS or rebuild the extension with the
34 | correct settings.
35 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | concurrency:
4 | group: ${{ github.event.repository.name }}-deploy
5 | cancel-in-progress: true
6 |
7 | on:
8 | workflow_call:
9 | inputs:
10 | ref:
11 | description: 'Git commit to release'
12 | required: false
13 | type: string
14 | workflow_dispatch:
15 |
16 | jobs:
17 | continuous-integration:
18 | uses: ./.github/workflows/continuous-integration.yml
19 | name: continuous integration
20 | with:
21 | ref: ${{ inputs.ref }}
22 |
23 | upload-packages:
24 | needs: continuous-integration
25 | runs-on: ubuntu-latest
26 | environment: production
27 |
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v4
31 | with:
32 | ref: ${{ inputs.ref }}
33 | - name: Cache the node_modules dir
34 | uses: actions/cache@v4
35 | with:
36 | path: node_modules
37 | key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock') }}
38 | - name: Install
39 | run: yarn install --immutable
40 | - name: Fetch packages
41 | uses: actions/download-artifact@v4
42 | with:
43 | name: packages
44 | path: dist/
45 | - name: Upload packages
46 | env:
47 | CHROME_WEBSTORE_CLIENT_ID: ${{ secrets.CHROME_WEBSTORE_CLIENT_ID }}
48 | CHROME_WEBSTORE_CLIENT_SECRET: ${{ secrets.CHROME_WEBSTORE_CLIENT_SECRET }}
49 | CHROME_WEBSTORE_REFRESH_TOKEN: ${{ secrets.CHROME_WEBSTORE_REFRESH_TOKEN }}
50 | FIREFOX_AMO_KEY: ${{ secrets.FIREFOX_AMO_KEY }}
51 | FIREFOX_AMO_SECRET: ${{ secrets.FIREFOX_AMO_SECRET }}
52 | run: tools/deploy
53 |
--------------------------------------------------------------------------------
/src/vendor/pdfjs/web/standard_fonts/LICENSE_FOXIT:
--------------------------------------------------------------------------------
1 | // Copyright 2014 PDFium Authors. All rights reserved.
2 | //
3 | // Redistribution and use in source and binary forms, with or without
4 | // modification, are permitted provided that the following conditions are
5 | // met:
6 | //
7 | // * Redistributions of source code must retain the above copyright
8 | // notice, this list of conditions and the following disclaimer.
9 | // * Redistributions in binary form must reproduce the above
10 | // copyright notice, this list of conditions and the following disclaimer
11 | // in the documentation and/or other materials provided with the
12 | // distribution.
13 | // * Neither the name of Google Inc. nor the names of its
14 | // contributors may be used to endorse or promote products derived from
15 | // this software without specific prior written permission.
16 | //
17 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/src/vendor/pdfjs/web/images/loading-dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/pdfjs-init.js:
--------------------------------------------------------------------------------
1 | /* global PDFViewerApplication */
2 |
3 | // This script is run once PDF.js has loaded and it configures the viewer
4 | // and injects the Hypothesis client.
5 |
6 | async function init() {
7 | const configPromise = chrome.runtime.sendMessage(chrome.runtime.id, {
8 | type: 'getConfigForTab',
9 | });
10 |
11 | const viewerLoaded = new Promise(resolve => {
12 | // See https://github.com/mozilla/pdf.js/wiki/Third-party-viewer-usage
13 | document.addEventListener('webviewerloaded', () => {
14 | // Wait for the PDF viewer to be fully initialized before loading the client.
15 | // Note that the PDF may still be loading after initialization.
16 | //
17 | // @ts-expect-error - PDFViewerApplication is missing from types.
18 | PDFViewerApplication.initializedPromise.then(resolve);
19 | });
20 | });
21 |
22 | // Concurrently request Hypothesis client config and listen for PDF.js
23 | // to finish initializing.
24 | const [config] = await Promise.all([configPromise, viewerLoaded]);
25 |
26 | const configScript = document.createElement('script');
27 | configScript.type = 'application/json';
28 | configScript.className = 'js-hypothesis-config';
29 | configScript.textContent = JSON.stringify(config);
30 |
31 | // This ensures the client removes the script when the extension is deactivated
32 | configScript.setAttribute('data-remove-on-unload', '');
33 | // The boot script expects this attribute when running from the browser extension
34 | configScript.setAttribute('data-extension-id', chrome.runtime.id);
35 |
36 | document.head.appendChild(configScript);
37 |
38 | const embedScript = document.createElement('script');
39 | embedScript.src = '/client/build/boot.js';
40 | document.body.appendChild(embedScript);
41 | }
42 |
43 | init();
44 |
--------------------------------------------------------------------------------
/rollup-tests.config.js:
--------------------------------------------------------------------------------
1 | import glob from 'glob';
2 | import alias from '@rollup/plugin-alias';
3 | import { babel } from '@rollup/plugin-babel';
4 | import commonjs from '@rollup/plugin-commonjs';
5 | import json from '@rollup/plugin-json';
6 | import multi from '@rollup/plugin-multi-entry';
7 | import replace from '@rollup/plugin-replace';
8 | import { nodeResolve } from '@rollup/plugin-node-resolve';
9 | import { vitestCoverageOptions } from '@hypothesis/frontend-testing/vitest';
10 |
11 | export const excludeFromCoverage = [
12 | '**/node_modules/**',
13 | '**/test/**/*.js',
14 | '**/test-util/**',
15 | ];
16 |
17 | export default {
18 | input: ['tests/bootstrap.js', ...glob.sync('tests/**/*-test.js')],
19 | output: {
20 | file: 'build/tests.bundle.js',
21 | format: 'es',
22 | sourcemap: true,
23 | },
24 | treeshake: false, // Disabled for build performance
25 | plugins: [
26 | alias({
27 | entries: [
28 | {
29 | find: '../../build/settings.json',
30 | replacement: '../../tests/settings.json',
31 | },
32 | ],
33 | }),
34 | babel({
35 | babelHelpers: 'bundled',
36 | exclude: 'node_modules/**',
37 | extensions: ['.js', '.ts'],
38 | plugins: [
39 | [
40 | 'mockable-imports',
41 | {
42 | excludeDirs: ['tests'],
43 | },
44 | ],
45 | [
46 | 'babel-plugin-istanbul',
47 | {
48 | ...vitestCoverageOptions,
49 | exclude: excludeFromCoverage,
50 | },
51 | ],
52 | ],
53 | }),
54 | replace({
55 | preventAssignment: true,
56 | EXTENSION_TESTS: 'true',
57 | }),
58 | nodeResolve({ extensions: ['.js', '.ts'] }),
59 | commonjs(),
60 | json(),
61 | multi(),
62 | ],
63 | };
64 |
--------------------------------------------------------------------------------
/tests/background/errors-test.js:
--------------------------------------------------------------------------------
1 | import * as errors from '../../src/background/errors';
2 |
3 | describe('errors', () => {
4 | beforeEach(() => {
5 | sinon.stub(console, 'error');
6 | });
7 |
8 | afterEach(() => {
9 | console.error.restore();
10 | });
11 |
12 | describe('#shouldIgnoreInjectionError', () => {
13 | const ignoredErrors = [
14 | 'The tab was closed',
15 | 'No tab with id 42',
16 | 'Cannot access contents of url "file:///C:/t/cpp.pdf". ' +
17 | 'Extension manifest must request permission to access this host.',
18 | 'Cannot access contents of page',
19 | 'The extensions gallery cannot be scripted.',
20 | ];
21 |
22 | const unexpectedErrors = ['SyntaxError: A typo'];
23 |
24 | it('should be true for "expected" errors', () => {
25 | ignoredErrors.forEach(message => {
26 | const error = { message };
27 | assert.isTrue(errors.shouldIgnoreInjectionError(error));
28 | });
29 | });
30 |
31 | it('should be false for unexpected errors', () => {
32 | unexpectedErrors.forEach(message => {
33 | const error = { message };
34 | assert.isFalse(errors.shouldIgnoreInjectionError(error));
35 | });
36 | });
37 |
38 | it("should be true for the extension's custom error classes", () => {
39 | const error = new errors.LocalFileError('some message');
40 | assert.isTrue(errors.shouldIgnoreInjectionError(error));
41 | });
42 | });
43 |
44 | describe('#report', () => {
45 | it('logs errors', () => {
46 | const error = new Error('A most unexpected error');
47 | errors.report(error, 'injecting the sidebar', { foo: 'bar' });
48 | assert.calledWith(console.error, 'injecting the sidebar', error, {
49 | foo: 'bar',
50 | });
51 | });
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/src/background/direct-link-query.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Subset of the client configuration which causes the client to show a
3 | * particular set of annotations automatically after it loads.
4 | *
5 | * See https://h-client.readthedocs.io/en/latest/publishers/config/#config-settings
6 | */
7 | export type Query = {
8 | /** ID of the direct-linked annotation. */
9 | annotations?: string;
10 | /** Filter query for the sidebar. */
11 | query?: string;
12 | /** ID of the direct-linked group. */
13 | group?: string;
14 | };
15 |
16 | /**
17 | * Extracts the direct-linking query from the URL if any.
18 | *
19 | * If present, the query causes the extension to activate automatically and
20 | * show the matching set of annotations.
21 | *
22 | * @param url -
23 | * The URL which may contain a '#annotations:' fragment specifying which
24 | * annotations to show.
25 | * @return - The direct link query translated into client configuration settings.
26 | */
27 | export function directLinkQuery(url: string): Query | null {
28 | // Annotation IDs are url-safe-base64 identifiers
29 | // See https://tools.ietf.org/html/rfc4648#page-7
30 | const idMatch = url.match(/#annotations:([A-Za-z0-9_-]+)$/);
31 | if (idMatch) {
32 | return { annotations: idMatch[1] };
33 | }
34 |
35 | const queryMatch = url.match(/#annotations:query:(.*)$/);
36 | if (queryMatch) {
37 | const query = decodeURIComponent(queryMatch[1]);
38 | return { query };
39 | }
40 |
41 | // Group IDs (and other "pubids" in h) are a subset of ASCII letters and
42 | // digits. As a special exception, the "Public" group has underscores in its
43 | // ID ("__world__").
44 | const groupMatch = url.match(/#annotations:group:([A-Za-z0-9_]+)$/);
45 | if (groupMatch) {
46 | return { group: groupMatch[1] };
47 | }
48 |
49 | return null;
50 | }
51 |
--------------------------------------------------------------------------------
/tests/background/direct-link-query-test.js:
--------------------------------------------------------------------------------
1 | import { directLinkQuery } from '../../src/background/direct-link-query';
2 |
3 | describe('common.direct-link-query', () => {
4 | it('returns `null` if the URL contains no #annotations fragment', () => {
5 | const url = 'https://example.com';
6 | assert.equal(directLinkQuery(url), null);
7 | });
8 |
9 | it('returns the annotation ID if the URL contains a #annotations: fragment', () => {
10 | const url = 'https://example.com/#annotations:1234';
11 | assert.deepEqual(directLinkQuery(url), {
12 | annotations: '1234',
13 | });
14 | });
15 |
16 | it('does not return annotation ID if it is invalid', () => {
17 | // "invalid" here refers only to the character set, not whether the annotation
18 | // actually exists.
19 | const url = 'https://example.com/#annotations:[foo]';
20 | assert.equal(directLinkQuery(url), null);
21 | });
22 |
23 | it('returns the query if the URL contains a #annotations:query: fragment', () => {
24 | const url = 'https://example.com/#annotations:query:user%3Ajsmith';
25 | assert.deepEqual(directLinkQuery(url), {
26 | query: 'user:jsmith',
27 | });
28 | });
29 |
30 | ['123', 'abcDEF456', '__world__'].forEach(groupId => {
31 | it('returns the group ID if the URL contains a #annotations:group: fragment', () => {
32 | const url = `https://example.com/#annotations:group:${groupId}`;
33 | assert.deepEqual(directLinkQuery(url), {
34 | group: groupId,
35 | });
36 | });
37 | });
38 |
39 | it('does not return group ID if it is invalid', () => {
40 | // "invalid" here refers only to the character set, not whether the group
41 | // actually exists.
42 | const url = 'https://example.com/#annotations:group:%%%';
43 | assert.deepEqual(directLinkQuery(url), null);
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/tests/background/detect-content-type-test.js:
--------------------------------------------------------------------------------
1 | import { detectContentType } from '../../src/background/detect-content-type';
2 |
3 | describe('detectContentType', () => {
4 | const sandbox = sinon.createSandbox();
5 |
6 | let el;
7 | beforeEach(() => {
8 | el = document.createElement('div');
9 | document.body.appendChild(el);
10 | });
11 |
12 | afterEach(() => {
13 | el.parentElement.removeChild(el);
14 |
15 | sandbox.restore();
16 | });
17 |
18 | it('returns HTML by default', () => {
19 | el.innerHTML = '';
20 | assert.deepEqual(detectContentType(), { type: 'HTML' });
21 | });
22 |
23 | it('returns "PDF" if Google Chrome PDF viewer is present', () => {
24 | el.innerHTML = '';
25 | assert.deepEqual(detectContentType(), { type: 'PDF' });
26 | });
27 |
28 | it('returns "PDF" if Chrome\'s OOPIF PDF viewer is present', () => {
29 | const fakeOpenOrClosedShadowRoot = sinon.stub();
30 | vi.stubGlobal('chrome', {
31 | dom: {
32 | openOrClosedShadowRoot: fakeOpenOrClosedShadowRoot,
33 | },
34 | });
35 |
36 | try {
37 | const dummyElement = document.createElement('div');
38 | const pdfViewer = document.createElement('iframe');
39 | pdfViewer.setAttribute('type', 'application/pdf');
40 | const fakeBodyShadowRoot = dummyElement.attachShadow({ mode: 'open' });
41 | fakeBodyShadowRoot.append(pdfViewer);
42 |
43 | fakeOpenOrClosedShadowRoot
44 | .withArgs(document.body)
45 | .returns(fakeBodyShadowRoot);
46 |
47 | assert.deepEqual(detectContentType(), { type: 'PDF' });
48 | } finally {
49 | vi.unstubAllGlobals();
50 | }
51 | });
52 |
53 | it('returns "PDF" if Firefox PDF viewer is present', () => {
54 | const fakeDocument = {
55 | querySelector: function () {
56 | return null;
57 | },
58 | baseURI: 'resource://pdf.js',
59 | };
60 | assert.deepEqual(detectContentType(fakeDocument), { type: 'PDF' });
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hypothesis-browser-extension",
3 | "version": "1.1734.0",
4 | "private": true,
5 | "type": "module",
6 | "description": "Annotate with anyone, anywhere.",
7 | "license": "BSD-2-Clause",
8 | "homepage": "https://hypothes.is",
9 | "bugs": "https://github.com/hypothesis/browser-extension/issues",
10 | "repository": "hypothesis/browser-extension",
11 | "devDependencies": {
12 | "@babel/core": "^7.28.5",
13 | "@babel/preset-env": "^7.28.5",
14 | "@babel/preset-typescript": "^7.28.5",
15 | "@hypothesis/frontend-build": "^5.1.0",
16 | "@hypothesis/frontend-testing": "^1.8.0",
17 | "@rollup/plugin-alias": "^6.0.0",
18 | "@rollup/plugin-babel": "^6.1.0",
19 | "@rollup/plugin-commonjs": "^29.0.0",
20 | "@rollup/plugin-json": "^6.1.0",
21 | "@rollup/plugin-multi-entry": "^7.1.0",
22 | "@rollup/plugin-node-resolve": "^16.0.3",
23 | "@rollup/plugin-replace": "^6.0.3",
24 | "@types/chrome": "^0.1.32",
25 | "@vitest/browser": "^4.0.15",
26 | "@vitest/browser-playwright": "^4.0.15",
27 | "@vitest/coverage-istanbul": "^4.0.15",
28 | "@vitest/eslint-plugin": "^1.5.2",
29 | "babel-plugin-istanbul": "^7.0.1",
30 | "babel-plugin-mockable-imports": "^2.0.1",
31 | "chai": "^6.2.1",
32 | "chrome-webstore-upload-cli": "^3.5.0",
33 | "eslint": "^9.39.1",
34 | "eslint-config-hypothesis": "^3.3.1",
35 | "git-describe": "^4.1.1",
36 | "globals": "^16.5.0",
37 | "gulp": "^5.0.1",
38 | "hypothesis": "^1.1734.0",
39 | "is-equal-shallow": "^0.1.3",
40 | "mustache": "^4.2.0",
41 | "playwright": "^1.57.0",
42 | "prettier": "3.7.4",
43 | "rollup": "^4.53.3",
44 | "sinon": "^21.0.0",
45 | "typescript": "^5.9.3",
46 | "typescript-eslint": "^8.48.1",
47 | "vitest": "^4.0.15",
48 | "web-ext": "^9.2.0"
49 | },
50 | "browserslist": "chrome 85, firefox 75",
51 | "scripts": {
52 | "lint": "eslint --cache .",
53 | "test": "gulp test",
54 | "typecheck": "tsc --build src/tsconfig.json"
55 | },
56 | "packageManager": "yarn@3.6.1"
57 | }
58 |
--------------------------------------------------------------------------------
/tools/settings.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Outputs a JSON object representing the extension settings based on a settings
5 | * file and the package environment.
6 | */
7 |
8 | import * as fs from 'node:fs';
9 | import * as path from 'node:path';
10 |
11 | import gitDescribe from 'git-describe';
12 | const { gitDescribeSync } = gitDescribe;
13 |
14 | // Suppress (expected) EPIPE errors on STDOUT
15 | process.stdout.on('error', err => {
16 | if (err.code === 'EPIPE') {
17 | process.exit();
18 | }
19 | });
20 |
21 | /**
22 | * getVersion fetches the current version from git, applying the following
23 | * rules:
24 | *
25 | * - If buildType is 'production' and the git state is not clean, throw an error
26 | * - Set the version number to X.Y.Z.W, where X.Y.Z is the last tagged release
27 | * and W is the number of commits since that release.
28 | * - If the buildType is 'production', set the version name to "Official Build",
29 | * otherwise set it to a string of the form "gXXXXXXX[.dirty]" to reflect the
30 | * exact commit and state of the repository.
31 | */
32 | function getVersion(buildType) {
33 | const gitInfo = gitDescribeSync();
34 |
35 | if (buildType === 'production' && gitInfo.dirty) {
36 | throw new Error('cannot create production build with dirty git state!');
37 | }
38 |
39 | const version = `${gitInfo.semver}.${gitInfo.distance}`;
40 | let versionName = 'Official Build';
41 |
42 | if (buildType !== 'production') {
43 | versionName = `${gitInfo.hash}${gitInfo.dirty ? '.dirty' : ''}`;
44 | }
45 |
46 | return { version, versionName };
47 | }
48 |
49 | if (process.argv.length !== 3) {
50 | console.error('Usage: %s ', path.basename(process.argv[1]));
51 | process.exit(1);
52 | }
53 |
54 | const settings = JSON.parse(
55 | fs.readFileSync(path.join(process.cwd(), process.argv[2])),
56 | );
57 | const settingsOut = {
58 | ...settings,
59 | ...getVersion(settings.buildType),
60 | };
61 |
62 | if (settingsOut.sentryPublicDSN) {
63 | settingsOut.raven = {
64 | dsn: settingsOut.sentryPublicDSN,
65 | release: settingsOut.version,
66 | };
67 | }
68 |
69 | console.log(JSON.stringify(settingsOut));
70 |
--------------------------------------------------------------------------------
/.github/workflows/continuous-integration.yml:
--------------------------------------------------------------------------------
1 | name: Continuous integration
2 | on:
3 | pull_request:
4 | workflow_call:
5 | inputs:
6 | ref:
7 | description: 'Git commit to build and test'
8 | required: false
9 | type: string
10 | jobs:
11 | ci:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 | with:
17 | ref: ${{ inputs.ref }}
18 | - name: Cache the node_modules dir
19 | uses: actions/cache@v4
20 | with:
21 | path: node_modules
22 | key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock') }}
23 | - name: Install
24 | run: yarn install --immutable && yarn playwright install chromium
25 | - name: Format
26 | run: make checkformatting
27 | - name: Lint & typecheck
28 | run: make lint
29 | - name: Test
30 | run: make test
31 | - name: Fetch git tags
32 | run: |
33 | # Fetch tags because `git describe` uses them and the output from `git describe`
34 | # is in turn used to produce the extension version number in `build/manifest.json`.
35 | #
36 | # GitHub does a shallow clone by default, so we have to un-shallow it for
37 | # `git describe` to work.
38 | git fetch --quiet --tags --unshallow
39 |
40 | # Show version information in the build logs. This command will also be
41 | # used by `tools/settings.js` to generate the extension version.
42 | git describe --tags
43 | - name: Build packages
44 | run: |
45 | make clean # Remove assets from test step
46 | make build SETTINGS_FILE=settings/chrome-staging.json dist/ci-chrome-staging.zip
47 | make build SETTINGS_FILE=settings/chrome-prod.json dist/ci-chrome-prod.zip
48 | make build SETTINGS_FILE=settings/firefox-staging.json dist/ci-firefox-staging.xpi
49 | make build SETTINGS_FILE=settings/firefox-prod.json dist/ci-firefox-prod.xpi
50 | - name: Archive packages
51 | uses: actions/upload-artifact@v4
52 | with:
53 | name: packages
54 | path: |
55 | dist/*.zip
56 | dist/*.xpi
57 | retention-days: 30
58 |
--------------------------------------------------------------------------------
/src/vendor/pdfjs/web/cmaps/LICENSE:
--------------------------------------------------------------------------------
1 | %%Copyright: -----------------------------------------------------------
2 | %%Copyright: Copyright 1990-2009 Adobe Systems Incorporated.
3 | %%Copyright: All rights reserved.
4 | %%Copyright:
5 | %%Copyright: Redistribution and use in source and binary forms, with or
6 | %%Copyright: without modification, are permitted provided that the
7 | %%Copyright: following conditions are met:
8 | %%Copyright:
9 | %%Copyright: Redistributions of source code must retain the above
10 | %%Copyright: copyright notice, this list of conditions and the following
11 | %%Copyright: disclaimer.
12 | %%Copyright:
13 | %%Copyright: Redistributions in binary form must reproduce the above
14 | %%Copyright: copyright notice, this list of conditions and the following
15 | %%Copyright: disclaimer in the documentation and/or other materials
16 | %%Copyright: provided with the distribution.
17 | %%Copyright:
18 | %%Copyright: Neither the name of Adobe Systems Incorporated nor the names
19 | %%Copyright: of its contributors may be used to endorse or promote
20 | %%Copyright: products derived from this software without specific prior
21 | %%Copyright: written permission.
22 | %%Copyright:
23 | %%Copyright: THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
24 | %%Copyright: CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
25 | %%Copyright: INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
26 | %%Copyright: MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
27 | %%Copyright: DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
28 | %%Copyright: CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
29 | %%Copyright: SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
30 | %%Copyright: NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
31 | %%Copyright: LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
32 | %%Copyright: HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
33 | %%Copyright: CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
34 | %%Copyright: OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
35 | %%Copyright: SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36 | %%Copyright: -----------------------------------------------------------
37 |
--------------------------------------------------------------------------------
/src/vendor/pdfjs/web/images/annotation-help.svg:
--------------------------------------------------------------------------------
1 |
2 |
27 |
--------------------------------------------------------------------------------
/tests/background/help-page-test.js:
--------------------------------------------------------------------------------
1 | import * as errors from '../../src/background/errors';
2 | import { HelpPage, $imports } from '../../src/background/help-page';
3 |
4 | describe('HelpPage', () => {
5 | let fakeChromeTabs;
6 | let fakeExtensionURL;
7 | let help;
8 |
9 | beforeEach(() => {
10 | fakeChromeTabs = { create: sinon.stub() };
11 | fakeExtensionURL = path => `chrome://abcd${path}`;
12 |
13 | $imports.$mock({
14 | './chrome-api': {
15 | chromeAPI: {
16 | runtime: { getURL: fakeExtensionURL },
17 | tabs: fakeChromeTabs,
18 | },
19 | },
20 | });
21 |
22 | help = new HelpPage();
23 | });
24 |
25 | afterEach(() => {
26 | $imports.$restore();
27 | });
28 |
29 | describe('showHelpForError', () => {
30 | [
31 | {
32 | getError: () => new errors.LocalFileError('msg'),
33 | helpSection: 'local-file',
34 | },
35 | {
36 | getError: () => new errors.NoFileAccessError('msg'),
37 | helpSection: 'no-file-access',
38 | },
39 | {
40 | getError: () => new errors.RestrictedProtocolError('msg'),
41 | helpSection: 'restricted-protocol',
42 | },
43 | {
44 | getError: () => new errors.BlockedSiteError('msg'),
45 | helpSection: 'blocked-site',
46 | },
47 | ].forEach(({ getError, helpSection }) => {
48 | it('shows appropriate page for the error', () => {
49 | help.showHelpForError({ id: 1, index: 1 }, getError());
50 | assert.called(fakeChromeTabs.create);
51 | assert.calledWith(fakeChromeTabs.create, {
52 | index: 2,
53 | openerTabId: 1,
54 | url: fakeExtensionURL(`/help/index.html?message=msg#${helpSection}`),
55 | });
56 | });
57 | });
58 |
59 | it('renders the "other-error" page for unknown errors', () => {
60 | help.showHelpForError({ id: 1, index: 1 }, new Error('Unexpected Error'));
61 | assert.called(fakeChromeTabs.create);
62 | assert.calledWith(fakeChromeTabs.create, {
63 | index: 2,
64 | openerTabId: 1,
65 | url: fakeExtensionURL(
66 | '/help/index.html?message=Unexpected+Error#other-error',
67 | ),
68 | });
69 | });
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/src/manifest.json.mustache:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Hypothesis - Web & PDF Annotation",
3 | "short_name": "Hypothesis",
4 | "version": "{{ version }}",
5 | {{#browserIsChrome}}
6 | "version_name": "{{ version }} ({{ versionName }})",
7 | {{/browserIsChrome}}
8 | "manifest_version": 3,
9 |
10 | {{#browserIsChrome}}
11 | "minimum_chrome_version": "88",
12 | {{/browserIsChrome}}
13 |
14 | {{#key}}
15 | "key": "{{{ key }}}",
16 | {{/key}}
17 |
18 | {{#browserIsFirefox}}
19 | "applications": {
20 | "gecko": {
21 | "strict_min_version": "68.0"
22 | }
23 | },
24 | {{/browserIsFirefox}}
25 |
26 | "description": "Collaboratively annotate, highlight, and tag web pages and PDF documents.",
27 | "icons": {
28 | "16": "images/icon16.png",
29 | "48": "images/icon48.png",
30 | "128": "images/icon128.png"
31 | },
32 |
33 | "homepage_url": "https://hypothes.is/",
34 |
35 | {{! Firefox does not support the "split" mode.
36 | See https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/incognito
37 | }}
38 | {{#browserIsChrome}}
39 | "incognito": "split",
40 | {{/browserIsChrome}}
41 |
42 | "options_ui": {
43 | "page": "options/index.html"
44 | },
45 |
46 | {{#browserIsChrome}}
47 | "offline_enabled": false,
48 | {{/browserIsChrome}}
49 |
50 | "permissions": [
51 | "scripting",
52 | "storage",
53 | "tabs"
54 | ],
55 |
56 | "host_permissions": [""],
57 |
58 | "optional_permissions": [
59 | {{! Used to enumerate frames on certain websites. }}
60 | "webNavigation"
61 | ],
62 |
63 | "background": {
64 | "service_worker": "extension.bundle.js"
65 | },
66 |
67 | "action": {
68 | "default_icon": {
69 | "19": "images/browser-icon-inactive.png",
70 | "38": "images/browser-icon-inactive@2x.png"
71 | }
72 | },
73 |
74 | {{#browserIsChrome}}
75 | "externally_connectable": {
76 | {{#bouncerUrl}}"matches": ["{{&bouncerUrl}}*"]{{/bouncerUrl}}
77 | },
78 | {{/browserIsChrome}}
79 |
80 | "web_accessible_resources": [
81 | {
82 | "resources": [
83 | "client/*",
84 | "help/*",
85 | "pdfjs/*",
86 | "pdfjs/web/viewer.html"
87 | ],
88 | "matches": [""]
89 | }
90 | ]
91 | }
92 |
--------------------------------------------------------------------------------
/src/background/uri-info.ts:
--------------------------------------------------------------------------------
1 | import settings from './settings';
2 | import { BadgeUriError } from './errors';
3 |
4 | const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
5 |
6 | // The following sites are personal in nature, high potential traffic
7 | // and URLs don't correspond to identifiable content
8 | const BLOCKED_HOSTNAMES = new Set([
9 | 'facebook.com',
10 | 'www.facebook.com',
11 | 'mail.google.com',
12 | ]);
13 |
14 | /**
15 | * Encodes a string for use in a query parameter.
16 | */
17 | function encodeUriQuery(val: string) {
18 | return encodeURIComponent(val).replace(/%20/g, '+');
19 | }
20 |
21 | /**
22 | * Returns a normalized version of URI for use in badge requests, or throws BadgeUrlError
23 | * if badge requests cannot be made for the given URL
24 | *
25 | * The normalization consist on (1) adding a final '/' at the end of the URL and
26 | * (2) removing the fragment from the URL. The URL fragment can be ignored as it
27 | * will result the same badge count.
28 | *
29 | * In addition, this normalization facilitates the identification of unique URLs.
30 | *
31 | * @return URL without fragment
32 | * @throws Will throw if URL is invalid or should not be sent to the 'badge' request endpoint
33 | */
34 | export function uriForBadgeRequest(uri: string) {
35 | const url = new URL(uri);
36 |
37 | if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
38 | throw new BadgeUriError('Blocked protocol');
39 | }
40 |
41 | if (BLOCKED_HOSTNAMES.has(url.hostname)) {
42 | throw new BadgeUriError('Blocked hostname');
43 | }
44 |
45 | url.hash = '';
46 |
47 | return url.toString();
48 | }
49 |
50 | /**
51 | * Queries the Hypothesis service that provides statistics about the annotations
52 | * for a given URL.
53 | *
54 | * @throws Will throw a variety of errors: network, json parsing, or wrong format errors.
55 | */
56 | export async function fetchAnnotationCount(uri: string): Promise {
57 | const response = await fetch(
58 | settings.apiUrl + '/badge?uri=' + encodeUriQuery(uri),
59 | {
60 | credentials: 'include',
61 | },
62 | );
63 |
64 | const data = await response.json();
65 |
66 | if (data && typeof data.total === 'number') {
67 | return data.total;
68 | }
69 |
70 | throw new Error('Unable to parse badge response');
71 | }
72 |
--------------------------------------------------------------------------------
/src/background/errors.ts:
--------------------------------------------------------------------------------
1 | export class ExtensionError extends Error {}
2 |
3 | export class LocalFileError extends ExtensionError {}
4 |
5 | export class NoFileAccessError extends ExtensionError {}
6 |
7 | export class RestrictedProtocolError extends ExtensionError {}
8 |
9 | export class BlockedSiteError extends ExtensionError {}
10 |
11 | export class AlreadyInjectedError extends ExtensionError {}
12 |
13 | export class RequestCanceledError extends Error {}
14 |
15 | export class BadgeUriError extends Error {}
16 |
17 | /**
18 | * Returns true if `err` is a recognized 'expected' error.
19 | */
20 | function isKnownError(err: unknown) {
21 | return err instanceof ExtensionError;
22 | }
23 |
24 | const IGNORED_ERRORS = [
25 | // Errors that can happen when the tab is closed during injection
26 | /The tab was closed/,
27 | /No tab with id.*/,
28 | // Attempts to access pages for which Chrome does not allow scripting
29 | /Cannot access contents of.*/,
30 | /The extensions gallery cannot be scripted/,
31 | // The extension is disabled on LMS assignments to avoid confusion with the
32 | // embedded Hypothesis instance. The user can still use the extension on other
33 | // pages hosted in the LMS itself.
34 | /Hypothesis extension can't be used on Hypothesis LMS assignments/,
35 | ];
36 |
37 | /**
38 | * Returns true if a given `err` is anticipated during sidebar injection, such
39 | * as the tab being closed by the user, and should not be reported to Sentry.
40 | *
41 | * @param err - The Error-like object
42 | */
43 | export function shouldIgnoreInjectionError(err: { message: string }) {
44 | if (IGNORED_ERRORS.some(pattern => err.message.match(pattern))) {
45 | return true;
46 | }
47 | if (isKnownError(err)) {
48 | return true;
49 | }
50 | return false;
51 | }
52 |
53 | /**
54 | * Report an error.
55 | *
56 | * All errors are logged to the console. Additionally unexpected errors,
57 | * ie. those which are not instances of ExtensionError, are reported to
58 | * Sentry.
59 | *
60 | * @param error - The error which happened.
61 | * @param when - Describes the context in which the error occurred.
62 | * @param context - Additional context for the error.
63 | */
64 | export function report(error: Error, when: string, context?: object) {
65 | console.error(when, error, context);
66 | }
67 |
--------------------------------------------------------------------------------
/tools/update-pdfjs:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | # This script fetches the latest build of PDF.js from the viewer demo
6 | # page.
7 | #
8 | # See https://github.com/mozilla/pdf.js/wiki/Setup-pdf.js-in-a-website#from-examples
9 | #
10 | # To update PDF.js to the latest version:
11 | #
12 | # 1. Create a new branch and run this script.
13 | # 2. Rebuild the extension and verify that PDFs work as expected in Chrome
14 | # 3. Commit the changes to the `src/vendor/` directory
15 |
16 | DEST_DIR=src/vendor/pdfjs
17 | PREFIX=pdf.js-gh-pages
18 | COMPONENTS="$PREFIX/build $PREFIX/web $PREFIX/LICENSE"
19 |
20 | # Check for uncommitted git changes. See https://stackoverflow.com/a/3879077/434243
21 | git update-index --refresh
22 | git diff-index --quiet HEAD --
23 | if [[ $? -ne 0 ]]; then
24 | echo "Cannot update PDF.js when there are uncommitted changes in working tree."
25 | exit 1
26 | fi
27 |
28 | # Download the latest version of the PDF.js library and viewer.
29 | rm -rf $DEST_DIR
30 | mkdir -p $DEST_DIR
31 |
32 | # Get the latest build of the viewer
33 | curl -L https://github.com/mozilla/pdf.js/archive/gh-pages.tar.gz \
34 | | tar -xz --directory $DEST_DIR --strip-components=1 $COMPONENTS
35 |
36 | # Remove example content from viewer
37 | rm $DEST_DIR/web/*.pdf
38 |
39 | # Remove sourcemaps. These increase the size of the extension significantly.
40 | find $DEST_DIR/ -name '*.map' -delete
41 |
42 | # Remove the check that the PDF being loaded is from the same origin as the
43 | # viewer.
44 | sed -i '' -e 's/HOSTED_VIEWER_ORIGINS.includes(viewerOrigin)/true \/* Hypothesis *\//' $DEST_DIR/web/viewer.js
45 |
46 | # Modify the viewer HTML page to load the Hypothesis client.
47 | sed -i '' -e 's/<\/head>/
121 |