Skip to content

Commit bb7ba5a

Browse files
committed
fix: trash enabled by default, restart button, dashboard sort, base64 XHR, Firefox support
- Default trashMode to 30 days so deleted scripts go to trash instead of permanent delete - Add restart message handler so the settings restart button works - Dashboard defaults to sorting by most recently updated - Encode arraybuffer XHR responses as base64 for efficient message passing - Add Firefox manifest and build script - Add CWS cookies justification doc
1 parent 012b042 commit bb7ba5a

6 files changed

Lines changed: 270 additions & 11 deletions

File tree

CWS_COOKIES_JUSTIFICATION.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# ScriptVault - Cookies Permission Justification (Chrome Web Store)
2+
3+
## Permission: `cookies` (optional)
4+
5+
### Single Purpose Description
6+
7+
ScriptVault is a userscript manager that allows users to install and run custom JavaScript userscripts on web pages. The `cookies` permission is listed as an **optional permission** and is only activated when a user installs a userscript that explicitly declares `@grant GM_cookie` or `@grant GM.cookie` in its metadata.
8+
9+
### Why the `cookies` permission is needed
10+
11+
ScriptVault implements the `GM_cookie` API, which is part of the standard Greasemonkey/Tampermonkey userscript API specification. This API provides three functions:
12+
13+
- **`GM_cookie.list()`** — Reads cookies for a specific domain (calls `chrome.cookies.getAll()`)
14+
- **`GM_cookie.set()`** — Sets a cookie on a specific domain (calls `chrome.cookies.set()`)
15+
- **`GM_cookie.delete()`** — Removes a cookie from a specific domain (calls `chrome.cookies.remove()`)
16+
17+
These functions are required for compatibility with existing userscripts that depend on cookie access for legitimate purposes such as:
18+
19+
- Managing login sessions across subdomains
20+
- Clearing tracking cookies from specific sites
21+
- Reading site preferences stored in cookies
22+
- Automating cookie consent workflows
23+
24+
### How it is used
25+
26+
1. The `cookies` permission is declared as an **optional permission** in `manifest.json` — it is never granted at install time.
27+
2. When a user installs a userscript containing `@grant GM_cookie`, ScriptVault requests the permission via `chrome.permissions.request()` with an explicit user prompt.
28+
3. Cookie operations are gated by a per-script `@grant` check — scripts without the `GM_cookie` grant cannot access cookie functions even if the permission has been granted.
29+
4. An additional user-facing setting ("Allow scripts to access cookies") in the dashboard provides a global toggle for cookie access.
30+
5. The extension does not read, modify, or transmit cookies for its own purposes. All cookie operations are initiated exclusively by user-installed userscripts.
31+
32+
### User control
33+
34+
- Users choose which userscripts to install and can review `@grant` declarations before installation.
35+
- The optional permission prompt gives users an explicit opt-in at the browser level.
36+
- The dashboard settings panel provides a global cookie access toggle.
37+
- Users can revoke the optional permission at any time via Chrome's extension settings.
38+
39+
### Privacy
40+
41+
ScriptVault does not collect, store, or transmit any cookie data. Cookie operations occur entirely on the user's device between the userscript and the browser's cookie store. No cookie data is sent to any remote server by the extension itself. See our [Privacy Policy](https://github.com/SysAdminDoc/ScriptVault/blob/main/PRIVACY.md) for full details.
42+
43+
---
44+
45+
## CWS Submission Form — Suggested Text
46+
47+
**"Why does your extension need the `cookies` permission?"**
48+
49+
> ScriptVault is a userscript manager. The `cookies` permission is declared as optional and is only requested when a user installs a userscript that uses the standard GM_cookie API (`@grant GM_cookie`). This API allows userscripts to list, set, and delete cookies for specific domains — a standard feature of userscript managers (Tampermonkey, Violentmonkey). The permission is never used by the extension itself; it is exclusively used to fulfill userscript API calls initiated by user-installed scripts. Users must explicitly opt in via Chrome's permission prompt, and a dashboard toggle provides additional control. No cookie data is collected or transmitted by the extension.

background.core.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,7 @@ async function handleMessage(message, sender) {
762762
const scriptId = data.id || data.scriptId;
763763
if (!scriptId) return { error: 'No script ID provided' };
764764
const settings = await SettingsManager.get();
765-
const trashMode = settings.trashMode || 'disabled';
765+
const trashMode = settings.trashMode || '30';
766766

767767
if (trashMode !== 'disabled') {
768768
// Move to trash instead of permanent delete
@@ -786,7 +786,7 @@ async function handleMessage(message, sender) {
786786
const trash = trashData.trash || [];
787787
// Clean expired entries
788788
const settings = await SettingsManager.get();
789-
const trashMode = settings.trashMode || 'disabled';
789+
const trashMode = settings.trashMode || '30';
790790
const maxAge = trashMode === '1' ? 86400000 : trashMode === '7' ? 604800000 : trashMode === '30' ? 2592000000 : 0;
791791
const now = Date.now();
792792
const valid = maxAge > 0 ? trash.filter(s => now - s.trashedAt < maxAge) : trash;
@@ -818,6 +818,11 @@ async function handleMessage(message, sender) {
818818
return { success: true };
819819
}
820820

821+
case 'restart': {
822+
chrome.runtime.reload();
823+
return { success: true };
824+
}
825+
821826
case 'permanentlyDelete': {
822827
const scriptId = data.scriptId;
823828
const trashData = await chrome.storage.local.get('trash');
@@ -1368,7 +1373,14 @@ async function handleMessage(message, sender) {
13681373

13691374
if (data.responseType === 'arraybuffer') {
13701375
const buffer = await response.arrayBuffer();
1371-
responseData = Array.from(new Uint8Array(buffer));
1376+
// Encode as base64 for efficient transfer (33% overhead vs 800%+ for number arrays)
1377+
const bytes = new Uint8Array(buffer);
1378+
let binary = '';
1379+
// Process in 32KB chunks to avoid call stack overflow
1380+
for (let offset = 0; offset < bytes.length; offset += 32768) {
1381+
binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 32768));
1382+
}
1383+
responseData = { __sv_base64__: true, data: btoa(binary) };
13721384
sendEvent('progress', {
13731385
readyState: 3,
13741386
lengthComputable: contentLength > 0,
@@ -3090,13 +3102,35 @@ ${req.code}
30903102
const eventType = msg.eventType;
30913103
const eventData = msg.data || {};
30923104
3105+
// Decode binary responses transferred as base64/dataURL
3106+
let responseValue = eventData.response;
3107+
if (responseValue && typeof responseValue === 'object' && responseValue.__sv_base64__) {
3108+
// arraybuffer: base64 -> ArrayBuffer
3109+
const binary = atob(responseValue.data);
3110+
const bytes = new Uint8Array(binary.length);
3111+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
3112+
responseValue = bytes.buffer;
3113+
} else if (details.responseType === 'blob' && typeof responseValue === 'string' && responseValue.startsWith('data:')) {
3114+
// blob: data URL -> Blob
3115+
try {
3116+
const [header, b64] = responseValue.split(',');
3117+
const mime = header.match(/:(.*?);/)?.[1] || 'application/octet-stream';
3118+
const binary = atob(b64);
3119+
const bytes = new Uint8Array(binary.length);
3120+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
3121+
responseValue = new Blob([bytes], { type: mime });
3122+
} catch (e) {
3123+
// Fall through with data URL string if conversion fails
3124+
}
3125+
}
3126+
30933127
// Build response object matching GM_xmlhttpRequest spec
30943128
const response = {
30953129
readyState: eventData.readyState || 0,
30963130
status: eventData.status || 0,
30973131
statusText: eventData.statusText || '',
30983132
responseHeaders: eventData.responseHeaders || '',
3099-
response: eventData.response,
3133+
response: responseValue,
31003134
responseText: eventData.responseText || '',
31013135
responseXML: eventData.responseXML,
31023136
finalUrl: eventData.finalUrl || details.url,

background.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5291,7 +5291,7 @@ async function handleMessage(message, sender) {
52915291
const scriptId = data.id || data.scriptId;
52925292
if (!scriptId) return { error: 'No script ID provided' };
52935293
const settings = await SettingsManager.get();
5294-
const trashMode = settings.trashMode || 'disabled';
5294+
const trashMode = settings.trashMode || '30';
52955295

52965296
if (trashMode !== 'disabled') {
52975297
// Move to trash instead of permanent delete
@@ -5315,7 +5315,7 @@ async function handleMessage(message, sender) {
53155315
const trash = trashData.trash || [];
53165316
// Clean expired entries
53175317
const settings = await SettingsManager.get();
5318-
const trashMode = settings.trashMode || 'disabled';
5318+
const trashMode = settings.trashMode || '30';
53195319
const maxAge = trashMode === '1' ? 86400000 : trashMode === '7' ? 604800000 : trashMode === '30' ? 2592000000 : 0;
53205320
const now = Date.now();
53215321
const valid = maxAge > 0 ? trash.filter(s => now - s.trashedAt < maxAge) : trash;
@@ -5347,6 +5347,11 @@ async function handleMessage(message, sender) {
53475347
return { success: true };
53485348
}
53495349

5350+
case 'restart': {
5351+
chrome.runtime.reload();
5352+
return { success: true };
5353+
}
5354+
53505355
case 'permanentlyDelete': {
53515356
const scriptId = data.scriptId;
53525357
const trashData = await chrome.storage.local.get('trash');
@@ -5897,7 +5902,14 @@ async function handleMessage(message, sender) {
58975902

58985903
if (data.responseType === 'arraybuffer') {
58995904
const buffer = await response.arrayBuffer();
5900-
responseData = Array.from(new Uint8Array(buffer));
5905+
// Encode as base64 for efficient transfer (33% overhead vs 800%+ for number arrays)
5906+
const bytes = new Uint8Array(buffer);
5907+
let binary = '';
5908+
// Process in 32KB chunks to avoid call stack overflow
5909+
for (let offset = 0; offset < bytes.length; offset += 32768) {
5910+
binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 32768));
5911+
}
5912+
responseData = { __sv_base64__: true, data: btoa(binary) };
59015913
sendEvent('progress', {
59025914
readyState: 3,
59035915
lengthComputable: contentLength > 0,
@@ -7619,13 +7631,35 @@ ${req.code}
76197631
const eventType = msg.eventType;
76207632
const eventData = msg.data || {};
76217633
7634+
// Decode binary responses transferred as base64/dataURL
7635+
let responseValue = eventData.response;
7636+
if (responseValue && typeof responseValue === 'object' && responseValue.__sv_base64__) {
7637+
// arraybuffer: base64 -> ArrayBuffer
7638+
const binary = atob(responseValue.data);
7639+
const bytes = new Uint8Array(binary.length);
7640+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
7641+
responseValue = bytes.buffer;
7642+
} else if (details.responseType === 'blob' && typeof responseValue === 'string' && responseValue.startsWith('data:')) {
7643+
// blob: data URL -> Blob
7644+
try {
7645+
const [header, b64] = responseValue.split(',');
7646+
const mime = header.match(/:(.*?);/)?.[1] || 'application/octet-stream';
7647+
const binary = atob(b64);
7648+
const bytes = new Uint8Array(binary.length);
7649+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
7650+
responseValue = new Blob([bytes], { type: mime });
7651+
} catch (e) {
7652+
// Fall through with data URL string if conversion fails
7653+
}
7654+
}
7655+
76227656
// Build response object matching GM_xmlhttpRequest spec
76237657
const response = {
76247658
readyState: eventData.readyState || 0,
76257659
status: eventData.status || 0,
76267660
statusText: eventData.statusText || '',
76277661
responseHeaders: eventData.responseHeaders || '',
7628-
response: eventData.response,
7662+
response: responseValue,
76297663
responseText: eventData.responseText || '',
76307664
responseXML: eventData.responseXML,
76317665
finalUrl: eventData.finalUrl || details.url,

build-firefox.sh

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/bin/bash
2+
# ScriptVault - Firefox Add-on Build Script
3+
# Packages the extension into an .xpi/.zip ready for AMO upload or sideloading
4+
5+
set -e
6+
7+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8+
BUILD_DIR="$SCRIPT_DIR/build-firefox"
9+
VERSION=$(grep -o '"version": "[^"]*"' "$SCRIPT_DIR/manifest-firefox.json" | cut -d'"' -f4)
10+
ZIP_NAME="ScriptVault-firefox-v${VERSION}.zip"
11+
12+
echo "Building ScriptVault for Firefox v$VERSION..."
13+
14+
# Build background.js first if source modules exist
15+
if [ -f "$SCRIPT_DIR/build-background.sh" ]; then
16+
echo "Building background.js from source modules..."
17+
bash "$SCRIPT_DIR/build-background.sh"
18+
fi
19+
20+
# Clean previous build
21+
rm -rf "$BUILD_DIR"
22+
mkdir -p "$BUILD_DIR"
23+
24+
# Files/folders to include
25+
INCLUDE=(
26+
background.js
27+
content.js
28+
shared
29+
pages
30+
images/icon16.png
31+
images/icon32.png
32+
images/icon48.png
33+
images/icon128.png
34+
lib
35+
_locales
36+
)
37+
38+
# Copy Firefox manifest as manifest.json
39+
cp "$SCRIPT_DIR/manifest-firefox.json" "$BUILD_DIR/manifest.json"
40+
41+
# Copy included files
42+
for item in "${INCLUDE[@]}"; do
43+
src="$SCRIPT_DIR/$item"
44+
dest="$BUILD_DIR/$item"
45+
if [ -d "$src" ]; then
46+
mkdir -p "$dest"
47+
cp -r "$src"/* "$dest"/
48+
elif [ -f "$src" ]; then
49+
mkdir -p "$(dirname "$dest")"
50+
cp "$src" "$dest"
51+
else
52+
echo "Warning: $item not found, skipping"
53+
fi
54+
done
55+
56+
# Build the zip/xpi
57+
cd "$BUILD_DIR"
58+
rm -f "$SCRIPT_DIR/$ZIP_NAME"
59+
60+
if command -v zip &> /dev/null; then
61+
zip -r "$SCRIPT_DIR/$ZIP_NAME" . -x "*.DS_Store" "*Thumbs.db"
62+
else
63+
powershell.exe -NoProfile -Command "Compress-Archive -Path '$BUILD_DIR\*' -DestinationPath '$SCRIPT_DIR\\$ZIP_NAME' -Force"
64+
fi
65+
66+
echo ""
67+
echo "Build complete: $ZIP_NAME"
68+
echo "Size: $(du -h "$SCRIPT_DIR/$ZIP_NAME" | cut -f1)"
69+
echo ""
70+
echo "Ready for Firefox Add-ons (AMO) upload or sideloading."
71+
72+
# Cleanup
73+
rm -rf "$BUILD_DIR"

manifest-firefox.json

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "__MSG_extName__",
4+
"version": "1.5.2",
5+
"description": "__MSG_extDescription__",
6+
"default_locale": "en",
7+
"homepage_url": "https://github.com/SysAdminDoc/ScriptVault",
8+
"browser_specific_settings": {
9+
"gecko": {
10+
"id": "ScriptVault@sysadmindoc.dev",
11+
"strict_min_version": "128.0"
12+
}
13+
},
14+
"icons": {
15+
"16": "images/icon16.png",
16+
"32": "images/icon32.png",
17+
"48": "images/icon48.png",
18+
"128": "images/icon128.png"
19+
},
20+
"permissions": [
21+
"storage",
22+
"tabs",
23+
"notifications",
24+
"menus",
25+
"scripting",
26+
"userScripts",
27+
"webNavigation",
28+
"unlimitedStorage",
29+
"alarms",
30+
"downloads"
31+
],
32+
"optional_permissions": [
33+
"clipboardWrite",
34+
"clipboardRead",
35+
"identity",
36+
"cookies"
37+
],
38+
"host_permissions": [
39+
"<all_urls>"
40+
],
41+
"background": {
42+
"scripts": ["background.js"],
43+
"type": "module"
44+
},
45+
"action": {
46+
"default_icon": {
47+
"16": "images/icon16.png",
48+
"32": "images/icon32.png"
49+
},
50+
"default_title": "__MSG_extName__",
51+
"default_popup": "pages/popup.html"
52+
},
53+
"options_ui": {
54+
"page": "pages/dashboard.html",
55+
"open_in_tab": true
56+
},
57+
"content_scripts": [{
58+
"matches": ["<all_urls>"],
59+
"js": ["content.js"],
60+
"run_at": "document_start",
61+
"all_frames": true
62+
}],
63+
"web_accessible_resources": [{
64+
"resources": ["pages/install.html"],
65+
"matches": ["<all_urls>"]
66+
}],
67+
"commands": {}
68+
}

pages/dashboard.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
editor: null,
1111
unsavedChanges: false,
1212
selectedScripts: new Set(),
13-
sortColumn: 'order',
14-
sortDirection: 'asc',
13+
sortColumn: 'updated',
14+
sortDirection: 'desc',
1515
openTabs: {} // { scriptId: { code, unsaved } }
1616
};
1717

@@ -338,6 +338,7 @@
338338
await loadScripts();
339339
initEditor();
340340
initEventListeners();
341+
updateSortIndicators();
341342
applyTheme();
342343
updateStats();
343344
toggleSyncProviderSettings();
@@ -456,7 +457,7 @@
456457
if (elements.settingsDebugMode) elements.settingsDebugMode.checked = s.debugMode || false;
457458
if (elements.settingsShowFixedSource) elements.settingsShowFixedSource.checked = s.showFixedSource || false;
458459
if (elements.settingsLoggingLevel) elements.settingsLoggingLevel.value = s.loggingLevel || 'error';
459-
if (elements.settingsTrashMode) elements.settingsTrashMode.value = s.trashMode || 'disabled';
460+
if (elements.settingsTrashMode) elements.settingsTrashMode.value = s.trashMode || '30';
460461

461462
// Appearance settings
462463
if (elements.settingsLayout) elements.settingsLayout.value = s.layout || 'dark';

0 commit comments

Comments
 (0)