├── .gitignore
├── LICENSE
├── README.md
├── icons
├── 128.png
├── 16.png
├── 32.png
└── 48.png
├── manifest.json
├── media
└── background.mp3
├── package-lock.json
├── package.json
├── popup
├── popup.css
├── popup.html
└── popup.js
├── scripts
└── content-script.js
├── service-worker.js
└── ss
├── screenshot_edit_config.png
├── screenshot_empty.png
├── screenshot_sidepanel.png
└── screenshot_usage.png
/.gitignore:
--------------------------------------------------------------------------------
1 | # General
2 | .DS_Store
3 | .AppleDouble
4 | .LSOverride
5 |
6 | # Icon must end with two \r
7 | Icon
8 |
9 | # Thumbnails
10 | ._*
11 |
12 | # Files that might appear in the root of a volume
13 | .DocumentRevisions-V100
14 | .fseventsd
15 | .Spotlight-V100
16 | .TemporaryItems
17 | .Trashes
18 | .VolumeIcon.icns
19 | .com.apple.timemachine.donotpresent
20 |
21 | # Directories potentially created on remote AFP share
22 | .AppleDB
23 | .AppleDesktop
24 | Network Trash Folder
25 | Temporary Items
26 | .apdisk
27 |
28 | node_modules/
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Aditya
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 | # Sniper
2 |
3 | A Manifest V3 extension to perform dynamic user specified actions on a web page without `userScripts` permission (And thus w/o need for developer mode).
4 |
5 | ## How
6 |
7 | By using [Computed property names](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#computed_property_names).
8 | The provided selector string is directly passed onto `document.querySelector` and the provided `action` and `arguments` are then applied to each element as `elem[action](...args)` or as values to HTML Element properties as `elem[action] = args`
9 |
10 | ## Screenshots
11 |
12 | ### Popup
13 |
14 |
15 |
16 | ### Usage
17 |
18 | 
19 |
20 | ### Integrates with Sidepanel
21 |
22 | 
23 |
24 | ### Edit Saved Config
25 |
26 |
27 |
28 | ## Acknowledgments
29 |
30 | The avatar style Shapes is based on: Shapes by Florian Körner, licensed under CC0 1.0 . From Dicebear
31 |
32 | Sound Effect from Pixabay
33 |
--------------------------------------------------------------------------------
/icons/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adityarsuryavamshi/Sniper/4604f43b25bef91b9e42d0ed77646733b9e6854b/icons/128.png
--------------------------------------------------------------------------------
/icons/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adityarsuryavamshi/Sniper/4604f43b25bef91b9e42d0ed77646733b9e6854b/icons/16.png
--------------------------------------------------------------------------------
/icons/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adityarsuryavamshi/Sniper/4604f43b25bef91b9e42d0ed77646733b9e6854b/icons/32.png
--------------------------------------------------------------------------------
/icons/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adityarsuryavamshi/Sniper/4604f43b25bef91b9e42d0ed77646733b9e6854b/icons/48.png
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "Sniper",
4 | "version": "0.0.1",
5 | "background": {
6 | "service_worker": "service-worker.js",
7 | "type": "module"
8 | },
9 | "web_accessible_resources": [
10 | {
11 | "resources": [
12 | "media/background.mp3"
13 | ],
14 | "matches": [
15 | ""
16 | ]
17 | }
18 | ],
19 | "icons": {
20 | "16": "icons/16.png",
21 | "32": "icons/32.png",
22 | "48": "icons/48.png",
23 | "128": "icons/128.png"
24 | },
25 | "host_permissions": [
26 | ""
27 | ],
28 | "permissions": [
29 | "storage",
30 | "sidePanel",
31 | "scripting"
32 | ],
33 | "side_panel": {
34 | "default_path": "popup/popup.html"
35 | },
36 | "action": {
37 | "default_popup": "popup/popup.html"
38 | }
39 | }
--------------------------------------------------------------------------------
/media/background.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adityarsuryavamshi/Sniper/4604f43b25bef91b9e42d0ed77646733b9e6854b/media/background.mp3
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sniper",
3 | "version": "1.0.0",
4 | "lockfileVersion": 3,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "sniper",
9 | "version": "1.0.0",
10 | "license": "ISC",
11 | "devDependencies": {
12 | "chrome-types": "^0.1.246"
13 | }
14 | },
15 | "node_modules/chrome-types": {
16 | "version": "0.1.246",
17 | "resolved": "https://registry.npmjs.org/chrome-types/-/chrome-types-0.1.246.tgz",
18 | "integrity": "sha512-nX4M3ISvRnx/Dt3fdfTjLQaCiWwKoVUc+eaEjmURTR+47YJ5E82MQozFOsqV/U4QG+4+hNHdAwj53NtBGOitUw==",
19 | "dev": true
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sniper",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "",
10 | "license": "ISC",
11 | "devDependencies": {
12 | "chrome-types": "^0.1.246"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/popup/popup.css:
--------------------------------------------------------------------------------
1 | body {
2 | width: 20rem;
3 | }
4 |
5 | .website-configs-container,
6 | .wesbite-config ul {
7 | list-style: none;
8 | padding: 0;
9 | }
10 |
11 | .website-configs-container > li {
12 | background-color: #e0e0e0;
13 | }
14 |
15 | .website-config-label {
16 | display: inline-block;
17 | font-weight: bold;
18 | min-width: 7rem;
19 | }
20 |
21 | .website-configs-container {
22 | display: flex;
23 | flex-direction: column;
24 | gap: 1rem;
25 | }
26 |
27 | .wesbite-config {
28 | padding: 0.5rem;
29 | }
30 |
31 | .wesbite-config .config-actions {
32 | padding-top: 0.5rem;
33 | display: flex;
34 | gap: 1rem;
35 | }
36 |
37 | .config-form {
38 | display: flex;
39 | flex-direction: column;
40 | gap: 0.25rem;
41 | }
42 |
43 | .editable {
44 | display: inline-block;
45 | background-color: #fefefe;
46 | border: 1px solid black;
47 | min-width: 7rem;
48 | /* font-size: 1rem; */
49 | }
--------------------------------------------------------------------------------
/popup/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Sniper
9 |
10 |
11 |
12 |
13 |
14 |
15 | -
16 | Config ID:
17 |
18 |
19 | -
20 | Site:
21 |
22 |
23 | -
24 | Reload interval:
25 |
26 |
27 | -
28 | Element Selector:
29 |
30 |
31 | -
32 | Action to perform:
33 |
34 |
35 | -
36 | Action arguments:
37 |
38 |
39 |
40 |
44 |
45 |
46 | Sniper
47 | Saved Website Configs
48 |
51 |
52 | New Config
53 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/popup/popup.js:
--------------------------------------------------------------------------------
1 | const configIDInput = document.querySelector('#config-id');
2 | const siteRegexInput = document.querySelector('#site-regex');
3 | const reloadIntervalInput = document.querySelector('#reload-interval');
4 | const elementSelectorInput = document.querySelector('#element-selector');
5 | const actionToPerformInput = document.querySelector('#element-action');
6 | const actionArgumentsInput = document.querySelector('#element-action-arguments');
7 |
8 |
9 | const websiteConfigTemplateElement = document.querySelector('#saved-site-config');
10 | const websiteConfigContainer = document.querySelector('.website-configs-container');
11 |
12 | function showSiteConfigs(...siteConfigs) {
13 | for (const siteConfig of siteConfigs) {
14 | const websiteConfigElemet = websiteConfigTemplateElement.content.cloneNode(true);
15 | const spanElements = websiteConfigElemet.querySelectorAll('.wesbite-config li span.website-config-value');
16 | spanElements[0].textContent = siteConfig.configID;
17 | spanElements[1].textContent = siteConfig.siteRegex;
18 | spanElements[2].textContent = siteConfig.reloadInterval;
19 | spanElements[3].textContent = siteConfig.elementSelector;
20 | spanElements[4].textContent = siteConfig.actionToPerform;
21 | spanElements[5].textContent = siteConfig.actionArguments;
22 |
23 |
24 | const deleteElement = websiteConfigElemet.querySelector('.delete-config');
25 | deleteElement.addEventListener('click', async (e) => {
26 | e.preventDefault();
27 | await chrome.runtime.sendMessage({ request: 'deleteConfig', payload: siteConfig.configID });
28 | await getAndShowAllConfigs()
29 | })
30 |
31 | const editButton = websiteConfigElemet.querySelector('.edit-config');
32 | let inEditMode = false;
33 | editButton.addEventListener('click', (e) => {
34 | e.preventDefault();
35 | if (!inEditMode) {
36 | for (let i = 1; i < spanElements.length; i++) {
37 | elem = spanElements[i];
38 | elem.setAttribute('contentEditable', true);
39 | elem.classList.toggle('editable')
40 | elem.focus();
41 | }
42 | editButton.textContent = 'Save';
43 | } else {
44 | for (let i = 1; i < spanElements.length; i++) {
45 | elem = spanElements[i];
46 | elem.setAttribute('contentEditable', false);
47 | elem.classList.toggle('editable')
48 | }
49 | editButton.textContent = 'Edit';
50 |
51 | const websiteConfig = {
52 | configID: spanElements[0].textContent,
53 | siteRegex: spanElements[1].textContent,
54 | reloadInterval: parseInt(spanElements[2].textContent),
55 | elementSelector: spanElements[3].textContent,
56 | actionToPerform: spanElements[4].textContent,
57 | actionArguments: spanElements[5].textContent.split(","),
58 | }
59 |
60 |
61 | chrome.runtime.sendMessage({ request: "updateConfig", payload: websiteConfig })
62 | .then(() => getAndShowAllConfigs())
63 | .catch(err => console.error(err))
64 | }
65 | inEditMode = !inEditMode;
66 | })
67 |
68 |
69 |
70 | websiteConfigContainer.appendChild(websiteConfigElemet);
71 | }
72 | }
73 |
74 |
75 |
76 |
77 | async function getAndShowAllConfigs() {
78 | // Clear out the existing contents
79 | while (websiteConfigContainer.hasChildNodes()) {
80 | websiteConfigContainer.firstChild.remove();
81 | }
82 | const storedConfigs = await chrome.runtime.sendMessage({ request: 'getAllConfigs' });
83 | showSiteConfigs(...storedConfigs);
84 | }
85 |
86 |
87 | document.querySelector('#save-config').addEventListener('click', async (e) => {
88 | e.preventDefault();
89 |
90 | const websiteConfig = {
91 | configID: configIDInput.value,
92 | siteRegex: siteRegexInput.value,
93 | reloadInterval: parseInt(reloadIntervalInput.value),
94 | elementSelector: elementSelectorInput.value,
95 | actionToPerform: actionToPerformInput.value,
96 | actionArguments: actionArgumentsInput.value.split(","),
97 | }
98 |
99 |
100 | chrome.runtime.sendMessage({ request: "addNewConfig", payload: websiteConfig })
101 | .then(() => showSiteConfigs(websiteConfig))
102 | .catch(err => console.error(err))
103 |
104 |
105 | // Clear out the form
106 | configIDInput.value = ""
107 | siteRegexInput.value = ""
108 | reloadIntervalInput.value = ""
109 | elementSelectorInput.value = ""
110 | actionToPerformInput.value = ""
111 | actionArgumentsInput.value = ""
112 |
113 | });
114 |
115 |
116 | document.querySelector('#clear-config').addEventListener('click', async () => {
117 | const response = chrome.runtime.sendMessage({ request: 'clearConfig' });
118 | // Getting it again from storage to ensure it's cleared out and to accurately reflect the state
119 | getAndShowAllConfigs();
120 | });
121 |
122 | (async () => {
123 | await getAndShowAllConfigs();
124 | })();
125 |
126 |
--------------------------------------------------------------------------------
/scripts/content-script.js:
--------------------------------------------------------------------------------
1 | const MIN_RELOAD_INTERVAL_IN_SEC = 30
2 |
3 |
4 | const checkAndPerformAction = async (elementSelector, action, arguments = [], reloadAble = false) => {
5 | if (reloadAble) {
6 | // For reload able actions, put a background music to keep the tab active
7 | addBackgroundAudioAndPlay();
8 | }
9 |
10 | if (elementSelector) {
11 | const elems = document.querySelectorAll(elementSelector);
12 | for (const elem of elems) {
13 | if (action) {
14 | applyFunc(elem, action, arguments)
15 | attributeSet(elem, action, ...arguments)
16 | }
17 | }
18 | }
19 |
20 | }
21 |
22 |
23 | const applyFunc = function (elem, func, args = []) {
24 | try {
25 | elem[func](...args)
26 | } catch (error) {
27 | console.error(`Failed to applyFunc Element: ${elem}, Function: ${func}, Arguments: ${args}, Error : ${error}`)
28 | }
29 | }
30 |
31 | const attributeSet = function (elem, property, value) {
32 | try {
33 | elem[property] = value
34 | } catch (error) {
35 | console.error(`Failed to attributeSet Element: ${elem}, Property: ${property}, Value: ${value}, Error: ${error}`)
36 | }
37 | }
38 |
39 |
40 | function addBackgroundAudioAndPlay() {
41 | const backgroundAudio = chrome.runtime.getURL("media/rainfall.mp3");
42 | const audioElem = document.createElement('audio');
43 | audioElem.src = backgroundAudio;
44 | audioElem.loop = true;
45 | document.querySelector('body').appendChild(audioElem);
46 | audioElem.play()
47 | .catch(e => {
48 | console.error(`Failed to Play Audio : ${e}`)
49 | })
50 | return audioElem;
51 | }
52 |
53 |
54 | (async () => {
55 | const websiteConfigs = await chrome.runtime.sendMessage({ request: 'getConfig' });
56 | const reloadIntervals = websiteConfigs
57 | .filter(({ reloadInterval }) => Number.isInteger(reloadInterval))
58 | .map(({ reloadInterval }) => Number.parseInt(reloadInterval));
59 |
60 |
61 | for (const wc of websiteConfigs) {
62 | checkAndPerformAction(wc.elementSelector, wc.actionToPerform, wc.actionArguments, reloadIntervals?.length > 0);
63 | }
64 | if (reloadIntervals?.length > 0) {
65 | // If there are multiple reload intervals then the smallest one is the one we pick, with enforcing 10 seconds to avoid accidental DOS-ing
66 | setInterval(() => {
67 | location.reload();
68 | }, Math.max(MIN_RELOAD_INTERVAL_IN_SEC, Math.min(...reloadIntervals)) * 1000);
69 | }
70 |
71 | })();
--------------------------------------------------------------------------------
/service-worker.js:
--------------------------------------------------------------------------------
1 | const extensionID = "inildfinknkmggbooddbdcnnpliflbbj";
2 | const sniperWebsiteConfigKey = 'sniperWebsiteConfigs';
3 |
4 | chrome.runtime.onInstalled.addListener(({ reason }) => {
5 | if (reason === 'install') {
6 | chrome.storage.local.set({ sniperWebsiteConfigs: [] });
7 | } else {
8 | // It's due to an update or chrome update or something like that
9 | // Just ensure that all the scripts are re-registered
10 | chrome.storage.local.get(sniperWebsiteConfigKey)
11 | .then(({ sniperWebsiteConfigs }) => registerConfigs(...sniperWebsiteConfigs))
12 | }
13 | })
14 |
15 |
16 |
17 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
18 | console.log(JSON.stringify(sender));
19 |
20 | if (new URL(sender.origin).hostname === extensionID) {
21 | // Request from Extension Pages
22 |
23 | switch (message.request) {
24 | case 'getAllConfigs':
25 | // Async Response with onMessage is a bit weird see for example https://stackoverflow.com/questions/44056271/chrome-runtime-onmessage-response-with-async-await
26 | chrome.storage.local.get(sniperWebsiteConfigKey)
27 | .then(({ sniperWebsiteConfigs }) => sendResponse(sniperWebsiteConfigs));
28 | return true;
29 | break;
30 | case 'addNewConfig':
31 | addNewConfig(message.payload)
32 | break;
33 | case 'clearConfig':
34 | chrome.storage.local.set({ sniperWebsiteConfigs: [] });
35 | break;
36 |
37 | case 'updateConfig':
38 | updateConfig(message.payload)
39 | break;
40 | case 'deleteConfig':
41 | clearConfigFor(message.payload);
42 | break;
43 | }
44 | } else {
45 | // Request from Content Scripts and other untrusted sources
46 |
47 | switch (message?.request) {
48 | case 'getConfig':
49 | const requestURL = sender.url;
50 | if (requestURL) {
51 | fetchConfigFor(requestURL).then(sendResponse);
52 | }
53 | return true;
54 | break;
55 | default:
56 | console.warn(`Unkown message received : ${JSON.stringify(message)}, sender: ${JSON.stringify(sender)}`);
57 | break;
58 | }
59 | }
60 |
61 |
62 | });
63 |
64 |
65 |
66 |
67 |
68 | async function clearConfigFor(configId) {
69 | const { sniperWebsiteConfigs } = await chrome.storage.local.get(sniperWebsiteConfigKey);
70 | for (let idx = 0; idx < sniperWebsiteConfigs.length; idx++) {
71 | const websiteConfig = sniperWebsiteConfigs[idx];
72 | if (websiteConfig.configID === configId) {
73 | sniperWebsiteConfigs.splice(idx, 1)
74 | }
75 | }
76 | await chrome.storage.local.set({ sniperWebsiteConfigs });
77 | deregisterConfigs(configId)
78 |
79 | }
80 |
81 | function deregisterConfigs(...configs) {
82 | chrome.scripting.unregisterContentScripts({
83 | ids: configs
84 | })
85 | }
86 |
87 | async function fetchConfigFor(requestURL) {
88 | const { sniperWebsiteConfigs } = await chrome.storage.local.get(sniperWebsiteConfigKey);
89 | const websiteConfigs = []
90 | for (const wc of sniperWebsiteConfigs) {
91 | let [scheme, rest] = wc.siteRegex.split("://")
92 | let [host, path] = rest.split("/")
93 | const pattern = new URLPattern({
94 | protocol: scheme,
95 | hostname: host,
96 | path: path
97 | });
98 | if (pattern.test(requestURL)) {
99 | websiteConfigs.push(wc);
100 | }
101 | }
102 | return websiteConfigs;
103 | }
104 |
105 |
106 | async function updateConfig(updatedWebsiteConfig) {
107 | updateConfigs(updatedWebsiteConfig);
108 | const { sniperWebsiteConfigs } = await chrome.storage.local.get(sniperWebsiteConfigKey);
109 | for (let idx = 0; idx < sniperWebsiteConfigs.length; idx++) {
110 | const websiteConfig = sniperWebsiteConfigs[idx];
111 | if (websiteConfig.configID === updatedWebsiteConfig.configID) {
112 | sniperWebsiteConfigs.splice(idx, 1, updatedWebsiteConfig)
113 | }
114 | }
115 | chrome.storage.local.set({ sniperWebsiteConfigs })
116 | }
117 |
118 | async function addNewConfig(websiteConfig) {
119 | const { sniperWebsiteConfigs } = await chrome.storage.local.get(sniperWebsiteConfigKey);
120 | sniperWebsiteConfigs.push(websiteConfig);
121 | chrome.storage.local.set({ sniperWebsiteConfigs });
122 | registerConfigs(websiteConfig)
123 | }
124 |
125 | function registerConfigs(...configs) {
126 | const registrationSpecs = configs.map(c => {
127 | return {
128 | id: c.configID,
129 | matches: [c.siteRegex],
130 | js: ["scripts/content-script.js"]
131 | }
132 | });
133 | chrome.scripting.registerContentScripts(registrationSpecs);
134 | }
135 |
136 | function updateConfigs(...configs) {
137 | const registrationSpecs = configs.map(c => {
138 | return {
139 | id: c.configID,
140 | matches: [c.siteRegex],
141 | js: ["scripts/content-script.js"]
142 | }
143 | });
144 | chrome.scripting.updateContentScripts(registrationSpecs);
145 | }
--------------------------------------------------------------------------------
/ss/screenshot_edit_config.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adityarsuryavamshi/Sniper/4604f43b25bef91b9e42d0ed77646733b9e6854b/ss/screenshot_edit_config.png
--------------------------------------------------------------------------------
/ss/screenshot_empty.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adityarsuryavamshi/Sniper/4604f43b25bef91b9e42d0ed77646733b9e6854b/ss/screenshot_empty.png
--------------------------------------------------------------------------------
/ss/screenshot_sidepanel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adityarsuryavamshi/Sniper/4604f43b25bef91b9e42d0ed77646733b9e6854b/ss/screenshot_sidepanel.png
--------------------------------------------------------------------------------
/ss/screenshot_usage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adityarsuryavamshi/Sniper/4604f43b25bef91b9e42d0ed77646733b9e6854b/ss/screenshot_usage.png
--------------------------------------------------------------------------------