diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 349d978..380ea99 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -30,6 +30,5 @@ jobs: - name: Archive production artifacts uses: actions/upload-artifact@v3 with: - name: tf2wikipricing.user.js - path: | - dist/userscript/tf2wikipricing.user.js \ No newline at end of file + name: tf2wikipricing + path: dist/ \ No newline at end of file diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index c071d53..a6cfd0b 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -26,9 +26,8 @@ jobs: - name: Archive production artifacts uses: actions/upload-artifact@v3 with: - name: tf2wikipricing.user.js - path: | - dist/userscript/tf2wikipricing.user.js + name: tf2wikipricing + path: dist/ deploy: runs-on: debian-latest needs: build @@ -36,8 +35,14 @@ jobs: - name: Download release artifacts uses: actions/download-artifact@v3 with: - name: tf2wikipricing.user.js - path: userscript + name: tf2wikipricing + path: dist/ + - name: Package Chrome extension + run: | + cd dist/ + echo "${{ secrets.CRX_PRIVATE_KEY }}" > private.pem + bun x crx pack -p private.pem -o tf2wikipricing.crx extension/ + rm -f private.pem - name: Create release id: use-go-action uses: akkuman/gitea-release-action@v1 @@ -45,5 +50,6 @@ jobs: title: "v${{ need.build.outputs.version }}" name: "v${{ need.build.outputs.version }}" files: | - userscript/** + dist/tf2wikipricing.crx + dist/tf2wikipricing.user.js sha256sum: true \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index c24e42b..a4c615f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index a3acaa6..9e9db52 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,14 @@ "dependencies": { "@types/chrome": "^0.0.270", "base64-inline-loader": "^2.0.1", + "crx": "^5.0.1", "css-loader": "^7.1.2", "css-to-string-loader": "^0.1.3", "extract-loader": "^5.1.0", "jsonc-loader": "^0.1.1", + "mini-css-extract-plugin": "^2.9.2", + "postcss-loader": "^8.1.1", + "postcss-url": "^10.1.3", "style-loader": "^4.0.0", "to-string-loader": "^1.2.0", "url-loader": "^4.1.1" diff --git a/src/background/background.ts b/src/background/background.ts new file mode 100644 index 0000000..4eb32b1 --- /dev/null +++ b/src/background/background.ts @@ -0,0 +1,111 @@ +chrome.runtime.onMessage.addListener( + function(request, sender, sendResponse) { + if (request.contentScriptQuery == "queryExchangeRates") { + const url = "https://open.er-api.com/v6/latest/USD"; + fetch(url) + .then(response => { + console.log(response) + return response.json() + }) + .then(json => { + sendResponse(json) + }) + .catch(error => { + console.error("Failed to get exchange rates", error); + }) + return true; + } +}) + +chrome.runtime.onMessage.addListener( + function(request, sender, sendResponse) { + if (request.contentScriptQuery == "querySchema") { + const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json"; + fetch(url) + .then(response => response.json()) + .then(json => sendResponse(json)) + .catch(error => { + console.error("Failed to get schema", error); + }) + return true; + } + } +); + +chrome.runtime.onMessage.addListener( + function(request, sender, sendResponse) { + if (request.contentScriptQuery == "getPricesTFToken") { + fetch('https://api2.prices.tf/auth/access', { + method: 'post', + headers: new Headers({ + 'Accept': 'application/json' + }) + }) + .then(response => response.json()) + .then(json => sendResponse(json['accessToken'])) + .catch(error => { + console.error("Failed to get access token", error); + }) + return true; + } + } +) + +class PricesResponse { + keys: number + metal: number +} + +async function priceUsingPricesTF(token: string, sku: string, retries: number = 3): Promise { + const url = `https://api2.prices.tf/prices/${encodeURIComponent(sku)}`; + const response = await fetch(url, { + method: 'get', + headers: { + 'Accept': 'application/json', + 'Authorization': `Bearer ${token}`, + } + }) + if (response.status === 404 && sku.includes(';') && !sku.includes(';uncraftable')) { + const quality: number = parseInt(sku.split(';')[1], 10); + if(quality === 6) { + // Try uncraftable variant if unique weapon + return priceUsingPricesTF(token, sku + ';uncraftable'); + } + } + if(response.status === 503) { + // Happens if we send too many requests in a short period of time + // Retry after a few seconds + if(retries >= 0) { + console.log(`Cloudflare rate limit exceeded, trying again after 1 second, ${retries} retries left`) + await new Promise(resolve => setTimeout(resolve, 1000)); + return priceUsingPricesTF(token, sku, retries - 1); + } else { + throw new Error(`Cloudflare rate limit exceeded, stopping`) + } + } + const data = await response.json(); + const prices = new PricesResponse(); + prices.keys = data['sellKeys'] + prices.metal = data['sellHalfScrap'] / 18.0; + return prices; +} + +chrome.runtime.onMessage.addListener( + function(request, sender, sendResponse) { + if (request.contentScriptQuery == "priceSKU") { + const sku: string = request.sku + const service: string = request.service + const token: string = request.token + switch (service) { + case "prices.tf": { + priceUsingPricesTF(token, sku).then((response) => { + sendResponse(JSON.stringify(response)); + }) + return true; + } + default: + return false; + } + } + } +); \ No newline at end of file diff --git a/src/content/content.ts b/src/content/content.ts index f486c98..955f2ea 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -1,4 +1,5 @@ -import styleCss from './style.css' +declare const __ENV_WEBEXTENSION: boolean; +declare const __ENV_USERSCRIPT: boolean; import { logDebug, log, logError } from './utils/log' import { getPricesToken } from './pricing/pricestf' @@ -417,7 +418,8 @@ async function inject() { } function addStyles() { - const head = document.head || document.getElementsByTagName('head')[0], + if(__ENV_USERSCRIPT) { + const head = document.head || document.getElementsByTagName('head')[0], style = document.createElement('style'); head.appendChild(style); style.innerHTML = styleCss; diff --git a/src/content/exchangeRateService.ts b/src/content/exchangeRateService.ts index 5b41de7..ba6a645 100644 --- a/src/content/exchangeRateService.ts +++ b/src/content/exchangeRateService.ts @@ -1,8 +1,9 @@ import { getStorageValue, setStorageValue } from './storage' import { logDebug, log, logError } from './utils/log' import { storage_exchangerates, storage_exchangerates_next, storage_exchangerates_update } from './config' -declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise -import './GM_fetch' +import { fetchWrap } from './fetchWrap'; +declare const __ENV_WEBEXTENSION: boolean; +declare const __ENV_USERSCRIPT: boolean; export interface ExchangeRates { [key: string]: number; @@ -26,7 +27,7 @@ export async function prepareExchangeRates(): Promise { const lastUpdateTime = new Date(update); const nextUpdateTime = new Date(nextUpdate); log(`Exchange rates updated at ${lastUpdateTime}`); - if (rates == null || Object.keys(rates).length === 0 || lastUpdateTime.getTime() > nextUpdateTime.getTime()) { + if (rates == null || Object.keys(rates).length === 0 || Date.now() > nextUpdateTime.getTime()) { needsUpdate = true } } else { @@ -35,19 +36,35 @@ export async function prepareExchangeRates(): Promise { if(needsUpdate) { log("Exchange rates out of Date. Rebuilding..."); - const url = "https://open.er-api.com/v6/latest/USD" - const response = await GM_fetch(url); - if (response.ok) { - await setStorageValue(storage_exchangerates_update, new Date().toISOString()) - const json = await response.json() - if(json != null){ - rates = json['rates'] - await setStorageValue(storage_exchangerates, rates) - await setStorageValue(storage_exchangerates_next, json['time_next_update_utc']) + let exchangeResponse: { + rates: ExchangeRates, + time_next_update_utc: string + } + if(__ENV_USERSCRIPT) { + const url = "https://open.er-api.com/v6/latest/USD" + try { + const response: Response = await fetchWrap(url) + if(response.ok) { + exchangeResponse = await response.json() + } + } catch (e) { + logDebug(e); + throw e; } - logDebug(`Exchange rates updated at ${new Date()}`) } else { - logError(`Failed to fetch exchange rates. Status code: ${response.status}`, response) + exchangeResponse = await chrome.runtime.sendMessage({contentScriptQuery: "queryExchangeRates"}) + } + try { + if(exchangeResponse == null) { + throw new Error("Rates are null") + } + rates = exchangeResponse.rates + await setStorageValue(storage_exchangerates_update, new Date().toISOString()) + await setStorageValue(storage_exchangerates, exchangeResponse.rates) + await setStorageValue(storage_exchangerates_next, exchangeResponse.time_next_update_utc) + logDebug(`Exchange rates updated at ${new Date()}`) + } catch(e) { + logError(`Failed to store exchange rates.`, e) } } diff --git a/src/content/fetchWrap.ts b/src/content/fetchWrap.ts new file mode 100644 index 0000000..49a30ce --- /dev/null +++ b/src/content/fetchWrap.ts @@ -0,0 +1,10 @@ +declare let __ENV_USERSCRIPT: boolean; +declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise + +export function fetchWrap(input: string | URL | globalThis.Request, init?: RequestInit): Promise { + if(__ENV_USERSCRIPT) { + return GM_fetch(input, init) + } else { + return fetch(input, init) + } +} diff --git a/src/content/priceService.ts b/src/content/priceService.ts index efa0a1a..4036c8c 100644 --- a/src/content/priceService.ts +++ b/src/content/priceService.ts @@ -2,6 +2,8 @@ import { defindex_key, storage_priceprefix } from "./config" import { priceUsingPricesTF } from "./pricing/pricestf" import { getStorageValue, setStorageValue } from "./storage" import { logDebug } from "./utils/log" +declare const __ENV_WEBEXTENSION: boolean; +declare const __ENV_USERSCRIPT: boolean; /** Pricing data for a given TF2 item. */ export class ItemPriceData { @@ -58,7 +60,12 @@ export async function fetchPrice(token: string, sku: string, update: Date = new data.ttl = ttl try { - const response = await priceUsingPricesTF(token, sku) + let response: PricesResponse + if(__ENV_USERSCRIPT) { + response = await priceUsingPricesTF(token, sku) + } else { + response = JSON.parse(await chrome.runtime.sendMessage({contentScriptQuery: "priceSKU", service: "prices.tf", sku: sku, token: token})); + } if (response) { data.keys = response.keys data.metal = response.metal diff --git a/src/content/pricing/pricestf.ts b/src/content/pricing/pricestf.ts index f125ec3..abeff72 100644 --- a/src/content/pricing/pricestf.ts +++ b/src/content/pricing/pricestf.ts @@ -1,10 +1,12 @@ -declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise -import '../GM_fetch' +import { fetchWrap } from '../fetchWrap' import { logDebug, logError } from '../utils/log' +declare const __ENV_WEBEXTENSION: boolean; +declare const __ENV_USERSCRIPT: boolean; async function getPricesToken(): Promise { + if(__ENV_USERSCRIPT) { return new Promise((resolve, reject) => { - GM_fetch('https://api2.prices.tf/auth/access', { + fetchWrap('https://api2.prices.tf/auth/access', { method: 'post', headers: new Headers({ 'Accept': 'application/json' @@ -18,6 +20,9 @@ async function getPricesToken(): Promise { }) .then((responseData) => resolve(responseData['accessToken'])) }) + } else { + return chrome.runtime.sendMessage({contentScriptQuery: 'getPricesTFToken'}) + } } class PricesResponse { @@ -43,7 +48,7 @@ async function priceUsingPricesTF(token: string, sku: string, retries: number = // https://api2.prices.tf/prices/${sku} // Authorization: Bearer ${token} try { - const response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku)}`, { + const response = await fetchWrap(`https://api2.prices.tf/prices/${encodeURIComponent(sku)}`, { method: 'get', headers: { 'Accept': 'application/json', diff --git a/src/content/schemaService.ts b/src/content/schemaService.ts index 47b00d4..1709ff1 100644 --- a/src/content/schemaService.ts +++ b/src/content/schemaService.ts @@ -1,10 +1,11 @@ +declare const __ENV_WEBEXTENSION: boolean; +declare const __ENV_USERSCRIPT: boolean; import { getStorageValue, setStorageValue } from './storage' import { logDebug, log, logError } from './utils/log' import './config' -declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise -import './GM_fetch' import { storage_version, storage_schema, storage_lastUpdateTime } from './config' import Australiums from '../resources/australiums.json' +import { fetchWrap } from './fetchWrap' const semver = require('semver') export function checkAustraliumVariant(defindex: number): boolean { @@ -162,7 +163,7 @@ export async function prepareSchema(): Promise { const storedVersion: string | null = await getStorageValue(storage_version, null) if(!storedVersion || !semver.valid(storedVersion)) { - log(`Cache is from an unknown version of the extension. Updating for version ${__VERSION__}`); + log(`Preparing the extension for the first time.`); needsUpdate = true } else if(semver.valid(storedVersion) && semver.lt(storedVersion, __VERSION__)) { log(`Cache is from a previous version (${storedVersion}) of the extension. Updating for version ${__VERSION__}`); @@ -183,15 +184,22 @@ export async function prepareSchema(): Promise { if(needsUpdate) { log("Item Schema out of Date. Rebuilding..."); - const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json" - const response = await GM_fetch(url); - if (response.ok) { + try { await setStorageValue(storage_lastUpdateTime, new Date().getTime()); // eslint-disable-next-line @typescript-eslint/no-explicit-any const cacheItems: any = {} - const responseItems: SchemaResponseItem[] = await response.json() + let responseItems: SchemaResponseItem[]; + if(__ENV_USERSCRIPT) { + const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json" + const response = await fetchWrap(url); + if(response.ok) { + responseItems = await response.json(); + } + } else { + responseItems = await chrome.runtime.sendMessage({contentScriptQuery: "querySchema"}) + } // We want to keep the keys `defindex`, `item_name`, and `attributes` responseItems.forEach((item: SchemaResponseItem) => { const defindex: number = item['defindex'] @@ -234,8 +242,8 @@ export async function prepareSchema(): Promise { itemSchema = cacheItems await setStorageValue(storage_version, __VERSION__); logDebug(`Item schema updated at ${new Date()}`) - } else { - logError("Could not fetch item schema."); + } catch (e) { + logError("Could not fetch item schema.", e); } } return itemSchema diff --git a/src/content/storage.ts b/src/content/storage.ts index 756df98..6b1d2b7 100644 --- a/src/content/storage.ts +++ b/src/content/storage.ts @@ -6,7 +6,8 @@ function getStorageValue(name: string, defaultValue: string): Promise { if(__ENV_USERSCRIPT) { return GM.getValue(name, defaultValue); } else if(__ENV_WEBEXTENSION) { - return browser.storage.local.get(name); + return chrome.storage.local.get(name) + .then((result) => result[name]) } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any return new Promise((resolve) => { @@ -19,7 +20,7 @@ function setStorageValue(name: string, value: unknown): Promise { if(__ENV_USERSCRIPT) { return GM.setValue(name, value as GM.Value); } else if(__ENV_WEBEXTENSION) { - return browser.storage.local.set({name, value}); + return chrome.storage.local.set({[name]: value}); } else { return new Promise((_, reject) => { reject(); diff --git a/src/manifest.json b/src/manifest.json index 09c8e80..0d5fd6e 100755 --- a/src/manifest.json +++ b/src/manifest.json @@ -4,14 +4,34 @@ "author": EXTENSION_AUTHOR, "manifest_version": 3, "version": EXTENSION_VERSION, + "permissions": [ + "storage", + "scripting" + ], + "host_permissions": [ + "https://wiki.teamfortress.com/wiki/*", + "https://*.prices.tf/*", + "https://open.er-api.com/*" + ], + "web_accessible_resources": [ + { + "resources": ["lib/style.css", "resources/*"], + "matches": ["https://wiki.teamfortress.com/*"] + } + ], "content_scripts": [ { "matches": ["*://wiki.teamfortress.com/wiki/*"], "run_at": "document_start", "all_frames": true, + "css": ["lib/style.css"], "js": ["content/content.js"] } ], + "background": { + "service_worker": "background/background.js", + "type": "module" + }, "icons": { "48": "icons/icon-48.png", "96": "icons/icon-96.png" diff --git a/tsconfig.json b/tsconfig.json index 8282a68..4b38ead 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,6 @@ "moduleResolution": "node", "resolveJsonModule": true, "allowSyntheticDefaultImports": true, - "types": ["bun-types", "jest", "greasemonkey", "firefox-webext-browser"] + "types": ["bun-types", "jest", "greasemonkey", "chrome", "firefox-webext-browser"] } } \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index a26fc60..b67934b 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,6 @@ var path = require('path'); var CopyPlugin = require('copy-webpack-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); var webpack = require('webpack'); var fs = require('fs'); var package = require('./package.json'); @@ -21,32 +22,66 @@ const defines = { } module.exports = [ - /* // WebExtension { + devtool: "source-map", entry: { - content: './src/content/content.ts' + content: './src/content/content.ts', + background: './src/background/background.ts', + style: './src/content/style.css' }, module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', - exclude: /node_modules/, + exclude: /node_modules|GM_fetch/, }, { - test: /\.(png|jpg|gif|svg)$/i, + test: /\.css$/i, use: [ { - loader: 'url-loader', + loader: MiniCssExtractPlugin.loader, options: { - limit: true, + publicPath: '/', // Adjust if needed for relative path resolution + }, + }, + { + loader: 'css-loader', + options: { + url: true, // Ensures url() in CSS is processed + }, + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: { + 'postcss-url': { + url: (asset) => { + // Transform relative URLs to extension-style URLs + const relativePath = asset.url.replace(/^\.\.\//, '') // Remove leading ../resources part + return `chrome-extension://__MSG_@@extension_id__/${relativePath}`; + }, + }, + }, + }, }, }, ], }, + { + test: /\.(jpe?g|png|ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/, + type: 'asset/resource', + generator: { + filename: 'resources/[name][ext]', + }, + }, ], }, + externals: { + './src/content/GM_fetch': 'commonjs2 null' + }, optimization: { minimize: true }, @@ -55,12 +90,12 @@ module.exports = [ filename: "[name]/[name].js" }, resolve: { - extensions: [".ts", ".tsx", ".js", ".json", ".css"] + extensions: [".ts", ".tsx", ".js", ".json"] }, plugins: [ - new webpack.DefinePlugin({__ENV_WEBEXTENSION: true, __ENV_USERSCRIPT: false}), + new webpack.DefinePlugin({ ...defines, __ENV_WEBEXTENSION: true, __ENV_USERSCRIPT: false}), new CopyPlugin({ patterns: [ - { from: './src/manifest.json', to: 'manifest.json', + { from: './src/manifest.json', to: 'manifest.json', transform(content, absoluteFrom) { return allReplace(content.toString(), defines) }, @@ -68,14 +103,17 @@ module.exports = [ ]}), new CopyPlugin({ patterns: [ { from: './src/icons', to: 'icons/[file]'}, + { from: './src/resources/*.png', to: 'resources/[name][ext]' }, ]}), + new MiniCssExtractPlugin({ + filename: 'lib/style.css' + }), ], }, - */ // Userscript { entry: { - content: './src/content/content.ts' + content: ['./src/content/content.ts', './src/content/GM_fetch/index.js' ] }, module: { rules: [