diff --git a/README.md b/README.md index d97dbd0c..2b0c8f40 100644 --- a/README.md +++ b/README.md @@ -18,20 +18,20 @@ The new rising trend (literally) that changes the browser game completely. Pop-a-loon is a browser extension that adds balloons to every website you visit. The balloons rise up the page and can be popped by clicking on them. See the [installation](#installation) instructions to get started with the extension. -![Screenshot-1](./docs/images/Screenshot-1.png) +![Screenshot-1](./docs/images/Screenshot-1-1280x800.png) > Balloons rise up the page on every website you visit. Click on them to pop them and earn points! -> Pop balloons by clicking on them. The more balloons you pop, the higher your score! - -![Screenshot-2](./docs/images/Screenshot-2.png) - +> > View your score and the number of balloons popped in the extension popup. -![Screenshot-3](./docs/images/Screenshot-3.png) +![Screenshot-2](./docs/images/Screenshot-2-1280x800.png) -> See your position in the leaderboard and compete with others to get the highest score! > Pop as many balloons as you can to earn points and climb the leaderboard! -> _Leaderboard from 27/04/2024_ +> _Leaderboard from 16/05/2024_ + +![Screenshot-3](./docs/images/Screenshot-3-1280x800.png) + +> Customize your experience in the extension settings. ## Table of Contents diff --git a/manifest.chrome.json b/manifest.chrome.json index 2246e3de..0967ef42 100644 --- a/manifest.chrome.json +++ b/manifest.chrome.json @@ -1,13 +1 @@ -{ - "action": { - "default_icon": { - "16": "./resources/icons/icon-16.png", - "24": "./resources/icons/icon-24.png", - "32": "./resources/icons/icon-32.png", - "48": "./resources/icons/icon-48.png", - "128": "./resources/icons/icon-128.png" - }, - "default_title": "Pop-a-loon", - "default_popup": "./popup.html" - } -} +{} diff --git a/manifest.firefox.json b/manifest.firefox.json index 4c1b73a0..9471958b 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -1,27 +1,10 @@ { - "manifest_version": 2, - "browser_action": { - "default_icon": { - "16": "./resources/icons/icon-16.png", - "24": "./resources/icons/icon-24.png", - "32": "./resources/icons/icon-32.png", - "48": "./resources/icons/icon-48.png", - "128": "./resources/icons/icon-128.png" - }, - "default_title": "Pop-a-loon", - "default_popup": "./popup.html" - }, "background": { - "scripts": [ - "./background.js" - ] + "scripts": ["./background.js"] }, "browser_specific_settings": { "gecko": { "id": "pop-a-loon@pop-a-loon" } - }, - "web_accessible_resources": [ - "resources/*" - ] -} \ No newline at end of file + } +} diff --git a/manifest.json b/manifest.json index 9018c1ec..b6bfb569 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,8 @@ "manifest_version": 3, "name": "Pop-a-loon", "description": "The new rising trend (literally) that changes the browser game completely.", - "permissions": ["storage", "alarms", "idle"], + "permissions": ["storage", "alarms", "idle", "scripting"], + "host_permissions": ["https://*/*", "http://*/*"], "icons": { "16": "./resources/icons/icon-16.png", "24": "./resources/icons/icon-24.png", @@ -10,12 +11,17 @@ "48": "./resources/icons/icon-48.png", "128": "./resources/icons/icon-128.png" }, - "content_scripts": [ - { - "matches": ["https://*/*", "http://*/*"], - "js": ["./content.js"] - } - ], + "action": { + "default_icon": { + "16": "./resources/icons/icon-16.png", + "24": "./resources/icons/icon-24.png", + "32": "./resources/icons/icon-32.png", + "48": "./resources/icons/icon-48.png", + "128": "./resources/icons/icon-128.png" + }, + "default_title": "Pop-a-loon", + "default_popup": "./popup.html" + }, "web_accessible_resources": [ { "matches": ["https://*/*", "http://*/*"], diff --git a/package-lock.json b/package-lock.json index 9e1c287d..99880509 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pop-a-loon", - "version": "1.9.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pop-a-loon", - "version": "1.9.0", + "version": "1.10.0", "license": "Apache-2.0", "dependencies": { "@hookform/resolvers": "^3.3.4", @@ -48,6 +48,7 @@ "jest-environment-jsdom": "^29.7.0", "jest-fetch-mock": "^3.0.3", "lint-staged": "^15.2.1", + "mini-css-extract-plugin": "^2.9.0", "mkdirp": "^3.0.1", "postcss": "^8.4.33", "prettier": "^3.2.5", @@ -6838,6 +6839,79 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mini-css-extract-plugin": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.0.tgz", + "integrity": "sha512-Zs1YsZVfemekSZG+44vBsYTLQORkPMwnlv+aehcxK/NLKC+EGhDB39/YePYYqx/sTk6NnYpuqikhSn7+JIevTA==", + "dev": true, + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", + "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.4.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", diff --git a/package.json b/package.json index 7eb81f42..a81afc72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pop-a-loon", - "version": "1.9.0", + "version": "1.10.0", "description": "The new rising trend (literally) that changes the browser game completely.", "private": true, "scripts": { @@ -72,6 +72,7 @@ "jest-environment-jsdom": "^29.7.0", "jest-fetch-mock": "^3.0.3", "lint-staged": "^15.2.1", + "mini-css-extract-plugin": "^2.9.0", "mkdirp": "^3.0.1", "postcss": "^8.4.33", "prettier": "^3.2.5", diff --git a/src/background/background.ts b/src/background/background.ts index fb9417ee..c95c5130 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -114,15 +114,15 @@ const updateBadgeColors = () => { const alarms = await browser.alarms.getAll(); if (alarms.some((alarm) => alarm.name === 'spawnBalloon')) return skipSpawnMessage('Spawn alarm already set'); - console.log(`Sending spawnBalloon message`); - - // Send the spawnBalloon message - const response = await browser.tabs - .sendMessage(tab.id, { action: 'spawnBalloon' }) - .catch((e) => {}); - if (browser.runtime.lastError) { - browser.runtime.lastError; - } + console.log(`Spawning balloon on tab`, tab.id); + + try { + // Execute content script on tab + await browser.scripting.executeScript({ + files: ['spawn-balloon.js'], + target: { tabId: tab.id }, + }); + } catch (e) {} lastSpawn = now; }; diff --git a/src/balloon.ts b/src/balloon.ts index b1a57867..d14b61a0 100644 --- a/src/balloon.ts +++ b/src/balloon.ts @@ -1,9 +1,6 @@ import browser from 'webextension-polyfill'; import storage from '@/storage'; -import { random, sendMessage } from '@utils'; - -export const balloonContainer = document.createElement('div'); -balloonContainer.id = 'balloon-container'; +import { getBalloonContainer, random, sendMessage } from '@utils'; export const balloonResourceLocation = browser.runtime.getURL( 'resources/balloons/' @@ -18,22 +15,36 @@ const buildBalloonElement = ( size: number; positionX: number; riseDuration: number; + waveDuration: number; onAnimationend: () => void; } ) => { - element.classList.add('balloon'); + const balloon = document.createElement('div'); + balloon.classList.add('balloon'); // Set the balloon's width and height - element.style.width = props.size + 'px'; - element.style.height = element.style.width; - element.style.left = `calc(${props.positionX.toString() + 'vw'} - ${props.size / 2}px)`; - element.style.animationDuration = props.riseDuration.toString() + 'ms'; - element.style.animationTimingFunction = 'linear'; - element.style.animationFillMode = 'forwards'; - element.style.animationName = 'rise'; - element.addEventListener('animationend', props.onAnimationend); - - return element; + balloon.style.width = props.size + 'px'; + balloon.style.height = balloon.style.width; + balloon.style.left = `calc(${props.positionX.toString() + 'vw'} - ${props.size / 2}px)`; + balloon.style.animationDuration = props.riseDuration.toString() + 'ms'; + balloon.style.animationTimingFunction = 'linear'; + balloon.style.animationFillMode = 'forwards'; + balloon.style.animationName = 'rise'; + balloon.addEventListener('animationend', props.onAnimationend); + + // Create a second div and apply the swing animation to it + const swingElement = document.createElement('div'); + swingElement.style.animation = `swing ${props.waveDuration}s infinite ease-in-out`; + const waveElement = document.createElement('div'); + waveElement.style.animation = `wave ${props.waveDuration / 2}s infinite ease-in-out alternate`; + // Start wave animation at -3/4 of the swing animation (makes sure the wave has started before the balloon comes on screen) + waveElement.style.animationDelay = `-${(props.waveDuration * 3) / 4}s`; + + balloon.appendChild(swingElement); + swingElement.appendChild(waveElement); + waveElement.appendChild(element); + + return balloon; }; export default abstract class Balloon { @@ -46,6 +57,7 @@ export default abstract class Balloon { public readonly element: HTMLDivElement; public readonly riseDurationThreshold: [number, number] = [10000, 15000]; + public readonly swingDurationThreshold: [number, number] = [2, 4]; public get popSound(): HTMLAudioElement { if (!this._popSound.src) { @@ -62,6 +74,20 @@ export default abstract class Balloon { return balloonResourceLocation + this.name + '/pop.mp3'; } + public get topElement(): HTMLDivElement { + let element = this.element; + while (!element.classList.contains('balloon')) { + if ( + !element.parentElement || + !(element.parentElement instanceof HTMLDivElement) || + element.parentElement === getBalloonContainer() + ) + return element; + element = element.parentElement; + } + return element; + } + constructor() { // Create the balloon element this.element = document.createElement('div'); @@ -81,28 +107,30 @@ export default abstract class Balloon { return this.element.style.animationName === 'rise'; } - public getRandomDuration( - duration: [number, number] = this.riseDurationThreshold - ): number { - return random(duration[0], duration[1]); - } - public rise(): void { // Load the balloon image this.balloonImage.src = this.balloonImageUrl; // Build the balloon element - buildBalloonElement(this.element, { + const balloonElement = buildBalloonElement(this.element, { size: random(50, 75), positionX: random(5, 95), - riseDuration: this.getRandomDuration(), + riseDuration: random( + this.riseDurationThreshold[0], + this.riseDurationThreshold[1] + ), + waveDuration: random( + this.swingDurationThreshold[0], + this.swingDurationThreshold[1] + ), onAnimationend: this.remove.bind(this), }); // Add the balloon to the container - balloonContainer.appendChild(this.element); + getBalloonContainer().appendChild(balloonElement); } public remove(): void { - this.element.remove(); + // loop until the parent node has 'balloon' class + this.topElement.remove(); this.element.style.animationName = 'none'; } diff --git a/src/balloons/confetti.ts b/src/balloons/confetti.ts index 3d7eab63..c5cf1538 100644 --- a/src/balloons/confetti.ts +++ b/src/balloons/confetti.ts @@ -1,6 +1,5 @@ import Balloon, { balloonResourceLocation } from '@/balloon'; -import { random } from '@/utils'; -import '@/../resources/balloons/confetti/confetti.css'; +import { importStylesheet, random } from '@/utils'; export default class Confetti extends Balloon { public readonly name = 'confetti'; @@ -10,6 +9,10 @@ export default class Confetti extends Balloon { constructor() { super(); + importStylesheet( + 'confetti-styles', + balloonResourceLocation + 'confetti/confetti.css' + ); this.element.appendChild(this.mask); this.mask.src = balloonResourceLocation + this.name + '/mask.png'; diff --git a/src/const.ts b/src/const.ts index 5cc63d15..189535ae 100644 --- a/src/const.ts +++ b/src/const.ts @@ -167,10 +167,13 @@ export const devRemoteResponse: Record = new Proxy( }, } ); + // // * Other // +export const BalloonContainerId = 'balloon-container'; + export type hexColor = string; export type Prettify = { diff --git a/src/content/content.ts b/src/content/content.ts deleted file mode 100644 index cf83e988..00000000 --- a/src/content/content.ts +++ /dev/null @@ -1,42 +0,0 @@ -import browser from 'webextension-polyfill'; -import { Message } from '@const'; -import { balloonContainer } from '@/balloon'; -import { weightedRandom } from '@/utils'; -import * as balloons from '@/balloons'; -import './style.css'; - -(() => { - if ( - // Prevent running in popup - document.body.id === 'pop-a-loon' || - // Prevent multiple script loads - document.body.contains(balloonContainer) - ) { - return; - } - - browser.runtime.onMessage.addListener( - async (message: Message, sender, sendResponse) => { - // Always call sendResponse, this is required - sendResponse(); - // If the message is not spawnBalloon, ignore it - if (message.action !== 'spawnBalloon') return; - - const balloonClasses = Object.values(balloons); - // Make a list from the spawn_chance from each balloon class - const spawnChances = balloonClasses.map( - (BalloonType) => BalloonType.spawn_chance - ); - - // Create a new balloon and make it rise - const Balloon = weightedRandom(balloonClasses, spawnChances, { - default: balloons.Default, - }); - - const balloon = new Balloon(); - balloon.rise(); - } - ); - - document.body.appendChild(balloonContainer); -})(); diff --git a/src/content/spawn-balloon.ts b/src/content/spawn-balloon.ts new file mode 100644 index 00000000..c44f79e5 --- /dev/null +++ b/src/content/spawn-balloon.ts @@ -0,0 +1,30 @@ +import browser from 'webextension-polyfill'; +import * as balloons from '@/balloons'; +import { getBalloonContainer, importStylesheet, weightedRandom } from '@/utils'; + +(() => { + // Prevent running in popup + if (document.body.id === 'pop-a-loon') return; + + importStylesheet( + 'balloon-styles', + browser.runtime.getURL('resources/stylesheets/style.css') + ); + + // Add the balloon container to the document + const _ = getBalloonContainer(); + + const balloonClasses = Object.values(balloons); + // Make a list from the spawn_chance from each balloon class + const spawnChances = balloonClasses.map( + (BalloonType) => BalloonType.spawn_chance + ); + + // Create a new balloon and make it rise + const Balloon = weightedRandom(balloonClasses, spawnChances, { + default: balloons.Default, + }); + + const balloon = new Balloon(); + balloon.rise(); +})(); diff --git a/src/content/style.css b/src/content/style.css index ce66e16a..711a799b 100644 --- a/src/content/style.css +++ b/src/content/style.css @@ -42,3 +42,24 @@ transform: translateY(-10vh); } } + +@keyframes swing { + 0% { + transform: translateX(15px); + } + 50% { + transform: translateX(-15px); + } + 100% { + transform: translateX(15px); + } +} + +@keyframes wave { + 0% { + transform: rotate(-8deg); + } + 100% { + transform: rotate(8deg); + } +} \ No newline at end of file diff --git a/src/popup/components/Header.tsx b/src/popup/components/Header.tsx index 879f2e1f..6055909b 100644 --- a/src/popup/components/Header.tsx +++ b/src/popup/components/Header.tsx @@ -1,10 +1,12 @@ import React, { useState, useEffect } from 'react'; +import browser from 'webextension-polyfill'; import { useLocation } from 'react-router-dom'; import { ClassValue } from 'clsx'; -import { LucideIcon } from 'lucide-react'; +import { LucideIcon, RotateCcw } from 'lucide-react'; import { Link } from 'react-router-dom'; import { ArrowLeft, List, Settings } from 'lucide-react'; import remote from '@/remote'; +import { Button } from './ui/button'; type iconProps = { to: string; @@ -17,6 +19,10 @@ type HeaderProps = { className?: ClassValue[]; }; +type BannerProps = { + remoteAvailable: boolean; +}; + const routeTitles: { [key: string]: string } = { '/settings': 'Settings', }; @@ -32,6 +38,42 @@ const HeaderIcon = (props: iconProps) => { ); }; +const Banner = (props: BannerProps) => { + if (!props.remoteAvailable) + return ( +
+ Remote not available +
+ ); + + const [alarms, setAlarms] = useState([]); + + useEffect(() => { + const fetchAlarms = async () => { + const alarms = await browser.alarms.getAll(); + setAlarms(alarms); + }; + fetchAlarms(); + }, []); + + if (alarms.length === 0) + return ( +
+ Something went wrong, please restart the extension. + +
+ ); + + return null; +}; + export default (props: HeaderProps) => { const [isAvailable, setIsAvailable] = useState(true); const location = useLocation(); @@ -52,7 +94,7 @@ export default (props: HeaderProps) => { return ( <> -
+
{location.pathname !== '/' && }
@@ -63,11 +105,7 @@ export default (props: HeaderProps) => { ))}
- {!isAvailable && ( -
- Remote not available -
- )} + ); }; diff --git a/src/popup/components/forms/LocalSettings.tsx b/src/popup/components/forms/LocalSettings.tsx index 817ce05b..362dd6e6 100644 --- a/src/popup/components/forms/LocalSettings.tsx +++ b/src/popup/components/forms/LocalSettings.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useState } from 'react'; +import browser, { manifest, type Permissions } from 'webextension-polyfill'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { Button } from '@components/ui/button'; import { Form, FormField, @@ -24,11 +26,18 @@ const SPAWN_RATE_STEP = 0.1; const formSchema = z.object({ popVolume: z.number().int().min(MIN_POP_VOLUME).max(MAX_POP_VOLUME), spawnRate: z.number().int().min(MIN_SPAWN_RATE).max(MAX_SPAWN_RATE), + permissions: z.object({ + origins: z.array(z.string()), + permissions: z.array(z.string()), + }), }); export default () => { const [popVolume, setPopVolume] = useState(0); const [spawnRate, setSpawnRate] = useState(0); + const [permissions, setPermissions] = useState( + {} + ); const form = useForm>({}); const popSound = new DefaultBalloon().popSound; @@ -66,12 +75,25 @@ export default () => { ); }; + const onGrantOriginPermissionClick = async () => { + const host_permissions = + await browser.runtime.getManifest().host_permissions; + if (!host_permissions) return console.error('No host_permissions found'); + const permissions = await browser.permissions.request({ + origins: host_permissions, + }); + console.log('Permissions granted for', permissions); + + setPermissions(await browser.permissions.getAll()); + }; + useEffect(() => { const loadVolume = async () => { const config = await storage.get('config'); // Load volume from storage setPopVolume(config.popVolume); setSpawnRate(config.spawnRate); + setPermissions(await browser.permissions.getAll()); }; loadVolume(); @@ -151,6 +173,41 @@ export default () => { )} /> + {/* If the user hasn't granted the host permissions; show the grant permission button */} + {!(permissions.origins?.length !== 0) && ( + ( + + + Host Permission + +

+ Host Permission{' '} + *recommended +

+

+ Pop-a-loon requires host permissions to function properly. +

+
+
+ + + + + + +
+ )} + /> + )} ); diff --git a/src/utils.ts b/src/utils.ts index fd422f79..a6773e95 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import browser from 'webextension-polyfill'; -import { Message } from '@const'; +import { Message, BalloonContainerId } from '@const'; import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; import storage from '@/storage'; @@ -100,3 +100,32 @@ export async function calculateBalloonSpawnDelay() { return randomDelay / spawnRateMultiplier; } + +function createBalloonContainer() { + const balloonContainer = document.createElement('div'); + balloonContainer.id = BalloonContainerId; + document.body.appendChild(balloonContainer); + return balloonContainer; +} +export function getBalloonContainer() { + return ( + document.getElementById(BalloonContainerId) ?? createBalloonContainer() + ); +} + +export async function importStylesheet(id: string, href: string) { + id = `pop-a-loon-${id}`; + if (!document.getElementById(id)) { + // Fetch the CSS file content + const response = await fetch(href); + const css = await response.text(); + + // Create a