├── .github ├── dependabot.yml └── workflows │ └── gh-pages.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public ├── 404.html ├── favicon.ico ├── file.html ├── index.html └── logo.svg ├── src ├── icons │ ├── bootstrap │ │ ├── LICENSE.md │ │ ├── caret-down.svg │ │ ├── download.svg │ │ ├── file-earmark-code.svg │ │ ├── file-earmark-image.svg │ │ ├── file-earmark-text.svg │ │ ├── file-earmark.svg │ │ ├── folder2-open.svg │ │ ├── folder2.svg │ │ ├── gear.svg │ │ ├── github.svg │ │ ├── info-circle-fill.svg │ │ ├── x-circle-fill.svg │ │ └── x-circle.svg │ ├── logo-dark.svg │ └── logo-light.svg ├── less │ ├── app.less │ ├── code-viewer.less │ ├── common.less │ ├── config.less │ ├── extension-meta.less │ ├── extension-selector.less │ ├── file-explorer.less │ ├── file-viewer.less │ ├── modal.less │ ├── permissions.less │ └── tags.less └── ts │ ├── components │ ├── AsyncButton.tsx │ ├── ConfigUI.tsx │ ├── ExtensionInspector.tsx │ ├── ExtensionMetaData.tsx │ ├── ExtensionSelector.tsx │ ├── FileExplorer.tsx │ ├── FilePreview.tsx │ ├── Permissions.tsx │ ├── RecentExtensions.tsx │ ├── TagList.tsx │ ├── UIBox.tsx │ └── previews │ │ ├── HTMLPreview.tsx │ │ └── ImagePreview.tsx │ ├── config.ts │ ├── index.d.ts │ ├── inspector │ ├── CacheHelper.ts │ ├── InspectorFactory.ts │ └── worker │ │ ├── AMO.ts │ │ ├── CWS.ts │ │ ├── CodeRenderer.ts │ │ ├── Extension.ts │ │ ├── ExtensionProvider.ts │ │ ├── FileTree.ts │ │ ├── helpers │ │ ├── PrismJSSetup.ts │ │ ├── ResourceLocator.ts │ │ └── ScriptFinder.ts │ │ └── worker.ts │ ├── main.tsx │ ├── modal.tsx │ ├── openViewer.tsx │ ├── types │ ├── ExtensionCache.ts │ ├── ExtensionDetails.ts │ ├── ExtensionId.ts │ ├── Manifest.ts │ ├── PackagedFiles.ts │ └── Translations.ts │ ├── utils │ ├── AsyncEvent.ts │ ├── FunctionTypes.ts │ ├── LocalFileProvider.ts │ ├── download.ts │ ├── html.ts │ └── paths.ts │ └── viewer │ └── viewer.tsx ├── tsconfig.json └── webpack.config.js /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "npm" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | day: "monday" 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "monthly" 14 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-20.04 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js environment 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '14' 23 | 24 | - run: npm ci 25 | - run: npm run build:prod 26 | 27 | - name: Deploy 28 | uses: peaceiris/actions-gh-pages@v4 29 | if: github.ref == 'refs/heads/main' 30 | with: 31 | github_token: ${{ secrets.GITHUB_TOKEN }} 32 | publish_dir: ./public 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public/*.bundle.js 3 | public/*.LICENSE.txt 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveServer.settings.root": "/public/", 3 | "liveServer.settings.ignoreFiles": [ 4 | ".vscode/**", 5 | "**/*.less", 6 | "**/*.ts", 7 | "**/*.tsx" 8 | ], 9 | "liveServer.settings.file": "404.html" 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tim Weißenfels 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build badge](https://github.com/tim-we/web-ext-inspector/actions/workflows/gh-pages.yml/badge.svg)](https://github.com/tim-we/web-ext-inspector/actions/workflows/gh-pages.yml) 2 | 3 | # Web Extension Inspector 4 | 5 | Inspect the contents of web extensions hosted on [addons.mozilla.org](https://addons.mozilla.org) or [Chrome Web Store](https://chrome.google.com/webstore). 6 | 7 | This project is **work-in-progress**. A new version (rewritten from scratch) with new features is on the dev branch. It will be merged once it reaches feature parity with the current version. 8 | 9 | **Available as a web-app [here](https://web-ext-inspector.com/).** 10 | 11 | **Warning:** Automatic downloads from Chrome Web Store [are not working at the moment](https://github.com/tim-we/web-ext-inspector/issues/132). You can still manually download the extension and upload it in the web app. 12 | 13 | ## Examples 14 | 15 | - inspect [Tabs Aside](https://tim-we.github.io/web-ext-inspector/?extension=tabs-aside) 16 | - inspect [I don't care about cookies](https://tim-we.github.io/web-ext-inspector/?extension=i-dont-care-about-cookies) 17 | - inspect [Enhancer for YouTube™](https://tim-we.github.io/web-ext-inspector/?extension=enhancer-for-youtube) 18 | 19 | ## Firefox integration 20 | 21 | - [Install web extension](https://addons.mozilla.org/en-US/firefox/addon/extension-inspector) 22 | - [Web extension GitHub repo](https://github.com/tim-we/inspector-extension) 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "extension-analyzer", 3 | "version": "1.4.1", 4 | "description": "", 5 | "private": true, 6 | "scripts": { 7 | "build": "webpack --progress", 8 | "build:prod": "webpack --mode=production", 9 | "build:watch": "webpack --progress --watch" 10 | }, 11 | "author": "tim-we", 12 | "dependencies": { 13 | "@zip.js/zip.js": "^2.6.62", 14 | "comlink": "^4.4.1", 15 | "friendly-time": "^1.1.1", 16 | "htmlparser2": "^9.1.0", 17 | "idb-keyval": "^6.2.1", 18 | "preact": "^10.19.3", 19 | "pretty-bytes": "^6.1.0", 20 | "prismjs": "^1.29.0", 21 | "prismjs-token-stream-transformer": "^0.2.0", 22 | "wouter-preact": "^2.11.0" 23 | }, 24 | "devDependencies": { 25 | "@types/css": "^0.0.37", 26 | "@types/prismjs": "^1.26.0", 27 | "css": "^3.0.0", 28 | "css-loader": "^5.2.7", 29 | "less": "^4.1.2", 30 | "less-loader": "^12.2.0", 31 | "style-loader": "^3.3.3", 32 | "svg-url-loader": "^8.0.0", 33 | "ts-loader": "^9.4.4", 34 | "typescript": "^5.0.4", 35 | "webpack": "^5.94.0", 36 | "webpack-cli": "^5.1.4" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git@github.com:tim-we/web-ext-inspector.git" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SPA Redirector 6 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tim-we/web-ext-inspector/261cea3d3488347018a20eb93f14f9c30f661e20/public/favicon.ico -------------------------------------------------------------------------------- /public/file.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Extension File Viewer 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Extension Inspector 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/icons/bootstrap/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-2020 The Bootstrap Authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/icons/bootstrap/caret-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/bootstrap/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/bootstrap/file-earmark-code.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/bootstrap/file-earmark-image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/bootstrap/file-earmark-text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/bootstrap/file-earmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/bootstrap/folder2-open.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/bootstrap/folder2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/bootstrap/gear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/bootstrap/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/bootstrap/info-circle-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/bootstrap/x-circle-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/bootstrap/x-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/icons/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/less/app.less: -------------------------------------------------------------------------------- 1 | @import "common.less"; 2 | 3 | body { 4 | font-family: sans-serif; 5 | background-color: rgb(32, 32, 32); 6 | color: white; 7 | margin: 0; 8 | padding: 0; 9 | min-height: 100vh; 10 | } 11 | 12 | #root { 13 | display: flex; 14 | flex-direction: column; 15 | padding: 0px 8px; 16 | margin: 0; 17 | min-height: 100vh; 18 | box-sizing: border-box; 19 | } 20 | 21 | header { 22 | h1 { 23 | font-size: 22px; 24 | margin: 10px 0; 25 | cursor: default; 26 | 27 | &::before { 28 | content: " "; 29 | display: inline-block; 30 | width: 1em; 31 | height: 1em; 32 | background-image: url("../icons/logo-light.svg"); 33 | background-repeat: no-repeat; 34 | background-size: contain; 35 | background-position: center; 36 | margin-right: 0.3em; 37 | vertical-align: sub; 38 | } 39 | } 40 | 41 | a { 42 | color: white; 43 | text-decoration: none; 44 | 45 | & > h1 { 46 | cursor: pointer; 47 | } 48 | } 49 | } 50 | 51 | footer { 52 | margin-bottom: 8px; 53 | text-align: center; 54 | } 55 | 56 | #view-on-github { 57 | color: white; 58 | text-decoration: none; 59 | opacity: 0.64; 60 | font-size: 0.85em; 61 | line-height: 16px; 62 | 63 | &::after { 64 | content: " "; 65 | display: inline-block; 66 | width: 16px; 67 | height: 16px; 68 | margin-left: 0.4em; 69 | margin-top: -1px; 70 | vertical-align: text-top; 71 | background-image: url("../icons/bootstrap/github.svg"); 72 | background-repeat: no-repeat; 73 | } 74 | 75 | &:hover { 76 | opacity: 1; 77 | text-decoration: underline; 78 | } 79 | } 80 | 81 | @import "modal.less"; 82 | @import "config.less"; 83 | @import "extension-selector.less"; 84 | @import "extension-meta.less"; 85 | @import "permissions.less"; 86 | @import "file-explorer.less"; 87 | @import "code-viewer.less"; 88 | -------------------------------------------------------------------------------- /src/less/code-viewer.less: -------------------------------------------------------------------------------- 1 | .modal-window.file-viewer { 2 | .modal-content { 3 | background-color: rgb(43, 43, 43); 4 | padding: 10px; 5 | 6 | pre { 7 | margin: 0; 8 | -moz-tab-size: 4; 9 | tab-size: 4; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/less/common.less: -------------------------------------------------------------------------------- 1 | .ui-box { 2 | background-color: rgb(48, 48, 48); 3 | box-shadow: inset 0px 0px 0px 1px rgb(64, 64, 64); 4 | margin-bottom: 10px; 5 | 6 | .title-bar { 7 | display: block; 8 | border: none; 9 | background-color: transparent; 10 | padding: 10px; 11 | user-select: none; 12 | font-size: 0.85em; 13 | font-family: sans-serif; 14 | color: rgb(200, 200, 200); 15 | outline: none; 16 | } 17 | 18 | &.collapsable { 19 | .title-bar { 20 | cursor: pointer; 21 | } 22 | 23 | .title-bar:focus-visible { 24 | color: white; 25 | text-decoration: underline; 26 | } 27 | 28 | .title-bar::before { 29 | content: " "; 30 | display: inline-block; 31 | vertical-align: sub; 32 | width: 16px; 33 | height: 16px; 34 | background-image: url("../icons/bootstrap/caret-down.svg"); 35 | background-repeat: no-repeat; 36 | opacity: 0.75; 37 | margin-right: 5px; 38 | transition: transform 0.2s; 39 | } 40 | 41 | &.collapsed { 42 | .title-bar::before { 43 | transform: rotate(-90deg); 44 | } 45 | } 46 | } 47 | 48 | .box-content { 49 | padding: 10px; 50 | padding-top: 0px; 51 | 52 | a { 53 | color: white; 54 | } 55 | } 56 | } 57 | 58 | span.info { 59 | display: inline-block; 60 | background-color: rgba(26, 136, 199, 0.699); 61 | padding: 5px; 62 | border-radius: 3px; 63 | margin: 10px; 64 | 65 | &::before { 66 | content: " "; 67 | display: inline-block; 68 | width: 1em; 69 | height: 1em; 70 | background-image: url("../icons/bootstrap/info-circle-fill.svg"); 71 | background-repeat: no-repeat; 72 | background-position: center; 73 | margin-right: 8px; 74 | vertical-align: sub; 75 | } 76 | } 77 | 78 | span.path { 79 | font-family: monospace; 80 | hyphens: none; 81 | } 82 | 83 | a.file, 84 | a.folder { 85 | color: white; 86 | text-decoration: none; 87 | 88 | &:hover, 89 | &:active, 90 | &:focus { 91 | text-decoration: underline; 92 | } 93 | 94 | &:focus-visible { 95 | outline: none; 96 | } 97 | } 98 | 99 | .hfill { 100 | width: 100%; 101 | flex-grow: 1; 102 | flex-shrink: 1; 103 | } 104 | 105 | @import "tags.less"; 106 | 107 | .status-message { 108 | background-color: rgb(48, 48, 48); 109 | box-shadow: inset 0px 0px 0px 1px rgb(64, 64, 64); 110 | margin-bottom: 10px; 111 | padding: 10px; 112 | } 113 | -------------------------------------------------------------------------------- /src/less/config.less: -------------------------------------------------------------------------------- 1 | #config { 2 | position: absolute; 3 | top: 8px; 4 | right: 8px; 5 | 6 | &.open::before { 7 | content: " "; 8 | display: block; 9 | position: fixed; 10 | left: 0; 11 | right: 0; 12 | top: 0; 13 | bottom: 0; 14 | z-index: 2; 15 | } 16 | 17 | button { 18 | border: none; 19 | background-color: transparent; 20 | width: 20px; 21 | height: 20px; 22 | margin-right: 2px; 23 | cursor: pointer; 24 | opacity: 0.75; 25 | 26 | background-image: url("../icons/bootstrap/gear.svg"); 27 | background-repeat: no-repeat; 28 | background-position: center; 29 | 30 | &:hover { 31 | opacity: 1; 32 | } 33 | } 34 | 35 | #config-popup { 36 | position: absolute; 37 | top: 32px; 38 | right: 0px; 39 | border: 1px solid rgb(80, 80, 80); 40 | background-color: rgb(64, 64, 64); 41 | padding: 10px 8px; 42 | min-width: 200px; 43 | max-width: 80vw; 44 | box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.32); 45 | z-index: 2; 46 | 47 | label { 48 | margin-right: 0.5em; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/less/extension-meta.less: -------------------------------------------------------------------------------- 1 | .extension-meta-data .box-content { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | 6 | .icon { 7 | margin-left: 10px; 8 | margin-right: 30px; 9 | 10 | min-width: 48px; 11 | min-height: 48px; 12 | max-width: 96px; 13 | max-height: 96px; 14 | } 15 | 16 | table { 17 | td:first-child { 18 | font-size: 0.9em; 19 | opacity: 0.75; 20 | padding-right: 20px; 21 | user-select: none; 22 | } 23 | } 24 | 25 | .version.friendly-time { 26 | margin-left: 0.625em; 27 | opacity: 0.8; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/less/extension-selector.less: -------------------------------------------------------------------------------- 1 | .extension-selector { 2 | input { 3 | margin: 10px; 4 | margin-left: 15px; 5 | } 6 | 7 | form.addon-store-selector { 8 | label { 9 | font-family: monospace; 10 | user-select: none; 11 | margin-left: 15px; 12 | } 13 | 14 | input[type="text"] { 15 | margin-left: 0; 16 | } 17 | } 18 | 19 | ul li:not(:last-of-type) { 20 | margin-bottom: 0.5em; 21 | } 22 | } 23 | 24 | .recent-extension { 25 | display: flex; 26 | flex-direction: row; 27 | flex-wrap: wrap; 28 | gap: 0.2em 0.3em; 29 | 30 | a.title { 31 | flex-basis: 100%; 32 | } 33 | 34 | > :not(.title) { 35 | opacity: 0.75; 36 | font-size: 0.8em; 37 | } 38 | 39 | button.remove { 40 | position: relative; 41 | top: -1px; 42 | border: none; 43 | background: none; 44 | color: inherit; 45 | cursor: pointer; 46 | } 47 | 48 | &:hover button.remove { 49 | text-decoration: underline; 50 | } 51 | 52 | button:hover { 53 | opacity: 1; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/less/file-explorer.less: -------------------------------------------------------------------------------- 1 | .file-explorer .box-content { 2 | display: flex; 3 | flex-direction: row; 4 | 5 | .file-tree { 6 | flex-grow: 1; 7 | max-height: 600px; 8 | min-height: 300px; 9 | overflow-y: auto; 10 | scrollbar-width: thin; 11 | contain: layout paint; 12 | 13 | @media (max-width: 770px) { 14 | font-size: 0.9em; 15 | } 16 | 17 | ul { 18 | list-style: none; 19 | margin: 0px; 20 | padding-left: 25px; 21 | contain: layout paint; 22 | 23 | li { 24 | margin-bottom: 5px; 25 | margin-top: 5px; 26 | 27 | &:last-child { 28 | margin-bottom: 0px; 29 | } 30 | 31 | .size, 32 | .num-files { 33 | margin-left: 10px; 34 | font-size: 0.8em; 35 | opacity: 0.5; 36 | user-select: none; 37 | white-space: nowrap; 38 | } 39 | } 40 | 41 | li::before { 42 | content: " "; 43 | display: inline-block; 44 | width: 16px; 45 | height: 16px; 46 | background-image: url("../icons/bootstrap/file-earmark.svg"); 47 | background-repeat: no-repeat; 48 | background-position: center; 49 | margin-right: 10px; 50 | } 51 | 52 | li.folder { 53 | &::before { 54 | background-image: url("../icons/bootstrap/folder2.svg"); 55 | } 56 | 57 | &.open::before { 58 | background-image: url("../icons/bootstrap/folder2-open.svg"); 59 | } 60 | 61 | &.open > a { 62 | opacity: 0.8; 63 | } 64 | } 65 | 66 | li.code::before { 67 | background-image: url("../icons/bootstrap/file-earmark-code.svg"); 68 | } 69 | 70 | li.image::before { 71 | background-image: url("../icons/bootstrap/file-earmark-image.svg"); 72 | } 73 | 74 | li.text::before { 75 | background-image: url("../icons/bootstrap/file-earmark-text.svg"); 76 | } 77 | 78 | ul::before { 79 | content: " "; 80 | position: absolute; 81 | left: 7px; 82 | top: 3px; 83 | bottom: 5px; 84 | width: 1px; 85 | display: block; 86 | background-color: rgba(255, 255, 255, 0.1); 87 | } 88 | } 89 | 90 | & > ul { 91 | padding: 0; 92 | padding-left: 5px; 93 | 94 | @media (min-width: 980px) { 95 | padding: 5px; 96 | padding-left: 10px; 97 | padding-top: 0; 98 | } 99 | } 100 | } 101 | 102 | .file-preview { 103 | position: relative; 104 | min-width: 300px; 105 | max-width: 45%; 106 | padding: 10px; 107 | margin-left: 10px; 108 | background-color: rgb(64, 64, 64); 109 | display: flex; 110 | flex-direction: column; 111 | align-items: center; 112 | justify-content: space-between; 113 | 114 | @media (max-width: 770px) { 115 | min-width: 200px; 116 | } 117 | 118 | @media (min-width: 1281px) { 119 | max-width: 40%; 120 | } 121 | 122 | .properties { 123 | width: 100%; 124 | 125 | table { 126 | th { 127 | text-align: left; 128 | min-width: 70px; 129 | } 130 | 131 | td { 132 | text-align: right; 133 | } 134 | } 135 | 136 | .tags { 137 | padding: 0 3px; 138 | } 139 | } 140 | 141 | a.close { 142 | display: block; 143 | position: absolute; 144 | top: 10px; 145 | right: 10px; 146 | width: 16px; 147 | height: 16px; 148 | background-image: url("../icons/bootstrap/x-circle.svg"); 149 | background-repeat: no-repeat; 150 | text-decoration: none; 151 | background-color: inherit; 152 | cursor: pointer; 153 | opacity: 0.75; 154 | 155 | &:hover, 156 | &:focus, 157 | &:active { 158 | opacity: 1; 159 | } 160 | } 161 | 162 | .main-actions { 163 | button { 164 | display: inline-block; 165 | background-color: rgb(0, 75, 190); 166 | text-decoration: none; 167 | color: white; 168 | padding: 5px 10px; 169 | border-radius: 5px; 170 | box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2); 171 | border: 1px solid rgba(255, 255, 255, 0.1); 172 | font-size: 16px; 173 | transition: background-color 0.25s; 174 | 175 | &:not(:last-child) { 176 | margin-right: 10px; 177 | } 178 | 179 | &:disabled { 180 | background-color: rgb(87, 88, 89); 181 | color: rgb(182, 182, 182); 182 | } 183 | 184 | &:not(:disabled) { 185 | cursor: pointer; 186 | } 187 | 188 | &.download::before { 189 | content: " "; 190 | display: inline-block; 191 | margin-right: 7px; 192 | width: 16px; 193 | height: 16px; 194 | background-image: url("../icons/bootstrap/download.svg"); 195 | background-repeat: no-repeat; 196 | background-position: center; 197 | vertical-align: sub; 198 | } 199 | } 200 | } 201 | 202 | .content-preview { 203 | display: flex; 204 | flex-direction: column; 205 | justify-content: center; 206 | flex-grow: 1; 207 | margin-top: 10px; 208 | margin-bottom: 10px; 209 | 210 | .image-preview { 211 | display: flex; 212 | flex-direction: column; 213 | justify-content: center; 214 | align-items: center; 215 | 216 | background-color: rgba(255, 255, 255, 0.25); 217 | max-height: 300px; 218 | max-width: 100%; 219 | min-height: 48px; 220 | min-width: 48px; 221 | 222 | img { 223 | max-width: 100%; 224 | max-height: 100%; 225 | min-height: 32px; 226 | min-width: 32px; 227 | } 228 | } 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/less/file-viewer.less: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | background: rgb(43, 43, 43); 4 | color: white; 5 | margin: 0; 6 | padding: 0; 7 | height: 100vh; 8 | } 9 | 10 | @controls-height: 0;//42px; 11 | 12 | #controls { 13 | position: absolute; 14 | top: 0; 15 | left: 0; 16 | right: 0; 17 | height: @controls-height; 18 | background-color: rgb(90,90,90); 19 | padding: 0 5px; 20 | display: none; // TODO 21 | } 22 | 23 | #file-content { 24 | position: absolute; 25 | top: @controls-height; 26 | left: 0; 27 | right: 0; 28 | bottom: 0; 29 | overflow: auto; 30 | padding: 10px; 31 | 32 | pre { 33 | margin: 0; 34 | -moz-tab-size: 4; 35 | tab-size: 4; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/less/modal.less: -------------------------------------------------------------------------------- 1 | .modal-window { 2 | @outer-margin: 20px; 3 | @title-bar-height: 42px; 4 | 5 | position: fixed; 6 | top: @outer-margin; 7 | left: @outer-margin; 8 | right: @outer-margin; 9 | bottom: @outer-margin; 10 | background-color: rgb(64, 64, 64); 11 | border: 1px solid rgb(200, 200, 200); 12 | color: white; 13 | z-index: 2; 14 | 15 | // backdrop 16 | &::before { 17 | content: " "; 18 | position: fixed; 19 | top: 0; 20 | left: 0; 21 | right: 0; 22 | bottom: 0; 23 | background-color: rgba(0, 0, 0, 0.5); 24 | } 25 | 26 | .title-bar { 27 | position: absolute; 28 | top: 0; 29 | left: 0; 30 | right: 0; 31 | height: @title-bar-height; 32 | background-color: rgb(90, 90, 90); 33 | 34 | display: flex; 35 | flex-direction: row; 36 | justify-content: space-between; 37 | padding: 0 5px; 38 | padding-right: 0; 39 | 40 | &::before { 41 | content: " "; 42 | display: inline-block; 43 | width: 16px; 44 | height: @title-bar-height; 45 | background-repeat: no-repeat; 46 | background-position: center; 47 | margin-right: 5px; 48 | } 49 | 50 | h2 { 51 | margin: 0; 52 | padding: 0; 53 | line-height: @title-bar-height; 54 | font-weight: normal; 55 | user-select: none; 56 | font-size: 20px; 57 | } 58 | 59 | .modal-controls { 60 | height: @title-bar-height; 61 | 62 | button { 63 | display: block; 64 | width: @title-bar-height; 65 | height: @title-bar-height; 66 | background-color: transparent; 67 | background-image: url("../icons/bootstrap/x-circle-fill.svg"); 68 | background-repeat: no-repeat; 69 | background-position: center; 70 | border: none; 71 | cursor: pointer; 72 | opacity: 0.75; 73 | transition: opacity 0.25s; 74 | 75 | &:hover { 76 | opacity: 1; 77 | } 78 | } 79 | } 80 | } 81 | 82 | &.file-viewer .title-bar::before { 83 | background-image: url("../icons/bootstrap/file-earmark-code.svg"); 84 | } 85 | 86 | .modal-content { 87 | position: absolute; 88 | top: @title-bar-height; 89 | left: 0; 90 | right: 0; 91 | bottom: 0; 92 | overflow: auto; 93 | padding: 10px; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/less/permissions.less: -------------------------------------------------------------------------------- 1 | .permissions .box-content { 2 | display: flex; 3 | flex-direction: row; 4 | gap: 10px; 5 | 6 | table { 7 | width: 100%; 8 | text-align: left; 9 | border-collapse: separate; 10 | border-spacing: 0 1em; 11 | margin-top: -1em; 12 | margin-bottom: -1em; 13 | border: none; 14 | 15 | td { 16 | display: flex; 17 | flex-direction: row; 18 | gap: 6px; 19 | flex-wrap: wrap; 20 | 21 | .none-info { 22 | color: rgb(200, 200, 200); 23 | user-select: none; 24 | } 25 | } 26 | 27 | th { 28 | font-size: 0.85em; 29 | user-select: none; 30 | color: rgb(200, 200, 200); 31 | width: 90px; 32 | font-weight: normal; 33 | } 34 | 35 | .permission { 36 | @permission-height: 24px; 37 | 38 | position: relative; 39 | box-sizing: border-box; 40 | padding: 0 7px; 41 | border-radius: 20px; 42 | white-space: nowrap; 43 | border: 1px solid rgba(255, 255, 255, 0.25); 44 | height: @permission-height; 45 | line-height: @permission-height - 2px; 46 | text-decoration: none; 47 | color: white; 48 | transition: padding 0.25s; 49 | cursor: pointer; 50 | 51 | &.api { 52 | background-color: rgb(227, 103, 12); 53 | } 54 | 55 | &.host { 56 | background-color: rgb(0, 80, 200); 57 | font-family: monospace; 58 | } 59 | 60 | &::after { 61 | content: " "; 62 | display: block; 63 | position: absolute; 64 | right: 3px; 65 | top: -1px; 66 | width: 16px; 67 | height: @permission-height; 68 | background-image: url("../icons/bootstrap/info-circle-fill.svg"); 69 | background-repeat: no-repeat; 70 | background-position: center; 71 | opacity: 0; 72 | transition: 0.25s; 73 | } 74 | 75 | &:focus { 76 | border: 1px solid rgba(255, 255, 255, 0.75); 77 | } 78 | 79 | &:focus-visible { 80 | outline: none; 81 | } 82 | 83 | &:hover, 84 | &:focus { 85 | padding-right: 22px; 86 | 87 | &::after { 88 | opacity: 1; 89 | } 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/less/tags.less: -------------------------------------------------------------------------------- 1 | .tags { 2 | padding: 0px 10px; 3 | 4 | .tag { 5 | background-color: rgba(128, 128,128, 0.6); 6 | border-radius: 10px; 7 | margin-left: 5px; 8 | padding: 1px 7px; 9 | font-size: 0.8em; 10 | box-shadow: inset 0px 0px 0px 1px rgba(255, 255, 255, 0.085); 11 | color: rgb(225, 225, 225); 12 | user-select: none; 13 | 14 | @media (max-width: 770px) { 15 | font-size: 0.7em; 16 | } 17 | 18 | &:first-child { 19 | margin-left: 0; 20 | } 21 | 22 | &.background { 23 | background-color: rgba(0, 60, 255, 0.6); 24 | } 25 | 26 | &.content { 27 | background-color: rgb(153, 153, 0); 28 | color: rgb(255, 255, 255); 29 | } 30 | 31 | &.user-script-api { 32 | background-color: rgb(141, 0, 153); 33 | } 34 | 35 | &.manifest { 36 | background-color: rgb(7, 149, 35); 37 | color: rgb(235, 235, 235); 38 | } 39 | 40 | &.devtools { 41 | background-color: rgb(227, 103, 12); 42 | color: rgb(255, 255, 255); 43 | } 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /src/ts/components/AsyncButton.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ComponentChild } from "preact"; 2 | 3 | type ABProps = { 4 | onClick?: (e: MouseEvent) => Promise; 5 | classes?: string[]; 6 | description?: string; 7 | }; 8 | 9 | type ABState = { 10 | enabled: boolean; 11 | }; 12 | 13 | export default class AsyncButton extends Component { 14 | constructor(props: ABProps) { 15 | super(props); 16 | this.state = { enabled: true }; 17 | } 18 | 19 | public render(): ComponentChild { 20 | const props = this.props; 21 | const classStr = (props.classes ?? []).join(" "); 22 | return ( 23 | 31 | ); 32 | } 33 | 34 | private onClick(e: MouseEvent): void { 35 | e.stopPropagation(); 36 | if (this.state.enabled && this.props.onClick) { 37 | this.setState({ enabled: false }, () => { 38 | this.props.onClick!(e).finally(() => 39 | this.setState({ enabled: true }) 40 | ); 41 | }); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ts/components/ConfigUI.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import * as config from "../config"; 3 | import { ConfigOption } from "../config"; 4 | 5 | type State = { 6 | open: boolean; 7 | }; 8 | 9 | export default class ConfigUI extends Component<{}, State> { 10 | public render() { 11 | return ( 12 |
{ 16 | if (this.state.open) { 17 | this.setState({ open: false }); 18 | } 19 | }} 20 | > 21 | 28 | {this.state.open ? ( 29 |
e.stopPropagation()}> 30 | {config.getAll().map((co) => ( 31 | 32 | ))} 33 |
34 | ) : null} 35 |
36 | ); 37 | } 38 | } 39 | 40 | type COProps = { 41 | option: ConfigOption; 42 | }; 43 | 44 | type COState = { 45 | value: any; 46 | }; 47 | 48 | abstract class ConfigOptionUI extends Component { 49 | constructor(props: COProps) { 50 | super(props); 51 | this.state = { value: config.get(props.option.id) }; 52 | } 53 | 54 | protected setValue(newValue: any): void { 55 | config.set(this.props.option.id, newValue); 56 | this.setState({ value: newValue }); 57 | } 58 | } 59 | 60 | class SelectConfigOption extends ConfigOptionUI { 61 | public render() { 62 | const co = this.props.option; 63 | const id = "option-" + co.id; 64 | 65 | return ( 66 |
67 | 68 | 81 |
82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ts/components/ExtensionInspector.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import FileExplorer from "./FileExplorer"; 3 | import ExtensionMetaData from "./ExtensionMetaData"; 4 | import { createInspector, Inspector } from "../inspector/InspectorFactory"; 5 | import ExtensionPermissions from "./Permissions"; 6 | import { openFileViewer } from "../openViewer"; 7 | import UIBox from "./UIBox"; 8 | import { getDownloadURL as getCWSDownloadURL } from "../inspector/worker/CWS"; 9 | import { ExtensionId } from "../types/ExtensionId"; 10 | 11 | type Props = { 12 | extension: ExtensionId; 13 | }; 14 | 15 | type State = { 16 | inspector?: Inspector; 17 | statusMessage?: string; 18 | loading: boolean; 19 | }; 20 | 21 | export default class ExtensionInspector extends Component { 22 | public constructor(props: Props) { 23 | super(props); 24 | this.state = { 25 | loading: true, 26 | statusMessage: "loading", 27 | }; 28 | 29 | createInspector(props.extension, (status) => { 30 | this.setState({ statusMessage: status }); 31 | }).then(async (inspector) => { 32 | this.setState({ inspector, loading: false }); 33 | 34 | const details = await inspector.getDetails(); 35 | document.title = `Inspecting ${details.name}`; 36 | 37 | // if (props.extension.type === "url") { 38 | // URL.revokeObjectURL(props.extension.url); 39 | // } 40 | }); 41 | } 42 | 43 | public render() { 44 | const state = this.state; 45 | const ext = this.props.extension; 46 | 47 | return ( 48 | <> 49 | {state.inspector ? ( 50 | <> 51 | 52 | 53 | 57 | openFileViewer(path, state.inspector!) 58 | } 59 | /> 60 | 61 | ) : null} 62 | {state.statusMessage ? ( 63 |
64 | {state.statusMessage} 65 |
66 | ) : null} 67 | {state.loading && ext.source === "chrome" ? ( 68 | 69 | Chrome extensions require a download proxy and could 70 | therefore take longer to load. If there is a problem 71 | with the download you can 72 |
    73 |
  1. 74 | 79 | download the extension manually 80 | 81 |
  2. 82 |
  3. upload it on the start page
  4. 83 |
84 |
85 | ) : null} 86 | 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ts/components/ExtensionMetaData.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import prettyBytes from "pretty-bytes"; 3 | import { Inspector } from "../inspector/InspectorFactory"; 4 | import friendlyTime from "friendly-time"; 5 | import UIBox from "./UIBox"; 6 | import { ExtensionDetails } from "../types/ExtensionDetails"; 7 | 8 | type Props = { 9 | inspector: Inspector; 10 | }; 11 | 12 | type State = { 13 | details?: ExtensionDetails; 14 | }; 15 | 16 | export default class ExtensionMetaData extends Component { 17 | constructor(props: Props) { 18 | super(props); 19 | props.inspector 20 | .getDetails() 21 | .then((details) => this.setState({ details })); 22 | } 23 | 24 | public render() { 25 | const details = this.state.details; 26 | 27 | if (details === undefined) { 28 | return
loading details...
; 29 | } 30 | 31 | const lastUpdateTime = details.last_updated 32 | ? new Date(details.last_updated) 33 | : undefined; 34 | const createdTime = details.created 35 | ? new Date(details.created) 36 | : undefined; 37 | 38 | return ( 39 | 40 | {details.icon_url ? ( 41 | extension icon 46 | ) : null} 47 | 48 | 49 | 50 | 51 | 52 | 53 | {details.author ? ( 54 | 55 | 56 | 57 | 58 | ) : null} 59 | 60 | 61 | 72 | 73 | {createdTime ? ( 74 | 75 | 76 | 77 | 78 | ) : null} 79 | 80 | 81 | 82 | 83 | 84 |
Name{details.name}
Author{details.author}
Version 62 | {details.version} 63 | {lastUpdateTime ? ( 64 | 68 | {`(${friendlyTime(lastUpdateTime)})`} 69 | 70 | ) : null} 71 |
Created{friendlyTime(createdTime)}
Size{prettyBytes(details.size)}
85 |
86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/ts/components/ExtensionSelector.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent as FC } from "preact"; 2 | import { useState, useRef, useEffect } from "preact/hooks"; 3 | import * as LFP from "../utils/LocalFileProvider"; 4 | import { Link, useLocation } from "wouter-preact"; 5 | import UIBox from "./UIBox"; 6 | import { RecentExtensions } from "./RecentExtensions"; 7 | 8 | const ExtensionSelector: FC = () => { 9 | const navigate = useLocation()[1]; 10 | const fileRef = useRef(null); 11 | const files = fileRef.current?.files; 12 | 13 | const [amoId, setAMOId] = useState(""); 14 | const [cwsId, setCWSId] = useState(""); 15 | const [fileSelected, setFileSelected] = useState(false); 16 | 17 | const createSubmitHandler = ( 18 | prefix: string, 19 | id: string, 20 | before: (id: string) => void = () => {} 21 | ) => { 22 | return (e: Event) => { 23 | e.preventDefault(); 24 | const urlId = encodeURIComponent(id.trim()); 25 | if (urlId === "") { 26 | return; 27 | } 28 | before(urlId); 29 | 30 | if (urlId) { 31 | navigate(prefix + urlId); 32 | } 33 | }; 34 | }; 35 | 36 | useEffect(() => void (document.title = "Extension Inspector"), []); 37 | 38 | return ( 39 | <> 40 | 45 |
    46 |
  • 47 | {"from the "} 48 | 49 | official add-on website 50 | 51 | : 52 |
    59 | 62 | 68 | setAMOId( 69 | (e.target as HTMLInputElement).value 70 | ) 71 | } 72 | /> 73 | {amoId.trim().length > 0 ? ( 74 | 75 | ) : null} 76 |
    77 |
  • 78 |
  • 79 | {"from the "} 80 | 84 | Chrome Web Store 85 | 86 | : 87 |
    94 | 97 | 103 | setCWSId( 104 | (e.target as HTMLInputElement).value 105 | ) 106 | } 107 | /> 108 | {cwsId.trim().length === 32 ? ( 109 | 110 | ) : null} 111 |
    112 |
  • 113 |
  • 114 | or select a local file: 115 |
    { 120 | LFP.addFile(files![0], id); 121 | } 122 | )} 123 | > 124 | { 129 | setFileSelected( 130 | fileRef.current!.files != null && 131 | fileRef.current!.files.length === 1 132 | ); 133 | }} 134 | /> 135 | {fileSelected ? ( 136 | 137 | ) : null} 138 |
    139 |
  • 140 |
141 | 142 | You can integrate this tool into the offical add-on website 143 | with an{" "} 144 | 148 | extension 149 | 150 | . 151 | 152 |
153 | 154 | 155 | 156 | ); 157 | }; 158 | 159 | export default ExtensionSelector; 160 | 161 | type ExampleProps = { name: string; id: string }; 162 | const Example: FC = ({ id, name }) => ( 163 |
  • 164 | inspect {name} 165 |
  • 166 | ); 167 | 168 | const ExampleSelector: FC = () => ( 169 | 170 | Select one of the examples: 171 |
      172 | 176 | 177 | 178 |
    179 |
    180 | ); 181 | -------------------------------------------------------------------------------- /src/ts/components/FileExplorer.tsx: -------------------------------------------------------------------------------- 1 | import { Component, createRef } from "preact"; 2 | import { Inspector } from "../inspector/InspectorFactory"; 3 | import { TreeNodeDTO } from "../inspector/worker/FileTree"; 4 | import prettyBytes from "pretty-bytes"; 5 | import FilePreview from "./FilePreview"; 6 | import TagList from "./TagList"; 7 | import UIBox from "./UIBox"; 8 | import { 9 | FileAsyncAction, 10 | FileSelectListener, 11 | TreeFileDTO, 12 | } from "../types/PackagedFiles"; 13 | 14 | type Props = { 15 | inspector: Inspector; 16 | selected?: string; 17 | onFileOpen: FileAsyncAction; 18 | }; 19 | 20 | type State = { 21 | selectedFile?: { 22 | path: string; 23 | node: TreeFileDTO; 24 | }; 25 | }; 26 | 27 | export default class FileExplorer extends Component { 28 | private data: Map; 29 | 30 | public constructor(props: Props) { 31 | super(props); 32 | this.data = new Map(); 33 | this.selectFile = this.selectFile.bind(this); 34 | 35 | (async () => { 36 | const path = ""; 37 | 38 | const list = await props.inspector.listDirectoryContents(path); 39 | this.data.set(path, list); 40 | const root = this.data.get("")!; 41 | 42 | if (props.selected) { 43 | const node = root.find( 44 | (n) => n.name === props.selected && n.type === "file" 45 | ); 46 | if (node) { 47 | this.setState({ 48 | selectedFile: { 49 | path: props.selected, 50 | node: node as TreeFileDTO, 51 | }, 52 | }); 53 | } 54 | } 55 | 56 | this.forceUpdate(); 57 | })(); 58 | } 59 | 60 | private async selectFile(path: string, node: TreeFileDTO): Promise { 61 | await this.loadAllParents(path); 62 | this.forceUpdate(); 63 | this.setState({ selectedFile: { path, node } }); 64 | } 65 | 66 | private async loadAllParents(path: string): Promise { 67 | let folders = path.split("/"); 68 | folders.pop(); 69 | 70 | while (folders.length > 0) { 71 | let folder = folders.join("/"); 72 | const list = await this.props.inspector.listDirectoryContents( 73 | folder 74 | ); 75 | this.data.set(folder, list); 76 | folders.pop(); 77 | } 78 | } 79 | 80 | public render() { 81 | return ( 82 | 87 |
    88 | 94 |
    95 | {this.state.selectedFile ? ( 96 | 101 | this.setState({ selectedFile: undefined }) 102 | } 103 | onFileSelect={this.selectFile} 104 | onFileOpen={this.props.onFileOpen} 105 | /> 106 | ) : null} 107 |
    108 | ); 109 | } 110 | } 111 | 112 | type FVProps = { 113 | path: string; 114 | data: Map; 115 | inspector: Inspector; 116 | onFileSelect: FileSelectListener; 117 | }; 118 | 119 | class FolderView extends Component { 120 | public render() { 121 | const data = this.props.data.get(this.props.path); 122 | 123 | if (!data) { 124 | return null; 125 | } 126 | 127 | return ( 128 | 198 | ); 199 | } 200 | } 201 | 202 | type FNVProps = { 203 | path: string; 204 | node: TreeFileDTO; 205 | inspector: Inspector; 206 | onSelect: FileSelectListener; 207 | scrollIntoView?: boolean; 208 | }; 209 | 210 | class FileNodeView extends Component { 211 | private ref = createRef(); 212 | 213 | componentDidUpdate() { 214 | if (this.props.scrollIntoView && this.ref.current) { 215 | this.ref.current.scrollIntoView(); 216 | } 217 | } 218 | 219 | public render() { 220 | const path = this.props.path; 221 | const node = this.props.node; 222 | 223 | //@ts-ignore 224 | if (node.type === "folder") { 225 | throw new Error(`Node ${node.name} is not a folder.`); 226 | } 227 | 228 | const classes = ["file"].concat(node.tags); 229 | 230 | return ( 231 |
  • 232 | { 238 | e.preventDefault(); 239 | e.stopPropagation(); 240 | this.props.onSelect(path, node); 241 | }} 242 | > 243 | {node.name} 244 | 245 | {prettyBytes(node.size)} 246 | 247 |
  • 248 | ); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/ts/components/FilePreview.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "preact"; 2 | import { Inspector } from "../inspector/InspectorFactory"; 3 | import { TreeNodeDTO } from "../inspector/worker/FileTree"; 4 | import prettyBytes from "pretty-bytes"; 5 | import { startDownload } from "../utils/download"; 6 | import TagList from "./TagList"; 7 | import ImagePreview from "./previews/ImagePreview"; 8 | import HTMLPreview from "./previews/HTMLPreview"; 9 | import { getFolder } from "../utils/paths"; 10 | import { 11 | FileAsyncAction, 12 | FileSelectListener, 13 | TreeFileDTO, 14 | } from "../types/PackagedFiles"; 15 | import AsyncButton from "./AsyncButton"; 16 | 17 | type FPProps = { 18 | path: string; 19 | node: TreeFileDTO; 20 | inspector: Inspector; 21 | closer: () => void; 22 | onFileSelect: FileSelectListener; 23 | onFileOpen: FileAsyncAction; 24 | }; 25 | 26 | const FilePreview: FunctionComponent = (props) => { 27 | const node = props.node; 28 | const canOpen = 29 | /\.(js|mjs|json|htm|html|xml|css)$/i.test(node.name) || 30 | node.tags.includes("text"); 31 | 32 | return ( 33 |
    34 |
    35 | 36 | 37 | 38 | 39 | 40 | 41 | {node.name !== props.path ? ( 42 | 43 | 44 | 49 | 50 | ) : null} 51 | 52 | 53 | 54 | 55 | 56 |
    Name{node.name}
    Folder 45 | 46 | {getFolder(props.path)} 47 | 48 |
    Size{prettyBytes(node.size)}
    57 | 58 |
    59 | 66 |
    67 | {canOpen ? ( 68 | props.onFileOpen(props.path, node)} 71 | > 72 | Open 73 | 74 | ) : null} 75 | { 79 | const url = await props.inspector.getFileDownloadURL( 80 | props.path 81 | ); 82 | startDownload(url, node.name); 83 | }} 84 | > 85 | Download 86 | 87 |
    88 | props.closer()} 92 | data-native 93 | > 94 |
    95 | ); 96 | }; 97 | 98 | export default FilePreview; 99 | 100 | type CPProps = { 101 | path: string; 102 | file: TreeNodeDTO; 103 | inspector: Inspector; 104 | onFileSelect: FileSelectListener; 105 | }; 106 | 107 | const ContentPreview: FunctionComponent = (props) => { 108 | const file = props.file; 109 | 110 | if (file.type === "folder") { 111 | return null; 112 | } 113 | 114 | if (file.tags.includes("image")) { 115 | return ( 116 |
    117 | 122 |
    123 | ); 124 | } else if (file.tags.includes("html")) { 125 | return ( 126 |
    127 | 133 |
    134 | ); 135 | } 136 | 137 | return null; 138 | }; 139 | -------------------------------------------------------------------------------- /src/ts/components/Permissions.tsx: -------------------------------------------------------------------------------- 1 | import { Component, FunctionalComponent as FC } from "preact"; 2 | import { Inspector } from "../inspector/InspectorFactory"; 3 | import { Manifest } from "../types/Manifest"; 4 | import UIBox from "./UIBox"; 5 | 6 | type Props = { 7 | inspector: Inspector; 8 | }; 9 | 10 | type State = { 11 | manifest?: Manifest; 12 | }; 13 | 14 | export default class ExtensionPermissions extends Component { 15 | public constructor(props: Props) { 16 | super(props); 17 | this.state = {}; 18 | props.inspector 19 | .getManifest() 20 | .then((manifest) => this.setState({ manifest })); 21 | } 22 | 23 | public render() { 24 | const manifest = this.state.manifest; 25 | if (manifest === undefined) { 26 | return null; 27 | } 28 | 29 | const requiredPermissions = manifest.permissions ?? []; 30 | const optionalPermissions = manifest.optional_permissions ?? []; 31 | 32 | requiredPermissions.sort(); 33 | optionalPermissions.sort(); 34 | 35 | return ( 36 | 41 | 42 | 43 | 44 | 45 | 52 | 53 | 54 | 55 | 62 | 63 | 64 |
    Required 46 | 47 | {requiredPermissions.map((p) => ( 48 | 49 | ))} 50 | 51 |
    Optional 56 | 57 | {optionalPermissions.map((p) => ( 58 | 59 | ))} 60 | 61 |
    65 |
    66 | ); 67 | } 68 | } 69 | 70 | const mdnBaseURL = 71 | "https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions"; 72 | 73 | const apiPermissions: Map = new Map([ 74 | [ 75 | "activeTab", 76 | mdnBaseURL + "/manifest.json/permissions#activetab_permission", 77 | ], 78 | ["alarms", null], 79 | ["background", null], 80 | ["bookmarks", null], 81 | ["browserSettings", null], 82 | ["browsingData", null], 83 | ["captivePortal", null], 84 | [ 85 | "clipboardRead", 86 | mdnBaseURL + "/manifest.json/permissions#clipboard_access", 87 | ], 88 | [ 89 | "clipboardWrite", 90 | mdnBaseURL + "/manifest.json/permissions#clipboard_access", 91 | ], 92 | ["contentSettings", null], 93 | ["contextMenus", null], 94 | ["contextualIdentities", null], 95 | ["cookies", null], 96 | ["debugger", null], 97 | ["dns", null], 98 | ["downloads", null], 99 | ["downloads.open", mdnBaseURL + "/API/downloads/open"], 100 | ["find", null], 101 | ["geolocation", null], 102 | ["history", null], 103 | ["identity", null], 104 | ["idle", null], 105 | ["management", null], 106 | ["menus", null], 107 | ["menus.overrideContext", mdnBaseURL + "/API/menus/overrideContext"], 108 | ["nativeMessaging", null], 109 | ["notifications", null], 110 | ["pageCapture", null], 111 | ["pkcs11", null], 112 | ["privacy", null], 113 | ["proxy", null], 114 | ["search", null], 115 | ["sessions", null], 116 | ["storage", null], 117 | ["tabHide", mdnBaseURL + "/API/tabs/hide"], 118 | ["tabs", null], 119 | ["theme", null], 120 | ["topSites", null], 121 | [ 122 | "unlimitedStorage", 123 | mdnBaseURL + "/manifest.json/permissions#unlimited_storage", 124 | ], 125 | ["webNavigation", null], 126 | ["webRequest", null], 127 | ["webRequestBlocking", mdnBaseURL + "/API/webRequest"], 128 | ]); 129 | 130 | const hostPermissionsInfo = 131 | mdnBaseURL + "/manifest.json/permissions#host_permissions"; 132 | 133 | const PermissionList: FC<{}> = (props) => { 134 | if (Array.isArray(props.children) && props.children.length > 0) { 135 | return <>{props.children}; 136 | } else { 137 | return ( 138 | 139 | None 140 | 141 | ); 142 | } 143 | }; 144 | 145 | type PermissionProps = { 146 | permission: string; 147 | }; 148 | 149 | const Permission = (props: PermissionProps) => { 150 | const type = apiPermissions.has(props.permission) ? "api" : "host"; 151 | 152 | let infoURL = hostPermissionsInfo; 153 | 154 | if (type === "api") { 155 | const url = apiPermissions.get(props.permission); 156 | 157 | infoURL = url ? url : mdnBaseURL + "/API/" + props.permission; 158 | } 159 | 160 | const classes = ["permission", type]; 161 | 162 | return ( 163 | 170 | {props.permission} 171 | 172 | ); 173 | }; 174 | -------------------------------------------------------------------------------- /src/ts/components/RecentExtensions.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import { getRecent, removeFromCache } from "../inspector/CacheHelper"; 3 | import { ExtensionCacheInfo } from "../types/ExtensionCache"; 4 | import UIBox from "./UIBox"; 5 | import { Link } from "wouter-preact"; 6 | import { ExtensionId } from "../types/ExtensionId"; 7 | 8 | type State = { 9 | extensions: ExtensionCacheInfo[]; 10 | }; 11 | 12 | let updateRecentExtensions = (extensions: ExtensionCacheInfo[]) => {}; 13 | 14 | export class RecentExtensions extends Component<{}, State> { 15 | public constructor() { 16 | super(); 17 | 18 | this.state = { extensions: [] }; 19 | 20 | updateRecentExtensions = (extensions: ExtensionCacheInfo[]) => 21 | this.setState({ extensions }); 22 | } 23 | 24 | componentWillMount() { 25 | getRecent().then((extensions) => { 26 | this.setState({ extensions }); 27 | }); 28 | } 29 | 30 | render() { 31 | if (this.state.extensions.length === 0) { 32 | return null; 33 | } 34 | 35 | return ( 36 | 41 |
      42 | {this.state.extensions.map((e) => ( 43 |
    • 44 | 45 |
    • 46 | ))} 47 |
    48 |
    49 | ); 50 | } 51 | } 52 | 53 | function RecentExtension(props: { info: ExtensionCacheInfo }) { 54 | const e = props.info; 55 | return ( 56 |
    57 | {e.name} 58 | {`version ${e.version}`} 59 | 68 |
    69 | ); 70 | } 71 | 72 | function extIdToURL(id: ExtensionId): string { 73 | if (id.source === "url") { 74 | return "/inspect/url/" + encodeURIComponent(id.url); 75 | } 76 | 77 | return `/inspect/${id.source}/${id.id}`; 78 | } 79 | -------------------------------------------------------------------------------- /src/ts/components/TagList.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "preact"; 2 | 3 | type TLProps = { tags: string[]; showAll?: boolean }; 4 | 5 | const ignoreTags = new Set(["code", "image", "text", "html", "stylesheet"]); 6 | 7 | const TagList: FunctionComponent = ({ tags, showAll }) => { 8 | const tagsToShow = showAll 9 | ? tags 10 | : tags.filter((tag) => !ignoreTags.has(tag)); 11 | return ( 12 | 13 | {tagsToShow.map((tag) => ( 14 | 15 | ))} 16 | 17 | ); 18 | }; 19 | 20 | export default TagList; 21 | 22 | type TProps = { tag: string }; 23 | 24 | const tagTooltips: Map = new Map([ 25 | ["web", "web accessible resource"], 26 | ["background", "background script or page"], 27 | ["content", "content script"], 28 | ]); 29 | 30 | const Tag: FunctionComponent = ({ tag }) => ( 31 | 32 | {tag} 33 | 34 | ); 35 | -------------------------------------------------------------------------------- /src/ts/components/UIBox.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | 3 | type Props = { 4 | title: string; 5 | classes?: string[]; 6 | collapsable?: boolean; 7 | }; 8 | 9 | type State = { 10 | expanded: boolean; 11 | }; 12 | 13 | let count = 0; 14 | 15 | export default class UIBox extends Component { 16 | state = { expanded: true }; 17 | 18 | public render() { 19 | const collapsable = this.props.collapsable ?? true; 20 | let classes = ["ui-box"]; 21 | 22 | if (collapsable) { 23 | classes.push("collapsable"); 24 | } 25 | 26 | if (this.props.classes) { 27 | classes = classes.concat(this.props.classes); 28 | } 29 | 30 | if (!this.state.expanded) { 31 | classes.push("collapsed"); 32 | } 33 | 34 | const clickHandler = collapsable 35 | ? () => 36 | this.setState(({ expanded }) => ({ 37 | expanded: !expanded, 38 | })) 39 | : () => {}; 40 | 41 | count++; 42 | const titleId = "uibox-title-bar-" + count; 43 | 44 | return ( 45 |
    46 | 56 | {this.state.expanded ? ( 57 |
    58 | {this.props.children} 59 |
    60 | ) : null} 61 |
    62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/ts/components/previews/HTMLPreview.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import { Inspector } from "../../inspector/InspectorFactory"; 3 | import { TreeNodeDTO } from "../../inspector/worker/FileTree"; 4 | import { FileSelectListener } from "../../types/PackagedFiles"; 5 | 6 | type Props = { 7 | path: string; 8 | name: string; 9 | inspector: Inspector; 10 | onFileSelect: FileSelectListener; 11 | }; 12 | 13 | type State = { 14 | references?: ScriptInfo[]; 15 | }; 16 | 17 | type ScriptInfo = { 18 | path: string; 19 | node: TreeNodeDTO; 20 | }; 21 | 22 | export default class HTMLPreview extends Component { 23 | componentWillMount() { 24 | this.props.inspector 25 | .analyzeHTML(this.props.path) 26 | .then(({ scripts }) => { 27 | this.setState({ references: scripts }); 28 | }); 29 | } 30 | 31 | public render() { 32 | if (this.state.references !== undefined) { 33 | return ( 34 | 57 | ); 58 | } else { 59 | return null; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ts/components/previews/ImagePreview.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import { Inspector } from "../../inspector/InspectorFactory"; 3 | 4 | type Props = { 5 | path: string; 6 | name: string; 7 | inspector: Inspector; 8 | }; 9 | 10 | type State = { 11 | dataURL?: string; 12 | }; 13 | 14 | export default class ImagePreview extends Component { 15 | componentWillMount() { 16 | this.props.inspector 17 | .getFileDownloadURL(this.props.path, 0.0) 18 | .then((dataURL) => this.setState({ dataURL })); 19 | } 20 | 21 | componentWillUnmount() { 22 | if (this.state.dataURL) { 23 | URL.revokeObjectURL(this.state.dataURL); 24 | } 25 | } 26 | 27 | public render() { 28 | if (this.state.dataURL) { 29 | return ( 30 |
    31 | {this.props.name} 36 |
    37 | ); 38 | } else { 39 | return null; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ts/config.ts: -------------------------------------------------------------------------------- 1 | const configOptions: Map = new Map(); 2 | 3 | const storage = window.localStorage; 4 | 5 | export type ConfigOption = { 6 | id: string; 7 | label: string; 8 | type: "select"; 9 | options: string[]; 10 | default: string; 11 | }; 12 | 13 | function addConfigOption(o: ConfigOption): void { 14 | configOptions.set(o.id, o); 15 | } 16 | 17 | addConfigOption({ 18 | id: "open-files-in", 19 | label: "Open files in a", 20 | type: "select", 21 | options: ["modal", "popup", "tab"], 22 | default: "modal", 23 | }); 24 | 25 | export function get(id: string): T { 26 | const value = storage.getItem(`config.${id}`); 27 | const fallback = configOptions.get(id)?.default; 28 | 29 | return (value ?? fallback) as any as T; 30 | } 31 | 32 | export function getAll(): ConfigOption[] { 33 | return Array.from(configOptions.values()); 34 | } 35 | 36 | export function set(id: string, value: T): void { 37 | if (!configOptions.has(id)) { 38 | throw new Error("Not a valid config option."); 39 | } 40 | 41 | storage.setItem(`config.${id}`, "" + value); 42 | } 43 | -------------------------------------------------------------------------------- /src/ts/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'friendly-time'; 2 | -------------------------------------------------------------------------------- /src/ts/inspector/CacheHelper.ts: -------------------------------------------------------------------------------- 1 | import { update, get } from "idb-keyval"; 2 | import { ExtCacheMap, ExtensionCacheInfo } from "../types/ExtensionCache"; 3 | import { ExtensionId } from "../types/ExtensionId"; 4 | 5 | const CACHED_EXTENSIONS_KEY = "cachedExtensions"; 6 | 7 | const CACHE_MAX_AGE = 1000 * 60 * 60 * 24 * 7; 8 | 9 | const extensionCache = (async () => { 10 | if (!caches) { 11 | // Chrome: caches is undefined in non-secure contexts 12 | return Promise.reject(); 13 | } 14 | 15 | // Firefox: rejects with SecurityError when opening a cache in a non-secure context 16 | return caches.open("extensions"); 17 | })().catch(() => console.log("Extension cache not available.")); 18 | 19 | export async function removeOld() { 20 | const cache = await extensionCache; 21 | const deletions: Promise[] = []; 22 | 23 | try { 24 | await update(CACHED_EXTENSIONS_KEY, (m) => { 25 | if (!m) { 26 | return new Map(); 27 | } 28 | 29 | for (const [k, v] of m.entries()) { 30 | const old = Date.now() - v.date.valueOf() > CACHE_MAX_AGE; 31 | 32 | if (old) { 33 | m.delete(k); 34 | 35 | if (cache) { 36 | deletions.push(cache.delete(v.url)); 37 | } 38 | } 39 | } 40 | 41 | return m; 42 | }); 43 | } catch (e) { 44 | console.error(e); 45 | } 46 | 47 | await Promise.all(deletions); 48 | } 49 | 50 | export async function getRecent(): Promise { 51 | const cachedExtensions = await get(CACHED_EXTENSIONS_KEY) 52 | .catch(() => new Map() as ExtCacheMap) 53 | .then((m) => (m ? Array.from(m.values()) : [])); 54 | 55 | cachedExtensions.sort((a, b) => b.date.valueOf() - a.date.valueOf()); 56 | 57 | return cachedExtensions; 58 | } 59 | 60 | export async function storeCacheInfo( 61 | id: ExtensionId, 62 | data: ExtensionCacheInfo 63 | ): Promise { 64 | try { 65 | await update(CACHED_EXTENSIONS_KEY, (m) => 66 | m!.set(extKey(id), data) 67 | ); 68 | } catch (e) { 69 | console.error(e); 70 | } 71 | } 72 | 73 | export async function getCacheData( 74 | id: ExtensionId 75 | ): Promise { 76 | try { 77 | // this will fail in FF private windows 78 | await update(CACHED_EXTENSIONS_KEY, (m) => m ?? new Map()); 79 | } catch (e) { 80 | console.error(e); 81 | return undefined; 82 | } 83 | 84 | const cachedExtensions = (await get("cachedExtensions"))!; 85 | return cachedExtensions.get(extKey(id)); 86 | } 87 | 88 | export async function removeFromCache(id: ExtensionId): Promise { 89 | await update(CACHED_EXTENSIONS_KEY, (m) => { 90 | if(!m) { 91 | return new Map(); 92 | } 93 | 94 | m.delete(extKey(id)); 95 | 96 | return m; 97 | }) 98 | } 99 | 100 | export function getExtensionCache(): Promise { 101 | return extensionCache; 102 | } 103 | 104 | function extKey(id: ExtensionId): string { 105 | return id.source === "url" 106 | ? `${id.source}.${id.url}` 107 | : `${id.source}.${id.id}`; 108 | } 109 | -------------------------------------------------------------------------------- /src/ts/inspector/InspectorFactory.ts: -------------------------------------------------------------------------------- 1 | import * as Comlink from "comlink"; 2 | import { ExtensionId } from "../types/ExtensionId"; 3 | import { StatusListener, WorkerAPI } from "./worker/worker"; 4 | 5 | export type Inspector = Comlink.Remote; 6 | 7 | const basePath = (() => { 8 | if (window.location.pathname.startsWith("/web-ext-inspector/")) { 9 | return "/web-ext-inspector"; 10 | } 11 | return ""; 12 | })(); 13 | 14 | export async function createInspector( 15 | ext: ExtensionId, 16 | onStatusChange?: StatusListener 17 | ): Promise { 18 | if (onStatusChange) { 19 | onStatusChange("initializing worker..."); 20 | } 21 | 22 | const worker = Comlink.wrap( 23 | new Worker(basePath + "/worker.bundle.js", { name: "ExtensionWorker" }) 24 | ); 25 | 26 | if (onStatusChange) { 27 | await worker.init(ext, Comlink.proxy(onStatusChange)); 28 | } else { 29 | await worker.init(ext); 30 | } 31 | 32 | return worker; 33 | } 34 | -------------------------------------------------------------------------------- /src/ts/inspector/worker/AMO.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API docs: 3 | * https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#detail 4 | */ 5 | 6 | const API = "https://addons.mozilla.org/api/v5"; 7 | 8 | export type Details = { 9 | id: number; 10 | authors: ExtensionAuthor[]; 11 | current_version: Version; 12 | guid: string; 13 | icon_url: string; 14 | name: { [key: string]: string }; 15 | last_updated: string; 16 | created: string; 17 | slug: string; 18 | url: string; 19 | }; 20 | 21 | type ExtensionAuthor = { 22 | id: number; 23 | name: string; 24 | url: string; 25 | username: string; 26 | }; 27 | 28 | type Version = { 29 | id: number; 30 | channel: "unlisted" | "listed"; 31 | file: File; 32 | version: string; 33 | compatibility: any; 34 | }; 35 | 36 | type File = { 37 | id: number; 38 | created: string; 39 | hash: string; 40 | is_mozilla_signed_extension: boolean; 41 | optional_permissions: string[]; 42 | permissions: string[]; 43 | size: number; 44 | status: string; 45 | url: string; 46 | }; 47 | 48 | export async function getInfo(slug: string): Promise
    { 49 | const response = await fetch(`${API}/addons/addon/${slug}/`); 50 | return response.json(); 51 | } 52 | -------------------------------------------------------------------------------- /src/ts/inspector/worker/CWS.ts: -------------------------------------------------------------------------------- 1 | const chromeVersion = "96.0.4664.110"; 2 | 3 | export function getProxiedDownloadURL(extId: string): string { 4 | return `https://web-ext-download-eu.herokuapp.com/cws/${extId}`; 5 | } 6 | 7 | export function getDownloadURL(extId: string): string { 8 | const host = "clients2.google.com"; 9 | const path = "/service/update2/crx"; 10 | const params = `response=redirect&prodversion=${chromeVersion}&x=id%3D${extId}%26installsource%3Dondemand%26uc&nacl_arch=x86-64&acceptformat=crx2,crx3`; 11 | return `https://${host}${path}?${params}`; 12 | } 13 | -------------------------------------------------------------------------------- /src/ts/inspector/worker/CodeRenderer.ts: -------------------------------------------------------------------------------- 1 | import "./helpers/PrismJSSetup"; 2 | import { 3 | cssBreakAfterComment, 4 | cssBreakAfterProperty, 5 | cssBreakAfterBlockStart, 6 | cssBreakAfterBlockEnd, 7 | cssBreakBeforeBlockEnd 8 | } from "prismjs-token-stream-transformer/dist/prefabs/css"; 9 | import Prism from "prismjs"; 10 | import { AnyToken } from "prismjs-token-stream-transformer/dist/Tokens"; 11 | 12 | export type SupportedLanguage = "javascript" | "json" | "markup" | "css" | "plaintext"; 13 | 14 | type CodeHighlighter = (code: string) => string; 15 | 16 | const highlighters = new Map(); 17 | highlighters.set("markup", (code) => 18 | Prism.highlight(code, Prism.languages.markup, "markup") 19 | ); 20 | highlighters.set("javascript", (code) => 21 | Prism.highlight(code, Prism.languages.javascript, "javascript") 22 | ); 23 | highlighters.set("json", (code) => 24 | Prism.highlight(code, Prism.languages.javascript, "javascript") 25 | ); 26 | highlighters.set("css", (code) => 27 | Prism.highlight(code, Prism.languages.css, "css") 28 | ); 29 | highlighters.set("plaintext", (code) => 30 | Prism.highlight(code, Prism.languages.plain, "plain") 31 | ); 32 | 33 | Prism.hooks.add("after-tokenize", (env) => { 34 | const tokens = env.tokens as AnyToken[]; 35 | 36 | if (env.language === "css") { 37 | cssBreakAfterComment.applyTo(tokens); 38 | cssBreakAfterProperty.applyTo(tokens); 39 | cssBreakAfterBlockStart.applyTo(tokens); 40 | cssBreakAfterBlockEnd.applyTo(tokens); 41 | cssBreakBeforeBlockEnd.applyTo(tokens); 42 | } 43 | }); 44 | 45 | export function renderCode(code: string, language: SupportedLanguage): string { 46 | const highlighter = highlighters.get(language); 47 | 48 | if(language === "json") { 49 | code = JSON.stringify(JSON.parse(code), null, 4); 50 | } 51 | 52 | if (!highlighter) { 53 | return code; 54 | } 55 | 56 | return highlighter(code); 57 | } 58 | -------------------------------------------------------------------------------- /src/ts/inspector/worker/Extension.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionId } from "../../types/ExtensionId"; 2 | import AsyncEvent from "../../utils/AsyncEvent"; 3 | import { createFileTree, TreeFile, TreeFolder, TreeNode } from "./FileTree"; 4 | import * as zip from "@zip.js/zip.js"; 5 | import { Manifest } from "../../types/Manifest"; 6 | import { ExtensionDetails } from "../../types/ExtensionDetails"; 7 | import * as ScriptFinder from "./helpers/ScriptFinder"; 8 | import * as ResourceLocator from "./helpers/ResourceLocator"; 9 | import { Translations } from "../../types/Translations"; 10 | import { OptionalMetaData } from "../../types/ExtensionCache"; 11 | 12 | export default class Extension { 13 | readonly id: ExtensionId; 14 | manifest: Manifest; 15 | details: ExtensionDetails; 16 | 17 | private readonly initialized = new AsyncEvent("extension initialized"); 18 | rootDir: TreeFolder; // TODO consider making this private 19 | private defaultTranslations: Translations | null = null; 20 | 21 | private constructor( 22 | id: ExtensionId, 23 | zipData: Blob, 24 | extraInfo: OptionalMetaData 25 | ) { 26 | this.id = id; 27 | const zipReader = new zip.ZipReader(new zip.BlobReader(zipData)); 28 | 29 | (async () => { 30 | // load files & directory structure 31 | this.rootDir = createFileTree(await zipReader.getEntries()); 32 | 33 | // load manifest 34 | const manifestNode = this.rootDir.get("manifest.json"); 35 | if (!(manifestNode instanceof TreeFile)) { 36 | throw new Error("Could not find manifest.json."); 37 | } 38 | manifestNode.addTag("manifest"); 39 | // TODO: manifest.json may contain single line comments (//) 40 | this.manifest = JSON.parse(await manifestNode.getTextContent()); 41 | if (this.manifest.manifest_version !== 2) { 42 | console.error( 43 | `Unsupported manifest version ${this.manifest.manifest_version}.` 44 | ); 45 | // TODO: add manifest v3 (currently Chrome-only) support 46 | } 47 | 48 | // translations 49 | if (this.manifest.default_locale !== undefined) { 50 | const messagesFile = this.rootDir.get( 51 | "_locales/" + 52 | this.manifest.default_locale + 53 | "/messages.json" 54 | ); 55 | if (messagesFile instanceof TreeFile) { 56 | this.defaultTranslations = JSON.parse( 57 | await messagesFile.getTextContent() 58 | ); 59 | } 60 | } 61 | 62 | await zipReader.close(); 63 | 64 | await this.createDetails(extraInfo); 65 | await this.findScriptsAndResources(); 66 | 67 | this.initialized.fire(); 68 | })(); 69 | } 70 | 71 | public static async create( 72 | id: ExtensionId, 73 | zipData: Blob, 74 | extraInfo: OptionalMetaData = {} 75 | ): Promise { 76 | const extension = new Extension(id, zipData, extraInfo); 77 | await extension.initialized.waitFor(); 78 | return extension; 79 | } 80 | 81 | public async getFileDownloadURL( 82 | path: string, 83 | timeout: number = 10.0 84 | ): Promise { 85 | if (!this.rootDir) { 86 | await this.initialized.waitFor(); 87 | } 88 | 89 | const fileNode = this.rootDir.get(path); 90 | 91 | if (!(fileNode instanceof TreeFile)) { 92 | throw new Error(`"${path}" is not a file.`); 93 | } 94 | 95 | let blob: Blob = await fileNode.entry.getData!(new zip.BlobWriter()); 96 | if (path.endsWith(".svg")) { 97 | blob = blob.slice(0, blob.size, "image/svg+xml"); 98 | } 99 | 100 | const url = URL.createObjectURL(blob); 101 | 102 | if (timeout > 0.0) { 103 | setTimeout(() => URL.revokeObjectURL(url), timeout * 1000); 104 | } 105 | 106 | return url; 107 | } 108 | 109 | public async getFileOrFolder(path: string): Promise { 110 | await this.initialized.waitFor(); 111 | 112 | const node = this.rootDir.get(path); 113 | 114 | if (!node) { 115 | throw new Error(`No such file or folder: ${path}`); 116 | } 117 | 118 | return node; 119 | } 120 | 121 | private fileExists(path: string): boolean { 122 | if (!this.rootDir) { 123 | throw new Error("File tree not initialized."); 124 | } 125 | 126 | const node = this.rootDir.get(path); 127 | 128 | if (!node) { 129 | return false; 130 | } 131 | 132 | return node instanceof TreeFile; 133 | } 134 | 135 | private async createDetails(extraInfo: OptionalMetaData): Promise { 136 | const manifest = this.manifest; 137 | 138 | let iconUrl: string | undefined = undefined; 139 | 140 | if (manifest.icons) { 141 | const sizes = Object.keys(manifest.icons).map((s) => 142 | parseInt(s, 10) 143 | ); 144 | // sort sizes descending 145 | sizes.sort((a, b) => b - a); 146 | 147 | if (sizes.length > 0) { 148 | const optimalSizes = sizes 149 | .filter((s) => this.fileExists(manifest.icons!["" + s])) 150 | .filter((s) => s >= 48 && s <= 96); 151 | const size = 152 | optimalSizes.length > 0 ? optimalSizes[0] : sizes[0]; 153 | iconUrl = await this.getFileDownloadURL( 154 | manifest.icons["" + size], 155 | 0.0 // TODO: revoke URL before worker gets destroyed 156 | ); 157 | } 158 | } 159 | 160 | this.details = { 161 | name: this.translate(manifest.name), 162 | version: manifest.version, 163 | size: this.rootDir.byteSize, 164 | icon_url: iconUrl, 165 | author: manifest.author ?? extraInfo.author, 166 | last_updated: extraInfo.last_updated, 167 | created: extraInfo.created, 168 | }; 169 | } 170 | 171 | private async findScriptsAndResources(): Promise { 172 | const root = this.rootDir; 173 | const manifest = this.manifest; 174 | 175 | await ScriptFinder.identifyBackgroundScripts(root, manifest); 176 | await ScriptFinder.identifyDevtoolsScripts(root, manifest); 177 | 178 | ScriptFinder.identifyContentScripts(root, manifest); 179 | ScriptFinder.identifySidebarScripts(root, manifest); 180 | ScriptFinder.identifyUserScriptAPI(root, manifest); 181 | ScriptFinder.identifyActionScripts(root, manifest); 182 | 183 | ResourceLocator.identifyWebAccessibleResources(root, manifest); 184 | } 185 | 186 | private translate(rawString: string): string { 187 | if (this.defaultTranslations === null) { 188 | return rawString; 189 | } 190 | 191 | const matches = rawString.match(/^__MSG_(.+)__$/); 192 | 193 | if (matches === null || matches.length !== 2) { 194 | return rawString; 195 | } 196 | 197 | const messageName = matches[1]; 198 | const translation = this.defaultTranslations[messageName]; 199 | 200 | if (translation) { 201 | return translation.message; 202 | } 203 | 204 | return rawString; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/ts/inspector/worker/ExtensionProvider.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionId } from "../../types/ExtensionId"; 2 | import Extension from "./Extension"; 3 | import * as AMOAPI from "./AMO"; 4 | import * as CWS from "./CWS"; 5 | import { 6 | getExtensionCache, 7 | getCacheData, 8 | storeCacheInfo, 9 | } from "../CacheHelper"; 10 | 11 | type StatusUpdater = (status: string) => void; 12 | 13 | export async function getExtension( 14 | ext: ExtensionId, 15 | updateStatus: StatusUpdater 16 | ): Promise { 17 | const cacheInfo = await getCacheData(ext); 18 | 19 | let downloadUrl = cacheInfo?.url; 20 | let extraInfo = cacheInfo?.extraInfo; 21 | 22 | if (!cacheInfo) { 23 | if (ext.source === "firefox") { 24 | const info = await AMOAPI.getInfo(ext.id); 25 | downloadUrl = info.current_version.file.url; 26 | extraInfo = { 27 | last_updated: info.last_updated, 28 | created: info.created, 29 | author: info.authors 30 | .map((a) => a.name ?? a.username) 31 | .join(", "), 32 | }; 33 | } else if (ext.source === "chrome") { 34 | downloadUrl = CWS.getProxiedDownloadURL(ext.id); 35 | } else if (ext.source === "url") { 36 | downloadUrl = ext.url; 37 | } 38 | } 39 | 40 | let blob: Blob | null = null; 41 | const cachedResponse = await getResponseFromCache(downloadUrl!); 42 | 43 | if (cachedResponse) { 44 | blob = await cachedResponse.blob(); 45 | 46 | if (cacheInfo) { 47 | const age = Date.now() - cacheInfo.date.valueOf(); 48 | const aDay = 1000 * 60 * 60 * 24; 49 | if (age > aDay) { 50 | // TODO check for new version 51 | } 52 | } 53 | } else { 54 | updateStatus("downloading..."); 55 | const response = await fetch(downloadUrl!); 56 | 57 | if (!response.ok) { 58 | return Promise.reject( 59 | `Failed to download extension. ${response.statusText} (${response.status})` 60 | ); 61 | } 62 | 63 | // navigator.storage is only available in secure contexts 64 | if (navigator.storage) { 65 | const cache = await getExtensionCache(); 66 | const { quota, usage } = await navigator.storage.estimate(); 67 | 68 | if (cache && quota! < usage!) { 69 | // response can only be used once 70 | blob = await response.clone().blob(); 71 | 72 | if (usage! + blob.size <= quota!) { 73 | // response can only be used once 74 | cache.put(downloadUrl!, response); 75 | } else if (usage! > 0) { 76 | // TODO remove oldest entry 77 | } 78 | } 79 | } 80 | 81 | if (!blob) { 82 | blob = await response.blob(); 83 | } 84 | } 85 | 86 | updateStatus("extracting..."); 87 | 88 | const extension = await Extension.create(ext, blob, extraInfo); 89 | 90 | if (ext.source !== "url") { 91 | // TODO 92 | await storeCacheInfo(ext, { 93 | id: ext, 94 | url: downloadUrl!, 95 | date: new Date(), 96 | version: extension.details.version, 97 | name: extension.details.name, 98 | extraInfo, 99 | }); 100 | } 101 | 102 | return extension; 103 | } 104 | 105 | async function getResponseFromCache( 106 | url: string 107 | ): Promise { 108 | const cache = await getExtensionCache(); 109 | 110 | if (!cache) { 111 | return undefined; 112 | } 113 | 114 | const cachedResponse = await cache.match(url); 115 | 116 | if (cachedResponse) { 117 | console.info(`Loading extension from cache.`); 118 | return cachedResponse; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/ts/inspector/worker/FileTree.ts: -------------------------------------------------------------------------------- 1 | import * as zip from "@zip.js/zip.js"; 2 | 3 | export type TreeNodeDTO = 4 | | { name: string; type: "folder"; numFiles: number } 5 | | { name: string; type: "file"; size: number; tags: string[] }; 6 | 7 | export abstract class TreeNode { 8 | public name: string; 9 | protected parent: TreeFolder | undefined; 10 | 11 | public constructor(name: string, parent?: TreeFolder) { 12 | this.name = name; 13 | this.parent = parent; 14 | } 15 | 16 | public abstract toDTO(): TreeNodeDTO; 17 | 18 | public get path(): string { 19 | if (this.parent) { 20 | const parentPath = this.parent.path; 21 | return parentPath ? parentPath + "/" + this.name : this.name; 22 | } else if (this instanceof TreeFolder) { 23 | return ""; 24 | } else { 25 | return this.name; 26 | } 27 | } 28 | } 29 | 30 | export class TreeFolder extends TreeNode { 31 | public children: Map = new Map(); 32 | private fileCount: number = 0; 33 | private uncompressedSize: number = 0; 34 | 35 | public insertEntry(entry: zip.Entry): void { 36 | this.insert(entry.filename, entry); 37 | } 38 | 39 | private insert(relPath: string, entry: zip.Entry): void { 40 | if (entry.directory) { 41 | // we extract folders from file paths 42 | // some zips do not contain directory entries 43 | return; 44 | } 45 | 46 | this.fileCount++; 47 | let parts = relPath.split("/"); 48 | const name = parts.shift()!; 49 | 50 | if (parts.length === 0) { 51 | const file = new TreeFile(entry, name, this); 52 | this.children.set(name, file); 53 | this.uncompressedSize += entry.uncompressedSize; 54 | } else { 55 | const folder = 56 | (this.children.get(name) as TreeFolder) ?? 57 | new TreeFolder(name, this); 58 | this.children.set(name, folder); 59 | folder.insert(parts.join("/"), entry); 60 | } 61 | } 62 | 63 | public get numFiles(): number { 64 | return this.fileCount; 65 | } 66 | 67 | public get byteSize(): number { 68 | return this.uncompressedSize; 69 | } 70 | 71 | public get(path: string): TreeNode | undefined { 72 | if (path === "") { 73 | return this; 74 | } 75 | 76 | let parts = path.split("/"); 77 | 78 | if (parts.length > 0 && parts[0] === "") { 79 | // fix paths starting with / 80 | parts.shift(); 81 | } 82 | 83 | const name = parts.shift()!; 84 | 85 | let node; 86 | 87 | if (name === "..") { 88 | node = this.parent; 89 | } else if (name === ".") { 90 | node = this; 91 | } else { 92 | node = this.children.get(name); 93 | } 94 | 95 | if (node instanceof TreeFolder && parts.length > 0) { 96 | return node.get(parts.join("/")); 97 | } 98 | 99 | return node; 100 | } 101 | 102 | public getAll(path: string): TreeNode[] { 103 | if (path === "") { 104 | return [this]; 105 | } 106 | 107 | let parts = path.split("/"); 108 | const name = parts.shift()!; 109 | 110 | if (name === "*") { 111 | const children = Array.from(this.children.values()); 112 | const subPath = parts.length > 0 ? parts.join("/") : "*"; 113 | return children.flatMap((c) => { 114 | if (c instanceof TreeFolder) { 115 | return c.getAll(subPath); 116 | } else { 117 | return parts.length > 0 ? [] : c; 118 | } 119 | }); 120 | } 121 | 122 | let node; 123 | 124 | if (name === "..") { 125 | node = this.parent; 126 | } else if (name === ".") { 127 | node = this; 128 | } else { 129 | node = this.children.get(name); 130 | } 131 | 132 | if (node instanceof TreeFolder && parts.length > 0) { 133 | return node.getAll(parts.join("/")); 134 | } else if (node) { 135 | return [node]; 136 | } else { 137 | return []; 138 | } 139 | } 140 | 141 | public toDTO(): TreeNodeDTO { 142 | return { name: this.name, type: "folder", numFiles: this.fileCount }; 143 | } 144 | } 145 | 146 | export class TreeFile extends TreeNode { 147 | public entry: zip.Entry; 148 | private tags: Set = new Set(); 149 | 150 | public constructor(entry: zip.Entry, name: string, parent: TreeFolder) { 151 | super(name, parent); 152 | this.entry = entry; 153 | 154 | if (entry.directory) { 155 | throw new Error(`Entry is a directory: ${entry.filename}`); 156 | } 157 | 158 | if (entry.encrypted) { 159 | this.tags.add("encrypted"); 160 | } 161 | 162 | if (/\.(js|jsx|json)$/i.test(name)) { 163 | this.tags.add("code"); 164 | } else if (/\.(html|htm)$/i.test(name)) { 165 | this.tags.add("html"); 166 | } else if (/\.(jpg|png|gif|svg)$/i.test(name)) { 167 | this.tags.add("image"); 168 | } else if (/\.(txt|md)$/i.test(name) || name === "LICENSE") { 169 | this.tags.add("text"); 170 | } else if (/\.css$/i.test(name)) { 171 | this.tags.add("stylesheet"); 172 | } 173 | } 174 | 175 | public addTag(tag: string): void { 176 | this.tags.add(tag); 177 | } 178 | 179 | public hasTag(tag: string): boolean { 180 | return this.tags.has(tag); 181 | } 182 | 183 | public toDTO(): TreeNodeDTO { 184 | return { 185 | name: this.name, 186 | type: "file", 187 | size: this.entry.uncompressedSize, 188 | tags: Array.from(this.tags), 189 | }; 190 | } 191 | 192 | public getTextContent(): Promise { 193 | return this.entry.getData!(new zip.TextWriter()); 194 | } 195 | } 196 | 197 | export function createFileTree(entries: zip.Entry[]): TreeFolder { 198 | const root = new TreeFolder("root"); 199 | for (const entry of entries) { 200 | root.insertEntry(entry); 201 | } 202 | return root; 203 | } 204 | -------------------------------------------------------------------------------- /src/ts/inspector/worker/helpers/PrismJSSetup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This code has to be executed before prismjs, order of imports matters: 3 | * ```js 4 | * import "PrismJSSetup.ts"; 5 | * import Prism from "prismjs"; 6 | * ``` 7 | */ 8 | 9 | self.Prism = self.Prism || {}; 10 | // @ts-ignore 11 | self.Prism.disableWorkerMessageHandler = true; 12 | // @ts-ignore 13 | self.Prism.manual = true; 14 | -------------------------------------------------------------------------------- /src/ts/inspector/worker/helpers/ResourceLocator.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from "../../../types/Manifest"; 2 | import { cleanPath } from "../../../utils/paths"; 3 | import { TreeFile, TreeFolder } from "../FileTree"; 4 | 5 | export function identifyWebAccessibleResources( 6 | root: TreeFolder, 7 | manifest: Manifest 8 | ): void { 9 | if (!manifest.web_accessible_resources) { 10 | return; 11 | } 12 | 13 | manifest.web_accessible_resources 14 | .flatMap((path) => root.getAll(cleanPath(path))) 15 | .forEach((node) => { 16 | if (node instanceof TreeFile) { 17 | node.addTag("web"); 18 | } 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/ts/inspector/worker/helpers/ScriptFinder.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from "../../../types/Manifest"; 2 | import { cleanPath, getFolder, joinPaths } from "../../../utils/paths"; 3 | import { TreeFile, TreeFolder, TreeNode } from "../FileTree"; 4 | import * as zip from "@zip.js/zip.js"; 5 | import { extractScripts } from "../../../utils/html"; 6 | 7 | export async function identifyBackgroundScripts( 8 | root: TreeFolder, 9 | manifest: Manifest 10 | ): Promise { 11 | if (!manifest.background) { 12 | return; 13 | } 14 | 15 | if (manifest.background.scripts) { 16 | manifest.background.scripts 17 | .map((path) => root.get(cleanPath(path))) 18 | .filter(isFile) 19 | .forEach((file) => file.addTag("background")); 20 | } else if (manifest.background.page) { 21 | findScriptNodes(manifest.background.page, root, "background"); 22 | } else { 23 | throw new Error("Unsupported background scripts."); 24 | } 25 | } 26 | 27 | export async function identifyDevtoolsScripts( 28 | root: TreeFolder, 29 | manifest: Manifest 30 | ): Promise { 31 | if (!manifest.devtools_page) { 32 | return; 33 | } 34 | 35 | findScriptNodes(manifest.devtools_page, root, "devtools"); 36 | } 37 | 38 | export function identifyContentScripts( 39 | root: TreeFolder, 40 | manifest: Manifest 41 | ): void { 42 | if (!manifest.content_scripts) { 43 | return; 44 | } 45 | 46 | const scripts = manifest.content_scripts 47 | .flatMap((cs) => cs.js ?? []) 48 | .map((path) => root.get(cleanPath(path))) 49 | .filter(isFile); 50 | 51 | scripts.forEach((script) => script.addTag("content")); 52 | } 53 | 54 | export function identifyUserScriptAPI( 55 | root: TreeFolder, 56 | manifest: Manifest 57 | ): void { 58 | if (manifest.user_scripts && manifest.user_scripts.api_script) { 59 | const node = root.get(cleanPath(manifest.user_scripts.api_script)); 60 | if (!isFile(node)) { 61 | return; 62 | } 63 | 64 | node.addTag("user-script-api"); 65 | } 66 | } 67 | 68 | export async function identifySidebarScripts( 69 | root: TreeFolder, 70 | manifest: Manifest 71 | ): Promise { 72 | if (!manifest.sidebar_action) { 73 | return; 74 | } 75 | 76 | await findScriptNodes( 77 | manifest.sidebar_action.default_panel, 78 | root, 79 | "sidebar" 80 | ); 81 | } 82 | 83 | export async function identifyActionScripts( 84 | root: TreeFolder, 85 | manifest: Manifest 86 | ): Promise { 87 | if (manifest.browser_action && manifest.browser_action.default_popup) { 88 | await findScriptNodes( 89 | manifest.browser_action.default_popup, 90 | root, 91 | "browser-action" 92 | ); 93 | } 94 | 95 | if (manifest.page_action && manifest.page_action.default_popup) { 96 | await findScriptNodes( 97 | manifest.page_action.default_popup, 98 | root, 99 | "page-action" 100 | ); 101 | } 102 | } 103 | 104 | export async function findScriptNodes( 105 | pagePath: string, 106 | root: TreeFolder, 107 | tag?: string 108 | ): Promise { 109 | const path = cleanPath(pagePath); 110 | const basePath = getFolder(path); 111 | const htmlNode = root.get(path); 112 | if (!isFile(htmlNode) || !htmlNode.hasTag("html")) { 113 | return []; 114 | } 115 | 116 | if (tag) { 117 | htmlNode.addTag(tag); 118 | } 119 | 120 | const html = await htmlNode.entry.getData!(new zip.TextWriter()); 121 | const scripts = extractScripts(html); 122 | let results = []; 123 | 124 | for (const script of scripts) { 125 | const node = root.get(joinPaths(basePath, script.src)); 126 | 127 | if (!isFile(node)) { 128 | continue; 129 | } 130 | 131 | if (tag) { 132 | node.addTag(tag); 133 | if (script.type === "module") { 134 | node.addTag("module"); 135 | } 136 | } 137 | 138 | results.push(node); 139 | } 140 | 141 | return results; 142 | } 143 | 144 | const isFile = function (node: TreeNode | undefined): node is TreeFile { 145 | return node !== undefined && node instanceof TreeFile; 146 | }; 147 | -------------------------------------------------------------------------------- /src/ts/inspector/worker/worker.ts: -------------------------------------------------------------------------------- 1 | import * as Comlink from "comlink"; 2 | import * as zip from "@zip.js/zip.js"; 3 | import { TreeFile, TreeFolder, TreeNodeDTO } from "./FileTree"; 4 | import { Manifest } from "../../types/Manifest"; 5 | import * as ScriptFinder from "./helpers/ScriptFinder"; 6 | import AsyncEvent from "../../utils/AsyncEvent"; 7 | import { renderCode, SupportedLanguage } from "./CodeRenderer"; 8 | import { ExtensionDetails } from "../../types/ExtensionDetails"; 9 | import Extension from "./Extension"; 10 | import { getExtension } from "./ExtensionProvider"; 11 | import { ExtensionId } from "../../types/ExtensionId"; 12 | 13 | zip.configure({ 14 | useWebWorkers: false, // this is already a worker 15 | }); 16 | 17 | export type StatusListener = (status: string) => void; 18 | 19 | export class WorkerAPI { 20 | private statusListener: StatusListener = () => {}; 21 | private initialized: AsyncEvent = new AsyncEvent("WorkerInitialized"); 22 | private extension: Extension; 23 | 24 | public async init( 25 | ext: ExtensionId, 26 | statusListener?: StatusListener 27 | ): Promise { 28 | this.statusListener = statusListener ?? this.statusListener; 29 | 30 | this.extension = await getExtension(ext, this.statusListener); 31 | this.setStatus(""); 32 | 33 | this.initialized.fire(); 34 | } 35 | 36 | public async getDetails(): Promise { 37 | await this.initialized.waitFor(); 38 | return this.extension.details; 39 | } 40 | 41 | public async listDirectoryContents(path: string): Promise { 42 | const dir = await this.extension.getFileOrFolder(path); 43 | 44 | if (dir instanceof TreeFolder) { 45 | const contents = Array.from(dir.children.values(), (tn) => 46 | tn.toDTO() 47 | ); 48 | contents.sort((a, b) => { 49 | if (a.type === b.type) { 50 | return a.name.localeCompare(b.name); 51 | } else { 52 | return a.type === "folder" ? -1 : 1; 53 | } 54 | }); 55 | return contents; 56 | } else { 57 | throw new Error(`${path} is not a directory.`); 58 | } 59 | } 60 | 61 | public async getManifest(): Promise { 62 | await this.initialized.waitFor(); 63 | return this.extension.manifest; 64 | } 65 | 66 | public async analyzeHTML(path: string) { 67 | const scripts = await ScriptFinder.findScriptNodes( 68 | path, 69 | this.extension.rootDir 70 | ); 71 | 72 | return { 73 | scripts: scripts.map((s) => ({ 74 | path: s.path, 75 | node: s.toDTO(), 76 | })), 77 | }; 78 | } 79 | 80 | public async getPrettyCode(path: string): Promise { 81 | const file = (await this.extension.getFileOrFolder(path)) as TreeFile; 82 | 83 | if (!file || file instanceof TreeFolder) { 84 | throw new Error(`File ${path} not found.`); 85 | } 86 | 87 | let content: string = await file.entry.getData!(new zip.TextWriter()); 88 | let language: SupportedLanguage = "plaintext"; 89 | 90 | if (/\.(htm|html|xml)$/i.test(file.name)) { 91 | language = "markup"; 92 | } else if (/\.(js|mjs)$/i.test(file.name)) { 93 | language = "javascript"; 94 | } else if (/\.json$/i.test(file.name)) { 95 | language = "json"; 96 | } else if (/\.css$/i.test(file.name)) { 97 | language = "css"; 98 | } 99 | 100 | const html = renderCode(content, language); 101 | 102 | return { 103 | language, 104 | code: html, 105 | }; 106 | } 107 | 108 | public async getFileDownloadURL( 109 | path: string, 110 | timeout: number = 10.0 111 | ): Promise { 112 | await this.initialized.waitFor(); 113 | return this.extension.getFileDownloadURL(path, timeout); 114 | } 115 | 116 | private setStatus(status: string) { 117 | this.statusListener(status); 118 | } 119 | } 120 | 121 | Comlink.expose(new WorkerAPI()); 122 | 123 | type HighlightedCode = { 124 | language: SupportedLanguage; 125 | code: string; 126 | }; 127 | -------------------------------------------------------------------------------- /src/ts/main.tsx: -------------------------------------------------------------------------------- 1 | import * as Preact from "preact"; 2 | import { Router, Link, Switch, Route, useLocation } from "wouter-preact"; 3 | 4 | import ExtensionInspector from "./components/ExtensionInspector"; 5 | import ExtensionSelector from "./components/ExtensionSelector"; 6 | import ConfigUI from "./components/ConfigUI"; 7 | import { setPortal } from "./modal"; 8 | import * as LFP from "./utils/LocalFileProvider"; 9 | 10 | // create styles (in ) 11 | import "prismjs/themes/prism-okaidia.css"; 12 | import "../less/app.less"; 13 | import { removeOld } from "./inspector/CacheHelper"; 14 | 15 | const pathBase = window.location.host.endsWith("github.io") 16 | ? "/web-ext-inspector" 17 | : undefined; 18 | 19 | const App: Preact.FunctionalComponent = () => { 20 | const urlParams = new URLSearchParams(window.location.search); 21 | const navigate = useLocation()[1]; 22 | 23 | if (urlParams.has("route")) { 24 | console.info("routing to " + urlParams.get("route")); 25 | navigate(urlParams.get("route")!, { replace: true }); 26 | } else if (urlParams.has("extension")) { 27 | // map old URLs to new URLs 28 | if (/^[a-z0-9\-]+$/.test(urlParams.get("extension")!)) { 29 | const sources = new Map([ 30 | ["amo", "firefox"], 31 | ["cws", "chrome"], 32 | ]); 33 | const store = urlParams.get("store"); 34 | const source = 35 | store && sources.has(store) ? sources.get(store) : "firefox"; 36 | const route = 37 | "/inspect/" + source + "/" + urlParams.get("extension")!; 38 | const prefix = pathBase ? pathBase : ""; 39 | navigate(prefix + route, { replace: true }); 40 | } 41 | } 42 | 43 | return ( 44 | 45 |
    46 |

    47 | 48 | 49 | Extension Inspector 50 | 51 | Extension Inspector 52 | 53 |

    54 |
    55 | 56 | 57 | {({ id }) => ( 58 | 61 | )} 62 | 63 | 64 | {({ id }) => ( 65 | 68 | )} 69 | 70 | 71 | {({ id }) => { 72 | const url = LFP.getURL(id); 73 | const navigate = useLocation()[1]; 74 | if (!url) { 75 | navigate("/", { replace: true }); 76 | return null; 77 | } 78 | return ( 79 | 82 | ); 83 | }} 84 | 85 | 86 | 87 | 88 | 89 |
    90 | 99 | 100 |
    101 | ); 102 | }; 103 | 104 | const root = document.createElement("div"); 105 | root.id = "root"; 106 | document.body.appendChild(root); 107 | 108 | const modalPortal = document.createElement("div"); 109 | modalPortal.id = "modalPortal"; 110 | document.body.appendChild(modalPortal); 111 | setPortal(modalPortal); 112 | 113 | removeOld(); 114 | Preact.render(, root); 115 | -------------------------------------------------------------------------------- /src/ts/modal.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent as FC, JSX, render } from "preact"; 2 | 3 | let portal: HTMLElement | null = null; 4 | 5 | export function setPortal(modalPortal: HTMLElement): void { 6 | portal = modalPortal; 7 | } 8 | 9 | type ModalProps = { 10 | title: string; 11 | classes: string[]; 12 | }; 13 | 14 | const ModalWindow: FC = (props) => { 15 | const classes = ["modal-window"].concat(props.classes); 16 | return ( 17 |
    e.stopPropagation()} 21 | > 22 |
    23 |

    {props.title}

    24 | 32 |
    33 | 34 |
    35 | ); 36 | }; 37 | 38 | export function showModal( 39 | title: string, 40 | classes: string[], 41 | content: JSX.Element 42 | ): void { 43 | if (portal === null) { 44 | throw new Error("Modal portal not set."); 45 | } 46 | 47 | render( 48 | 49 | {content} 50 | , 51 | portal 52 | ); 53 | } 54 | 55 | export function hideModal() { 56 | if (portal) { 57 | render(null, portal); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/ts/openViewer.tsx: -------------------------------------------------------------------------------- 1 | import * as Comlink from "comlink"; 2 | import { Inspector } from "./inspector/InspectorFactory"; 3 | import { getFile } from "./utils/paths"; 4 | import { FileViewerAPI } from "./viewer/viewer"; 5 | import * as config from "./config"; 6 | import { showModal } from "./modal"; 7 | 8 | const windowFeatures = "menubar=no,location=no,scrollbars=yes"; 9 | 10 | export async function openFileViewer(filePath: string, remote: Inspector): Promise { 11 | const openIn = config.get("open-files-in"); 12 | 13 | if (openIn === "popup" || openIn === "tab") { 14 | openFileViewerWindow(filePath, remote, openIn === "popup"); 15 | } else if (openIn === "modal") { 16 | await openFileViewerModal(filePath, remote); 17 | } 18 | } 19 | 20 | async function openFileViewerModal(filePath: string, remote: Inspector) { 21 | const result = await remote.getPrettyCode(filePath); 22 | 23 | const html = { __html: result.code }; 24 | 25 | showModal( 26 | getFile(filePath), 27 | ["file-viewer"], 28 |
    29 |             
    33 |         
    34 | ); 35 | } 36 | 37 | function openFileViewerWindow( 38 | filePath: string, 39 | remote: Inspector, 40 | popup: boolean 41 | ): void { 42 | const fileWnd = window.open( 43 | "/file.html?path=" + filePath, 44 | "ext-file-window", 45 | popup ? windowFeatures : "" 46 | ); 47 | 48 | if (fileWnd === null) { 49 | alert("Could not open file viewer popup."); 50 | return; 51 | } 52 | 53 | fileWnd.onload = async () => { 54 | const viewer = Comlink.wrap( 55 | Comlink.windowEndpoint(fileWnd) 56 | ); 57 | 58 | if ((await viewer.ping()) !== "pong") { 59 | alert("Failed to communicate with popup window."); 60 | return; 61 | } 62 | 63 | const result = await remote.getPrettyCode(filePath); 64 | 65 | viewer.show(getFile(filePath), result.code, result.language); 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/ts/types/ExtensionCache.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionId } from "./ExtensionId"; 2 | 3 | export type OptionalMetaData = Partial<{ 4 | last_updated: string; 5 | created: string; 6 | author: string; 7 | }>; 8 | 9 | export type ExtensionCacheInfo = { 10 | id: ExtensionId; 11 | url: string; 12 | date: Date; 13 | version: string; 14 | name: string; 15 | extraInfo?: OptionalMetaData; 16 | }; 17 | 18 | export type ExtCacheMap = Map; 19 | -------------------------------------------------------------------------------- /src/ts/types/ExtensionDetails.ts: -------------------------------------------------------------------------------- 1 | export type ExtensionDetails = { 2 | name: string; 3 | version: string; 4 | size: number; 5 | 6 | author?: string; 7 | last_updated?: string; 8 | created?: string; 9 | icon_url?: string; 10 | download_url?: string; 11 | }; 12 | -------------------------------------------------------------------------------- /src/ts/types/ExtensionId.ts: -------------------------------------------------------------------------------- 1 | export type ExtensionId = 2 | | { 3 | id: string; 4 | source: "firefox" | "chrome"; 5 | } 6 | | { 7 | url: string; 8 | source: "url"; 9 | }; 10 | -------------------------------------------------------------------------------- /src/ts/types/Manifest.ts: -------------------------------------------------------------------------------- 1 | export type Manifest = Manifest2; 2 | 3 | type Manifest2 = { 4 | manifest_version: 2; 5 | name: string; 6 | version: string; 7 | author?: string; 8 | default_locale?: string; 9 | 10 | background?: { 11 | page: string; 12 | scripts: string[]; 13 | persistent?: boolean; 14 | }; 15 | 16 | content_scripts?: ContentScript[]; 17 | user_scripts?: { 18 | api_script?: string; 19 | }; 20 | 21 | optional_permissions?: Permission[]; 22 | permissions?: Permission[]; 23 | 24 | commands?: { 25 | [commandName: string]: Command; 26 | }; 27 | 28 | web_accessible_resources?: string[]; 29 | 30 | sidebar_action?: { 31 | default_panel: string; 32 | open_at_install?: boolean; 33 | }; 34 | 35 | browser_action?: BrowserOrPageAction; 36 | page_action?: BrowserOrPageAction; 37 | 38 | icons?: { 39 | [iconSize: string]: string 40 | }; 41 | 42 | devtools_page?: string, 43 | 44 | // TODO 45 | content_security_policy?: string; 46 | protocol_handlers?: any[]; 47 | }; 48 | 49 | type Permission = string; 50 | 51 | type Command = { 52 | suggested_key?: { 53 | default?: string; 54 | mac?: string; 55 | linux?: string; 56 | windows?: string; 57 | chromeos?: string; 58 | android?: string; 59 | ios?: string; 60 | }; 61 | description?: string; 62 | }; 63 | 64 | type ContentScript = { 65 | matches: string[]; 66 | exclude_matches?: string[]; 67 | include_globs?: string[]; 68 | exclude_globs?: string[]; 69 | css?: string[]; 70 | js?: string[]; 71 | all_frames?: boolean; 72 | match_about_blank?: boolean; 73 | run_at?: "document_start" | "document_end" | "document_idle"; 74 | }; 75 | 76 | type BrowserOrPageAction = { 77 | default_popup?: string; 78 | default_title?: string; 79 | browser_style?: boolean; 80 | }; 81 | -------------------------------------------------------------------------------- /src/ts/types/PackagedFiles.ts: -------------------------------------------------------------------------------- 1 | import { TreeNodeDTO } from "../inspector/worker/FileTree"; 2 | 3 | export type TreeFileDTO = TreeNodeDTO & { type: "file" }; 4 | export type TreeFolderDTO = TreeNodeDTO & { type: "folder" }; 5 | 6 | export type FileSelectListener = (path: string, file: TreeFileDTO) => void; 7 | export type FileAsyncAction = (path: string, file: TreeFileDTO) => Promise; 8 | -------------------------------------------------------------------------------- /src/ts/types/Translations.ts: -------------------------------------------------------------------------------- 1 | export type Translations = Record< 2 | string, 3 | { 4 | message: string; 5 | description?: string; 6 | placeholders?: Record< 7 | string, 8 | { 9 | content: string; 10 | example?: string; 11 | } 12 | >; 13 | } 14 | >; 15 | -------------------------------------------------------------------------------- /src/ts/utils/AsyncEvent.ts: -------------------------------------------------------------------------------- 1 | export default class AsyncEvent { 2 | private resolve: () => void; 3 | private promise: Promise; 4 | private fired: boolean = false; 5 | private name: string; 6 | 7 | public constructor(name?: string) { 8 | this.promise = new Promise((resolve) => { 9 | this.resolve = resolve; 10 | }); 11 | this.name = name ?? ""; 12 | } 13 | 14 | public fire(): void { 15 | if (!this.fired) { 16 | this.fired = true; 17 | this.resolve(); 18 | } else { 19 | console.warn(`Event '${this.name}' already fired.`); 20 | } 21 | } 22 | 23 | public get hasFired(): boolean { 24 | return this.fired; 25 | } 26 | 27 | public waitFor(): Promise { 28 | return this.promise; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ts/utils/FunctionTypes.ts: -------------------------------------------------------------------------------- 1 | // export type Consumer = (data: T) => void; 2 | 3 | // export type BiConsumer = (a: T1, b: T2) => void; 4 | 5 | // export type Supplier = () => T; 6 | 7 | // export type Callback = () => void; 8 | 9 | // export type Predicate = (data: T) => boolean; 10 | 11 | // export type Producer = () => T; 12 | 13 | // export type Provider = Producer; 14 | -------------------------------------------------------------------------------- /src/ts/utils/LocalFileProvider.ts: -------------------------------------------------------------------------------- 1 | const files = new Map(); 2 | 3 | export function addFile(file: File, id: string = file.name): string { 4 | files.set(id, URL.createObjectURL(file)); 5 | return file.name; 6 | } 7 | 8 | export function getURL(id: string): string | undefined { 9 | return files.get(id); 10 | } 11 | 12 | export function free(id: string): void { 13 | const url = files.get(id); 14 | if (url) { 15 | URL.revokeObjectURL(url); 16 | } 17 | files.delete(id); 18 | } 19 | -------------------------------------------------------------------------------- /src/ts/utils/download.ts: -------------------------------------------------------------------------------- 1 | export function startDownload(url: string, filename: string): void { 2 | const a = document.createElement("a"); 3 | a.href = url; 4 | a.download = filename; 5 | a.click(); 6 | } 7 | -------------------------------------------------------------------------------- /src/ts/utils/html.ts: -------------------------------------------------------------------------------- 1 | import { Parser } from "htmlparser2"; 2 | 3 | type ScriptInfo = { 4 | src: string; 5 | type: string; 6 | }; 7 | 8 | export function extractScripts(html: string): ScriptInfo[] { 9 | let scripts: ScriptInfo[] = []; 10 | 11 | const parser = new Parser({ 12 | onopentag(name, attributes) { 13 | if (name === "script" && attributes.src) { 14 | scripts.push({ 15 | src: attributes.src, 16 | type: attributes.type ?? "text/javascript", 17 | }); 18 | } 19 | }, 20 | }); 21 | 22 | parser.write(html); 23 | parser.end(); 24 | 25 | return scripts; 26 | } 27 | -------------------------------------------------------------------------------- /src/ts/utils/paths.ts: -------------------------------------------------------------------------------- 1 | export function cleanPath(dirtyPath: string): string { 2 | const fixedSeparators = dirtyPath.replace(/\\/g, "/").replace(/\+/g, "/"); 3 | const fixedStart = fixedSeparators.replace(/^\.\//, ""); 4 | const fixedEnd = fixedStart.replace(/\/$/, ""); 5 | return fixedEnd; 6 | } 7 | 8 | export function getFolder(filePath: string): string { 9 | let parts = cleanPath(filePath).split("/"); 10 | parts.pop(); 11 | return parts.join("/"); 12 | } 13 | 14 | export function getFile(filePath: string): string { 15 | const parts = filePath.split("/"); 16 | return parts.length > 0 ? parts.pop()! : ""; 17 | } 18 | 19 | export function joinPaths(path1: string, path2: string): string { 20 | const parts1 = cleanPath(path1).split("/"); 21 | const parts2 = cleanPath(path2).split("/"); 22 | return parts1 23 | .concat(parts2) 24 | .filter((part) => part !== "") 25 | .join("/"); 26 | } 27 | -------------------------------------------------------------------------------- /src/ts/viewer/viewer.tsx: -------------------------------------------------------------------------------- 1 | import * as Comlink from "comlink"; 2 | import * as Preact from "preact"; 3 | 4 | // create styles (in ) 5 | import "prismjs/themes/prism-okaidia.css"; 6 | import "../../less/file-viewer.less"; 7 | 8 | type Props = { 9 | html: string; 10 | language?: string; 11 | }; 12 | 13 | class FileViewer extends Preact.Component { 14 | public render() { 15 | const html = { __html: this.props.html }; 16 | const codeClasses = []; 17 | 18 | if (this.props.language) { 19 | codeClasses.push("language-" + this.props.language); 20 | } 21 | 22 | return ( 23 | <> 24 |
    25 | 26 |
    27 |
    28 |
    29 |                         
    33 |                     
    34 |
    35 | 36 | ); 37 | } 38 | } 39 | 40 | export class FileViewerAPI { 41 | public async show( 42 | filename: string, 43 | html: string, 44 | language: string 45 | ): Promise { 46 | document.title = filename; 47 | 48 | Preact.render( 49 | , 50 | document.body 51 | ); 52 | } 53 | 54 | public ping(): string { 55 | return "pong"; 56 | } 57 | } 58 | 59 | if (window.opener) { 60 | Comlink.expose(new FileViewerAPI(), Comlink.windowEndpoint(window.opener)); 61 | } else { 62 | Preact.render( 63 |
    Failed to connect with opener window.
    , 64 | document.body 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./public", 4 | "target": "es2020", 5 | "module": "es2020", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "jsx": "react-jsx", 9 | "jsxImportSource": "preact", 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictPropertyInitialization": false, 13 | "allowSyntheticDefaultImports": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = (env, {mode}) => ({ 4 | mode: mode ?? "development", 5 | entry: { 6 | main: path.join(__dirname, "src", "ts", "main.tsx"), 7 | worker: path.join( 8 | __dirname, 9 | "src", 10 | "ts", 11 | "inspector", 12 | "worker", 13 | "worker.ts" 14 | ), 15 | viewer: path.join(__dirname, "src", "ts", "viewer", "viewer.tsx"), 16 | }, 17 | target: "web", 18 | devtool: mode === "development" ? "eval-cheap-module-source-map" : undefined, 19 | resolve: { 20 | extensions: [".ts", ".tsx", ".js"], 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.tsx?$/, 26 | loader: "ts-loader", 27 | options: { 28 | onlyCompileBundledFiles: true, 29 | }, 30 | }, 31 | { 32 | test: /\.(less|css)$/i, 33 | use: [ 34 | { loader: "style-loader" }, // creates style nodes from JS strings 35 | { loader: "css-loader" }, // translates CSS into a JS module (CommonJS) 36 | { loader: "less-loader" }, // compiles Less to CSS 37 | ], 38 | }, 39 | { 40 | test: /\.svg/, 41 | loader: "svg-url-loader", 42 | }, 43 | ], 44 | }, 45 | output: { 46 | filename: "[name].bundle.js", 47 | path: path.resolve(__dirname, "public"), 48 | }, 49 | devServer: { 50 | contentBase: path.join(__dirname, "public"), 51 | }, 52 | }); 53 | --------------------------------------------------------------------------------