├── .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 | Screenshot of Popup 15 | 16 | ### Usage 17 | 18 | ![Screenshot of Usage](ss/screenshot_usage.png) 19 | 20 | ### Integrates with Sidepanel 21 | 22 | ![Screenshot of Sidepanel](ss/screenshot_sidepanel.png) 23 | 24 | ### Edit Saved Config 25 | 26 | Screenshot of Editin 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 | 46 |

Sniper

47 |

Saved Website Configs

48 | 51 | 52 |

New Config

53 |
54 | 55 | 56 | 57 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
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 --------------------------------------------------------------------------------