-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathBuild-Extension.ps1
More file actions
287 lines (254 loc) · 11.3 KB
/
Copy pathBuild-Extension.ps1
File metadata and controls
287 lines (254 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# Build-Extension.ps1
#
# Converts YoutubeAdblock.user.js into extension/main.js by:
# 1. Stripping the ==UserScript== header
# 2. Injecting GM_* -> browser-native shims inside the IIFE
# 3. Injecting event listeners that the isolated-world bridge uses
# to relay action/command/context-menu triggers.
#
# Run after editing YoutubeAdblock.user.js. No parameters.
[CmdletBinding()]
param(
[string]$RepoRoot,
[string]$SourceName = 'YoutubeAdblock.user.js',
[string]$OutDir = 'extension',
[string]$OutName = 'main.js',
[string]$NetworkRuleSource = 'extension\rules\network-rules-source.json',
[string]$NetworkRuleOutput = 'extension\rules\network-blocks.json'
)
if ([string]::IsNullOrEmpty($RepoRoot)) {
if ($PSScriptRoot) {
$RepoRoot = $PSScriptRoot
} else {
$RepoRoot = (Get-Location).Path
}
}
$ErrorActionPreference = 'Stop'
$srcPath = Join-Path $RepoRoot $SourceName
$outPath = Join-Path $RepoRoot (Join-Path $OutDir $OutName)
$networkSourcePath = Join-Path $RepoRoot $NetworkRuleSource
$networkOutPath = Join-Path $RepoRoot $NetworkRuleOutput
if (-not (Test-Path -LiteralPath $srcPath)) {
throw "Source not found: $srcPath"
}
$raw = [System.IO.File]::ReadAllText($srcPath)
# Strip a leading UTF-8 BOM if present so the header regex matches cleanly.
if ($raw.Length -gt 0 -and $raw[0] -eq [char]0xFEFF) {
$raw = $raw.Substring(1)
}
if (Test-Path -LiteralPath $networkSourcePath) {
$networkSource = Get-Content -LiteralPath $networkSourcePath -Raw | ConvertFrom-Json
$sourcePatterns = @($networkSource.interceptPatterns)
$dnrRules = @($networkSource.dnrRules)
if (-not $sourcePatterns.Count) {
throw "Network rule source has no interceptPatterns: $networkSourcePath"
}
if (-not $dnrRules.Count) {
throw "Network rule source has no dnrRules: $networkSourcePath"
}
foreach ($pattern in $sourcePatterns) {
if ([string]::IsNullOrWhiteSpace([string]$pattern)) {
throw "Network rule source contains an empty intercept pattern."
}
$needle = "'" + [string]$pattern + "'"
if (-not $raw.Contains($needle)) {
throw "Userscript DEFAULT_FILTERS.interceptPatterns missing source pattern: $pattern"
}
}
$networkOutDir = Split-Path -Parent $networkOutPath
if (-not (Test-Path -LiteralPath $networkOutDir)) {
New-Item -ItemType Directory -Path $networkOutDir | Out-Null
}
# PowerShell 5.1 and PowerShell 7 use different pretty-print indentation.
# Keep generated rules compact so rebuilding is byte-stable in either host.
$dnrJson = $dnrRules | ConvertTo-Json -Depth 30 -Compress
$stableDnrJson = $dnrJson.Replace("`r`n", "`n").Replace("`r", "`n") + "`n"
[System.IO.File]::WriteAllText($networkOutPath, $stableDnrJson, (New-Object System.Text.UTF8Encoding($false)))
}
# Strip the ==UserScript== block (and anything before it that is only
# the file's leading whitespace). Case-insensitive, dotall.
$headerPattern = '(?is)^\s*//\s*==UserScript==.*?//\s*==/UserScript==\s*'
if ($raw -notmatch $headerPattern) {
throw "Could not locate ==UserScript== header in $srcPath"
}
$body = [System.Text.RegularExpressions.Regex]::Replace($raw, $headerPattern, '', 'IgnoreCase, Singleline')
# Locate the first `'use strict';` inside the IIFE so we can inject the
# GM_* shims immediately after it. Fail loudly if the anchor moved.
$useStrictPattern = "('use strict';\s*)"
if ($body -notmatch $useStrictPattern) {
throw "Expected 'use strict'; marker inside IIFE not found."
}
$gmShim = @'
/* =========================================================================
* GM_* SHIM (extension build, generated by Build-Extension.ps1)
* =========================================================================
* Replaces the userscript's GM_* APIs with browser-native equivalents so
* the same source compiles into both a userscript and an MV3 extension.
*
* Storage strategy:
* - localStorage is the read path (sync, no-latency at document-start).
* - A CustomEvent bridge asks the ISOLATED-world content script to mirror
* writes into chrome.storage.local and eligible settings into
* chrome.storage.sync chunks. When either storage area changes (e.g.
* user edited settings on another signed-in browser), the bridge pushes
* an update back and we refresh localStorage so subsequent reads win.
*
* Network strategy:
* - GM_xmlhttpRequest -> native fetch. raw.githubusercontent.com and
* sponsor.ajay.app both send Access-Control-Allow-Origin: *, so
* MAIN-world fetch works without a background-script proxy.
* ===================================================================== */
const __YTAB_STORAGE_KEY = '__ytab_ext_settings__';
const __YTAB_EVT_REQ = 'ytab:page-request';
const __YTAB_EVT_RES = 'ytab:page-response';
const __YTAB_EVT_SYNC = 'ytab:settings-changed';
function __ytabReadAll() {
try {
const raw = localStorage.getItem(__YTAB_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object') ? parsed : {};
} catch (e) {
return {};
}
}
function __ytabWriteAll(obj) {
try {
localStorage.setItem(__YTAB_STORAGE_KEY, JSON.stringify(obj));
} catch (e) { /* quota / serialization failure - read path still works */ }
}
function __ytabMirrorToBridge(key, value) {
try {
const id = 'ytab-' + Math.random().toString(36).slice(2, 10);
document.dispatchEvent(new CustomEvent(__YTAB_EVT_REQ, {
detail: { id, op: 'set', key: __YTAB_STORAGE_KEY, value }
}));
} catch (e) { /* ignore */ }
}
function GM_getValue(key, def) {
const all = __ytabReadAll();
return (key in all) ? all[key] : def;
}
function GM_setValue(key, val) {
const all = __ytabReadAll();
all[key] = val;
__ytabWriteAll(all);
__ytabMirrorToBridge(key, all);
}
function GM_registerMenuCommand(_label, _fn) { return null; }
function GM_unregisterMenuCommand(_handle) { /* no-op in extension */ }
function GM_xmlhttpRequest(opts) {
if (!opts || typeof opts !== 'object') return;
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
let timedOut = false;
const timer = (opts.timeout && controller)
? setTimeout(() => { timedOut = true; controller.abort(); }, opts.timeout)
: null;
fetch(opts.url, {
method: opts.method || 'GET',
headers: opts.headers || undefined,
credentials: 'omit',
signal: controller ? controller.signal : undefined
}).then(async (response) => {
if (timer) clearTimeout(timer);
let text = '';
try { text = await response.text(); } catch (e) { text = ''; }
if (typeof opts.onload === 'function') {
opts.onload({
status: response.status,
statusText: response.statusText,
responseText: text,
readyState: 4
});
}
}).catch((err) => {
if (timer) clearTimeout(timer);
if (timedOut && typeof opts.ontimeout === 'function') { opts.ontimeout(); return; }
if (typeof opts.onerror === 'function') opts.onerror(err);
});
}
// Pull cross-subdomain settings writes from the bridge into localStorage
// so the next read wins without racing the extension storage area.
document.addEventListener(__YTAB_EVT_SYNC, (event) => {
const detail = event && event.detail;
if (!detail || typeof detail !== 'object') return;
const incoming = detail[__YTAB_STORAGE_KEY];
if (incoming && typeof incoming === 'object') {
try {
localStorage.setItem(__YTAB_STORAGE_KEY, JSON.stringify(incoming));
} catch (e) { /* ignore */ }
} else {
try {
localStorage.removeItem(__YTAB_STORAGE_KEY);
} catch (e) { /* ignore */ }
}
});
'@
# Inject the shim right after the first 'use strict';
$body = [System.Text.RegularExpressions.Regex]::Replace($body, $useStrictPattern, "`$1$gmShim", 'None', [System.TimeSpan]::FromSeconds(5))
# Locate the closing `})();` at the end of the IIFE and inject command hooks
# right before it. Use the last occurrence to avoid matching a nested IIFE.
$iifeClose = '})();'
$lastIdx = $body.LastIndexOf($iifeClose)
if ($lastIdx -lt 0) {
throw "Expected closing `'})();'` of IIFE not found."
}
$commandHooks = @'
/* ========================================================================
* EXTENSION COMMAND HOOKS (generated by Build-Extension.ps1)
* ======================================================================
* Bridge DOM events dispatched from bridge.js (isolated world) into the
* existing control functions that the userscript build exposed through
* the Tampermonkey menu commands.
* ==================================================================== */
try {
document.addEventListener('ytab:open-panel', () => {
try { toggleSettings(true); } catch (e) { /* panel not yet built */ }
});
document.addEventListener('ytab:toggle-protection', () => {
try { setScriptEnabled(!isEnabled()); } catch (e) { /* ignore */ }
});
document.addEventListener('ytab:refresh-rules', () => {
try { fetchFilters(true); } catch (e) { /* ignore */ }
});
} catch (e) { /* DOM not ready */ }
'@
$body = $body.Substring(0, $lastIdx) + $commandHooks + $body.Substring($lastIdx)
# Prefix with a provenance header so the generated file is obviously derived.
$header = @'
/*!
* YoutubeAdblock - extension build
*
* GENERATED by Build-Extension.ps1 from YoutubeAdblock.user.js.
* Do not edit this file directly; changes will be overwritten on the next build.
*/
'@
$final = ($header + "`n" + $body.TrimStart()).Replace("`r`n", "`n").Replace("`r", "`n")
[System.IO.File]::WriteAllText($outPath, $final, (New-Object System.Text.UTF8Encoding($false)))
# Sanity check: the generated file must contain both shim markers AND the
# command-bridge markers. This catches regressions where a prior edit to
# the userscript moved the IIFE brace and broke injection.
$written = [System.IO.File]::ReadAllText($outPath)
$expectedMarkers = @(
'__YTAB_STORAGE_KEY',
'function GM_getValue',
'function GM_xmlhttpRequest',
"addEventListener('ytab:open-panel'",
"addEventListener('ytab:toggle-protection'",
"addEventListener('ytab:refresh-rules'"
)
foreach ($marker in $expectedMarkers) {
if (-not $written.Contains($marker)) {
throw "Generated main.js is missing required marker: $marker"
}
}
# Basic syntax smoke-test via node if available. Failing this surfaces
# invalid JS before a user loads the broken extension.
$node = Get-Command -Name node -ErrorAction SilentlyContinue
if ($node) {
& node --check $outPath
if ($LASTEXITCODE -ne 0) {
throw "node --check failed on generated main.js"
}
}
Write-Host "Wrote $outPath ($([math]::Round((Get-Item $outPath).Length / 1024, 1)) KB)"