├── .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 | [](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 |
13 | JavaScript is required to view the content of extension files.
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Extension Inspector
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | This web app does everything client-side and thus requires JavaScript.
17 |
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 |
29 | {props.children}
30 |
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 | {
24 | e.stopPropagation();
25 | this.setState((state) => ({ open: !state.open }));
26 | }}
27 | >
28 | {this.state.open ? (
29 |
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 | {co.label}
68 |
72 | this.setValue((e.target! as HTMLSelectElement).value)
73 | }
74 | >
75 | {co.options.map((o) => (
76 |
77 | {o}
78 |
79 | ))}
80 |
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 |
74 |
79 | download the extension manually
80 |
81 |
82 | upload it on the start page
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 |
46 | ) : null}
47 |
48 |
49 |
50 | Name
51 | {details.name}
52 |
53 | {details.author ? (
54 |
55 | Author
56 | {details.author}
57 |
58 | ) : null}
59 |
60 | Version
61 |
62 | {details.version}
63 | {lastUpdateTime ? (
64 |
68 | {`(${friendlyTime(lastUpdateTime)})`}
69 |
70 | ) : null}
71 |
72 |
73 | {createdTime ? (
74 |
75 | Created
76 | {friendlyTime(createdTime)}
77 |
78 | ) : null}
79 |
80 | Size
81 | {prettyBytes(details.size)}
82 |
83 |
84 |
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 |
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 |
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 | Name
39 | {node.name}
40 |
41 | {node.name !== props.path ? (
42 |
43 | Folder
44 |
45 |
46 | {getFolder(props.path)}
47 |
48 |
49 |
50 | ) : null}
51 |
52 | Size
53 | {prettyBytes(node.size)}
54 |
55 |
56 |
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 | Required
45 |
46 |
47 | {requiredPermissions.map((p) => (
48 |
49 | ))}
50 |
51 |
52 |
53 |
54 | Optional
55 |
56 |
57 | {optionalPermissions.map((p) => (
58 |
59 | ))}
60 |
61 |
62 |
63 |
64 |
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 | {
61 | removeFromCache(e.id);
62 | updateRecentExtensions(await getRecent());
63 | }}
64 | class="remove"
65 | >
66 | remove
67 |
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 |
54 | {this.props.title}
55 |
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 |
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 |
25 | hideModal()}
30 | >
31 |
32 |
33 |
{props.children}
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 | beautify
26 |
27 |
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 |
--------------------------------------------------------------------------------