@bghitcode/bghitapp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/cli.js +2995 -0
  4. package/package.json +104 -0
  5. package/src-tauri/Cargo.lock +5966 -0
  6. package/src-tauri/Cargo.toml +59 -0
  7. package/src-tauri/Info.plist +14 -0
  8. package/src-tauri/assets/macos/dmg/background.png +0 -0
  9. package/src-tauri/assets/main.wxs +350 -0
  10. package/src-tauri/bghitapp.json +42 -0
  11. package/src-tauri/build.rs +5 -0
  12. package/src-tauri/capabilities/default.json +29 -0
  13. package/src-tauri/entitlements.plist +7 -0
  14. package/src-tauri/icons/chatgpt.icns +0 -0
  15. package/src-tauri/icons/deepseek.icns +0 -0
  16. package/src-tauri/icons/excalidraw.icns +0 -0
  17. package/src-tauri/icons/flomo.icns +0 -0
  18. package/src-tauri/icons/gemini.icns +0 -0
  19. package/src-tauri/icons/grok.icns +0 -0
  20. package/src-tauri/icons/icon.icns +0 -0
  21. package/src-tauri/icons/icon.png +0 -0
  22. package/src-tauri/icons/lizhi.icns +0 -0
  23. package/src-tauri/icons/programmusic.icns +0 -0
  24. package/src-tauri/icons/qwerty.icns +0 -0
  25. package/src-tauri/icons/twitter.icns +0 -0
  26. package/src-tauri/icons/wechat.icns +0 -0
  27. package/src-tauri/icons/weekly.icns +0 -0
  28. package/src-tauri/icons/weread.icns +0 -0
  29. package/src-tauri/icons/xiaohongshu.icns +0 -0
  30. package/src-tauri/icons/youtube.icns +0 -0
  31. package/src-tauri/icons/youtubemusic.icns +0 -0
  32. package/src-tauri/rust_proxy.toml +10 -0
  33. package/src-tauri/src/app/config.rs +100 -0
  34. package/src-tauri/src/app/invoke.rs +242 -0
  35. package/src-tauri/src/app/menu.rs +324 -0
  36. package/src-tauri/src/app/mod.rs +6 -0
  37. package/src-tauri/src/app/setup.rs +172 -0
  38. package/src-tauri/src/app/window.rs +577 -0
  39. package/src-tauri/src/inject/auth.js +75 -0
  40. package/src-tauri/src/inject/custom.js +0 -0
  41. package/src-tauri/src/inject/event.js +1111 -0
  42. package/src-tauri/src/inject/find.js +708 -0
  43. package/src-tauri/src/inject/fullscreen.js +253 -0
  44. package/src-tauri/src/inject/offline.js +68 -0
  45. package/src-tauri/src/inject/splash-transition.js +13 -0
  46. package/src-tauri/src/inject/style.js +505 -0
  47. package/src-tauri/src/inject/theme_refresh.js +59 -0
  48. package/src-tauri/src/inject/toast.js +22 -0
  49. package/src-tauri/src/lib.rs +227 -0
  50. package/src-tauri/src/main.rs +8 -0
  51. package/src-tauri/src/util.rs +245 -0
  52. package/src-tauri/tauri.conf.json +20 -0
  53. package/src-tauri/tauri.linux.conf.json +12 -0
  54. package/src-tauri/tauri.macos.conf.json +28 -0
  55. package/src-tauri/tauri.windows.conf.json +15 -0
@@ -0,0 +1,1111 @@
1
+ const shortcuts = {
2
+ "[": () => window.history.back(),
3
+ "]": () => window.history.forward(),
4
+ "-": () => zoomOut(),
5
+ "=": () => zoomIn(),
6
+ "+": () => zoomIn(),
7
+ 0: () => setZoom("100%"),
8
+ r: () => window.location.reload(),
9
+ ArrowUp: () => scrollTo(0, 0),
10
+ ArrowDown: () => scrollTo(0, document.body.scrollHeight),
11
+ };
12
+
13
+ function setZoom(zoom) {
14
+ const html = document.getElementsByTagName("html")[0];
15
+ const body = document.body;
16
+ const zoomValue = parseFloat(zoom) / 100;
17
+ const isWindows = /windows/i.test(navigator.userAgent);
18
+
19
+ if (isWindows) {
20
+ body.style.transform = `scale(${zoomValue})`;
21
+ body.style.transformOrigin = "top left";
22
+ body.style.width = `${100 / zoomValue}%`;
23
+ body.style.height = `${100 / zoomValue}%`;
24
+ } else {
25
+ html.style.zoom = zoom;
26
+ window.dispatchEvent(new Event("resize"));
27
+ }
28
+
29
+ window.localStorage.setItem("htmlZoom", zoom);
30
+ }
31
+
32
+ function zoomCommon(zoomChange) {
33
+ const currentZoom = window.localStorage.getItem("htmlZoom") || "100%";
34
+ setZoom(zoomChange(currentZoom));
35
+ }
36
+
37
+ function zoomIn() {
38
+ zoomCommon((currentZoom) => `${Math.min(parseInt(currentZoom) + 10, 200)}%`);
39
+ }
40
+
41
+ function zoomOut() {
42
+ zoomCommon((currentZoom) => `${Math.max(parseInt(currentZoom) - 10, 30)}%`);
43
+ }
44
+
45
+ let pasteAsPlainTextPending = false;
46
+
47
+ function triggerPasteAsPlainText() {
48
+ pasteAsPlainTextPending = true;
49
+ document.execCommand("paste");
50
+ setTimeout(() => {
51
+ pasteAsPlainTextPending = false;
52
+ }, 100);
53
+ }
54
+
55
+ function handleShortcut(event) {
56
+ if (shortcuts[event.key]) {
57
+ event.preventDefault();
58
+ shortcuts[event.key]();
59
+ }
60
+ }
61
+
62
+ const DOWNLOADABLE_FILE_EXTENSIONS = {
63
+ documents: [
64
+ "pdf",
65
+ "doc",
66
+ "docx",
67
+ "xls",
68
+ "xlsx",
69
+ "ppt",
70
+ "pptx",
71
+ "txt",
72
+ "rtf",
73
+ "odt",
74
+ "ods",
75
+ "odp",
76
+ "pages",
77
+ "numbers",
78
+ "key",
79
+ "epub",
80
+ "mobi",
81
+ ],
82
+ archives: [
83
+ "zip",
84
+ "rar",
85
+ "7z",
86
+ "tar",
87
+ "gz",
88
+ "gzip",
89
+ "bz2",
90
+ "xz",
91
+ "lzma",
92
+ "deb",
93
+ "rpm",
94
+ "pkg",
95
+ "msi",
96
+ "exe",
97
+ "dmg",
98
+ "apk",
99
+ "ipa",
100
+ ],
101
+ data: [
102
+ "json",
103
+ "xml",
104
+ "csv",
105
+ "sql",
106
+ "db",
107
+ "sqlite",
108
+ "yaml",
109
+ "yml",
110
+ "toml",
111
+ "ini",
112
+ "cfg",
113
+ "conf",
114
+ "log",
115
+ ],
116
+ code: [
117
+ "js",
118
+ "ts",
119
+ "jsx",
120
+ "tsx",
121
+ "css",
122
+ "scss",
123
+ "sass",
124
+ "less",
125
+ "sh",
126
+ "bat",
127
+ "ps1",
128
+ ],
129
+ fonts: ["ttf", "otf", "woff", "woff2", "eot"],
130
+ design: ["ai", "psd", "sketch", "fig", "xd"],
131
+ system: [
132
+ "iso",
133
+ "img",
134
+ "bin",
135
+ "torrent",
136
+ "jar",
137
+ "war",
138
+ "indd",
139
+ "fla",
140
+ "swf",
141
+ "raw",
142
+ ],
143
+ };
144
+
145
+ const ALL_DOWNLOADABLE_EXTENSIONS = Object.values(
146
+ DOWNLOADABLE_FILE_EXTENSIONS,
147
+ ).flat();
148
+
149
+ const PREVIEWABLE_MEDIA_EXTENSIONS = [
150
+ "png",
151
+ "jpg",
152
+ "jpeg",
153
+ "gif",
154
+ "webp",
155
+ "svg",
156
+ "bmp",
157
+ "tiff",
158
+ "tif",
159
+ "avif",
160
+ "heic",
161
+ "heif",
162
+ "mp4",
163
+ "webm",
164
+ "mov",
165
+ "m4v",
166
+ "mkv",
167
+ "avi",
168
+ "ogv",
169
+ "mp3",
170
+ "wav",
171
+ "ogg",
172
+ "flac",
173
+ "aac",
174
+ "m4a",
175
+ ];
176
+
177
+ const DOWNLOAD_PATH_PATTERNS = [
178
+ "/download/",
179
+ "/files/",
180
+ "/attachments/",
181
+ "/assets/",
182
+ "/releases/",
183
+ "/dist/",
184
+ ];
185
+
186
+ // Language detection utilities
187
+ function getUserLanguage() {
188
+ return navigator.language || navigator.userLanguage;
189
+ }
190
+
191
+ function isChineseLanguage(language = getUserLanguage()) {
192
+ return (
193
+ language &&
194
+ (language.startsWith("zh") ||
195
+ language.includes("CN") ||
196
+ language.includes("TW") ||
197
+ language.includes("HK"))
198
+ );
199
+ }
200
+
201
+ // User notification helper
202
+ function showDownloadError(filename) {
203
+ const isChinese = isChineseLanguage();
204
+ const message = isChinese
205
+ ? `下载失败: ${filename}`
206
+ : `Download failed: ${filename}`;
207
+
208
+ if (window.Notification && Notification.permission === "granted") {
209
+ new Notification(isChinese ? "下载错误" : "Download Error", {
210
+ body: message,
211
+ });
212
+ } else {
213
+ console.error(message);
214
+ }
215
+ }
216
+
217
+ function getExtension(url) {
218
+ try {
219
+ const pathname = new URL(url).pathname.toLowerCase();
220
+ const extensionIndex = pathname.lastIndexOf(".");
221
+ return extensionIndex > -1 ? pathname.slice(extensionIndex + 1) : "";
222
+ } catch (e) {
223
+ return "";
224
+ }
225
+ }
226
+
227
+ function isPreviewableMedia(url) {
228
+ const extension = getExtension(url);
229
+ return PREVIEWABLE_MEDIA_EXTENSIONS.includes(extension);
230
+ }
231
+
232
+ // Unified file detection - replaces both isDownloadLink and isFileLink
233
+ function isDownloadableFile(url) {
234
+ try {
235
+ const extension = getExtension(url);
236
+ if (PREVIEWABLE_MEDIA_EXTENSIONS.includes(extension)) {
237
+ return false;
238
+ }
239
+
240
+ const urlObj = new URL(url);
241
+ const hasDownloadHints =
242
+ urlObj.searchParams.has("download") ||
243
+ urlObj.searchParams.has("attachment");
244
+
245
+ if (hasDownloadHints) {
246
+ return true;
247
+ }
248
+
249
+ return (
250
+ ALL_DOWNLOADABLE_EXTENSIONS.includes(extension) ||
251
+ DOWNLOAD_PATH_PATTERNS.some((pattern) =>
252
+ urlObj.pathname.toLowerCase().includes(pattern),
253
+ )
254
+ );
255
+ } catch (e) {
256
+ return false;
257
+ }
258
+ }
259
+
260
+ function normalizeAnchorHref(rawHref) {
261
+ return typeof rawHref === "string" ? rawHref.trim() : "";
262
+ }
263
+
264
+ function shouldBypassBghitappLinkHandling(rawHref) {
265
+ const normalizedHref = normalizeAnchorHref(rawHref).toLowerCase();
266
+ if (!normalizedHref) {
267
+ return false;
268
+ }
269
+
270
+ return (
271
+ normalizedHref.startsWith("javascript:") || normalizedHref.startsWith("#")
272
+ );
273
+ }
274
+
275
+ document.addEventListener("DOMContentLoaded", () => {
276
+ const tauri = window.__TAURI__;
277
+ const appWindow = tauri.window.getCurrentWindow();
278
+ const invoke = tauri.core.invoke;
279
+ const bghitappConfig = window["bghitappConfig"] || {};
280
+ const forceInternalNavigation = bghitappConfig.force_internal_navigation === true;
281
+ const internalUrlRegex = bghitappConfig.internal_url_regex || "";
282
+ let internalUrlPattern = null;
283
+ if (internalUrlRegex) {
284
+ try {
285
+ internalUrlPattern = new RegExp(internalUrlRegex);
286
+ } catch (e) {
287
+ console.error("[BghitApp] Invalid internal_url_regex pattern:", e);
288
+ }
289
+ }
290
+
291
+ if (!document.getElementById("bghitapp-top-dom")) {
292
+ const topDom = document.createElement("div");
293
+ topDom.id = "bghitapp-top-dom";
294
+ document.body.appendChild(topDom);
295
+ }
296
+
297
+ const domEl = document.getElementById("bghitapp-top-dom");
298
+
299
+ domEl.addEventListener("touchstart", () => {
300
+ appWindow.startDragging();
301
+ });
302
+
303
+ domEl.addEventListener("mousedown", (e) => {
304
+ e.preventDefault();
305
+ if (e.buttons === 1 && e.detail !== 2) {
306
+ appWindow.startDragging();
307
+ }
308
+ });
309
+
310
+ domEl.addEventListener("dblclick", () => {
311
+ appWindow.isFullscreen().then((fullscreen) => {
312
+ appWindow.setFullscreen(!fullscreen);
313
+ });
314
+ });
315
+
316
+ if (window["bghitappConfig"]?.disabled_web_shortcuts !== true) {
317
+ document.addEventListener("keyup", (event) => {
318
+ if (/windows|linux/i.test(navigator.userAgent) && event.ctrlKey) {
319
+ handleShortcut(event);
320
+ }
321
+ if (/macintosh|mac os x/i.test(navigator.userAgent) && event.metaKey) {
322
+ handleShortcut(event);
323
+ }
324
+ });
325
+ }
326
+
327
+ document.addEventListener(
328
+ "paste",
329
+ (event) => {
330
+ if (pasteAsPlainTextPending) {
331
+ event.preventDefault();
332
+ event.stopImmediatePropagation();
333
+
334
+ const text = event.clipboardData?.getData("text/plain") || "";
335
+ if (text) {
336
+ document.execCommand("insertText", false, text);
337
+ }
338
+ }
339
+ },
340
+ true,
341
+ );
342
+
343
+ // Trigger a native browser download via a transient anchor click. The Rust
344
+ // on_download handler then writes the file to the Downloads folder. This is
345
+ // used for blob:/data: URLs because routing their bytes through the Tauri
346
+ // IPC fails on strict-CSP sites (e.g. Gemini), whose connect-src blocks the
347
+ // IPC origin. The native download path is independent of the page CSP.
348
+ function triggerNativeDownload(url, filename) {
349
+ const anchor = document.createElement("a");
350
+ anchor.href = url;
351
+ anchor.download = filename || "";
352
+ anchor.style.display = "none";
353
+ document.body.appendChild(anchor);
354
+ anchor.click();
355
+ document.body.removeChild(anchor);
356
+ }
357
+
358
+ // process special download protocol['data:','blob:']
359
+ const isSpecialDownload = (url) =>
360
+ ["blob", "data"].some((protocol) => url.startsWith(protocol));
361
+
362
+ const isDownloadRequired = (url, anchorElement, e) =>
363
+ anchorElement.download || e.metaKey || e.ctrlKey || isDownloadableFile(url);
364
+
365
+ const handleExternalLink = (url) => {
366
+ // Don't try to open blob: or data: URLs with shell
367
+ if (isSpecialDownload(url)) {
368
+ console.warn("Cannot open special URL with shell:", url);
369
+ return;
370
+ }
371
+
372
+ invoke("plugin:shell|open", {
373
+ path: url,
374
+ }).catch((error) => {
375
+ console.error("Failed to open URL with shell:", url, error);
376
+ });
377
+ };
378
+
379
+ // Check if URL belongs to the same domain (including subdomains)
380
+ const isSameDomain = (url) => {
381
+ try {
382
+ const linkUrl = new URL(url);
383
+ const currentUrl = new URL(window.location.href);
384
+
385
+ if (linkUrl.hostname === currentUrl.hostname) return true;
386
+
387
+ // Extract root domain (e.g., bilibili.com from www.bilibili.com)
388
+ const getRootDomain = (hostname) => {
389
+ const parts = hostname.split(".");
390
+ return parts.length >= 2 ? parts.slice(-2).join(".") : hostname;
391
+ };
392
+
393
+ return (
394
+ getRootDomain(currentUrl.hostname) === getRootDomain(linkUrl.hostname)
395
+ );
396
+ } catch (e) {
397
+ return false;
398
+ }
399
+ };
400
+
401
+ // Check if URL should be treated as internal based on regex pattern or domain
402
+ const isInternalUrl = (url) => {
403
+ // If regex pattern is configured, use it as the primary check
404
+ if (internalUrlPattern) {
405
+ try {
406
+ return internalUrlPattern.test(url);
407
+ } catch (e) {
408
+ console.error("[BghitApp] Error testing internal_url_regex:", e);
409
+ // Fall back to domain check on error
410
+ return isSameDomain(url);
411
+ }
412
+ }
413
+ // Default to domain-based check
414
+ return isSameDomain(url);
415
+ };
416
+
417
+ const detectAnchorElementClick = (e) => {
418
+ // Safety check: ensure e.target exists and is an Element with closest method
419
+ if (!e.target || typeof e.target.closest !== "function") {
420
+ return;
421
+ }
422
+ const anchorElement = e.target.closest("a");
423
+
424
+ if (anchorElement && anchorElement.href) {
425
+ const rawHref = anchorElement.getAttribute("href") || "";
426
+ if (shouldBypassBghitappLinkHandling(rawHref)) {
427
+ return;
428
+ }
429
+
430
+ const target = anchorElement.target;
431
+ const hrefUrl = new URL(anchorElement.href);
432
+ const absoluteUrl = hrefUrl.href;
433
+ let filename = anchorElement.download || getFilenameFromUrl(absoluteUrl);
434
+
435
+ // Keep OAuth/authentication flows inside the app when popup support is enabled.
436
+ if (window.isAuthLink(absoluteUrl)) {
437
+ console.log("[BghitApp] Handling OAuth navigation in-app:", absoluteUrl);
438
+
439
+ if (window.bghitappConfig?.new_window) {
440
+ e.preventDefault();
441
+ e.stopImmediatePropagation();
442
+
443
+ const authWindow = originalWindowOpen.call(
444
+ window,
445
+ absoluteUrl,
446
+ "_blank",
447
+ "width=1200,height=800,scrollbars=yes,resizable=yes",
448
+ );
449
+
450
+ if (!authWindow) {
451
+ window.location.href = absoluteUrl;
452
+ }
453
+ }
454
+
455
+ return;
456
+ }
457
+
458
+ // Handle _blank links: internal links stay in-app, external links open in the system browser
459
+ if (target === "_blank") {
460
+ if (forceInternalNavigation) {
461
+ e.preventDefault();
462
+ e.stopImmediatePropagation();
463
+ window.location.href = absoluteUrl;
464
+ return;
465
+ }
466
+
467
+ if (isInternalUrl(absoluteUrl)) {
468
+ // For internal links (based on regex or domain), let the browser handle it naturally
469
+ return;
470
+ }
471
+
472
+ e.preventDefault();
473
+ e.stopImmediatePropagation();
474
+ handleExternalLink(absoluteUrl);
475
+ return;
476
+ }
477
+
478
+ if (target === "_new") {
479
+ if (forceInternalNavigation) {
480
+ e.preventDefault();
481
+ e.stopImmediatePropagation();
482
+ window.location.href = absoluteUrl;
483
+ return;
484
+ }
485
+
486
+ e.preventDefault();
487
+ handleExternalLink(absoluteUrl);
488
+ return;
489
+ }
490
+
491
+ // Process download links.
492
+ if (isDownloadRequired(absoluteUrl, anchorElement, e)) {
493
+ // Let the browser download blob:/data: URLs natively; the Rust
494
+ // on_download handler saves them to the Downloads folder. Routing them
495
+ // through the IPC fails on strict-CSP sites (e.g. Gemini), whose
496
+ // connect-src blocks the IPC origin, and on downloads triggered from a
497
+ // sandboxed iframe where the IPC can't be reached.
498
+ if (isSpecialDownload(absoluteUrl)) {
499
+ return;
500
+ }
501
+ e.preventDefault();
502
+ e.stopImmediatePropagation();
503
+ const userLanguage = getUserLanguage();
504
+ invoke("download_file", {
505
+ params: { url: absoluteUrl, filename, language: userLanguage },
506
+ });
507
+ return;
508
+ }
509
+
510
+ // Handle regular links: internal URLs allow normal navigation, external links open in the system browser
511
+ if (!target || target === "_self") {
512
+ // Optimization: Allow previewable media to be handled by the app/browser directly
513
+ // This fixes issues where CDN links are treated as external
514
+ if (isPreviewableMedia(absoluteUrl)) {
515
+ return;
516
+ }
517
+
518
+ if (!isInternalUrl(absoluteUrl)) {
519
+ if (forceInternalNavigation) {
520
+ return;
521
+ }
522
+
523
+ e.preventDefault();
524
+ e.stopImmediatePropagation();
525
+ handleExternalLink(absoluteUrl);
526
+ }
527
+ }
528
+ }
529
+ };
530
+
531
+ // Prevent some special websites from executing in advance, before the click event is triggered.
532
+ document.addEventListener("click", detectAnchorElementClick, true);
533
+
534
+ // Rewrite the window.open function.
535
+ const originalWindowOpen = window.open;
536
+ window.open = function (url, name, specs) {
537
+ const normalizedUrl = normalizeAnchorHref(url);
538
+ if (normalizedUrl.startsWith("#")) {
539
+ window.location.href = new URL(normalizedUrl, window.location.href).href;
540
+ return window;
541
+ }
542
+
543
+ if (shouldBypassBghitappLinkHandling(url)) {
544
+ return originalWindowOpen.call(window, url, name, specs);
545
+ }
546
+
547
+ // Allow authentication popups to open normally
548
+ if (window.isAuthPopup(url, name)) {
549
+ return originalWindowOpen.call(window, url, name, specs);
550
+ }
551
+
552
+ try {
553
+ const baseUrl = window.location.origin + window.location.pathname;
554
+ const hrefUrl = new URL(url, baseUrl);
555
+ const absoluteUrl = hrefUrl.href;
556
+
557
+ if (!isInternalUrl(absoluteUrl)) {
558
+ if (forceInternalNavigation) {
559
+ return originalWindowOpen.call(window, absoluteUrl, name, specs);
560
+ }
561
+
562
+ handleExternalLink(absoluteUrl);
563
+ return null;
564
+ }
565
+
566
+ return originalWindowOpen.call(window, absoluteUrl, name, specs);
567
+ } catch (error) {
568
+ return originalWindowOpen.call(window, url, name, specs);
569
+ }
570
+ };
571
+
572
+ // Set the default zoom, There are problems with Loop without using try-catch.
573
+ try {
574
+ setDefaultZoom();
575
+ } catch (e) {
576
+ console.log(e);
577
+ }
578
+
579
+ // Fix Chinese input method "Enter" on Safari
580
+ document.addEventListener(
581
+ "keydown",
582
+ (e) => {
583
+ if (e.key === "Process") e.stopPropagation();
584
+ },
585
+ true,
586
+ );
587
+
588
+ // Language detection and texts
589
+ const isChinese = isChineseLanguage();
590
+
591
+ const menuTexts = {
592
+ // Media operations
593
+ downloadImage: isChinese ? "下载图片" : "Download Image",
594
+ downloadVideo: isChinese ? "下载视频" : "Download Video",
595
+ downloadFile: isChinese ? "下载文件" : "Download File",
596
+ copyAddress: isChinese ? "复制地址" : "Copy Address",
597
+ openInBrowser: isChinese ? "浏览器打开" : "Open in Browser",
598
+ };
599
+
600
+ // Menu theme configuration
601
+ const MENU_THEMES = {
602
+ dark: {
603
+ menu: {
604
+ background: "#2d2d2d",
605
+ border: "1px solid #404040",
606
+ color: "#ffffff",
607
+ shadow: "0 4px 16px rgba(0, 0, 0, 0.4)",
608
+ },
609
+ item: {
610
+ divider: "#404040",
611
+ hoverBg: "#404040",
612
+ },
613
+ },
614
+ light: {
615
+ menu: {
616
+ background: "#ffffff",
617
+ border: "1px solid #e0e0e0",
618
+ color: "#333333",
619
+ shadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
620
+ },
621
+ item: {
622
+ divider: "#f0f0f0",
623
+ hoverBg: "#d0d0d0",
624
+ },
625
+ },
626
+ };
627
+
628
+ // Theme detection and menu styles
629
+ function getTheme() {
630
+ const prefersDark = window.matchMedia(
631
+ "(prefers-color-scheme: dark)",
632
+ ).matches;
633
+ return prefersDark ? "dark" : "light";
634
+ }
635
+
636
+ function getMenuStyles(theme = getTheme()) {
637
+ return MENU_THEMES[theme] || MENU_THEMES.light;
638
+ }
639
+
640
+ // Menu configuration constants
641
+ const MENU_CONFIG = {
642
+ id: "bghitapp-context-menu",
643
+ minWidth: "120px", // Compact width for better UX
644
+ borderRadius: "6px", // Slightly more rounded for modern look
645
+ fontSize: "13px",
646
+ zIndex: "999999",
647
+ fontFamily:
648
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
649
+ // Menu item dimensions
650
+ itemPadding: "8px 16px", // Increased vertical padding for better comfort
651
+ itemLineHeight: "1.2",
652
+ itemBorderRadius: "3px", // Subtle rounded corners for menu items
653
+ itemTransition: "background-color 0.1s ease",
654
+ };
655
+
656
+ // Create custom context menu
657
+ function createContextMenu() {
658
+ const contextMenu = document.createElement("div");
659
+ contextMenu.id = MENU_CONFIG.id;
660
+ const styles = getMenuStyles();
661
+
662
+ contextMenu.style.cssText = `
663
+ position: fixed;
664
+ background: ${styles.menu.background};
665
+ border: ${styles.menu.border};
666
+ border-radius: ${MENU_CONFIG.borderRadius};
667
+ box-shadow: ${styles.menu.shadow};
668
+ padding: 4px 0;
669
+ min-width: ${MENU_CONFIG.minWidth};
670
+ font-family: ${MENU_CONFIG.fontFamily};
671
+ font-size: ${MENU_CONFIG.fontSize};
672
+ color: ${styles.menu.color};
673
+ z-index: ${MENU_CONFIG.zIndex};
674
+ display: none;
675
+ user-select: none;
676
+ `;
677
+ document.body.appendChild(contextMenu);
678
+ return contextMenu;
679
+ }
680
+
681
+ function createMenuItem(text, onClick, divider = false) {
682
+ const item = document.createElement("div");
683
+ const styles = getMenuStyles();
684
+
685
+ item.style.cssText = `
686
+ padding: ${MENU_CONFIG.itemPadding};
687
+ cursor: pointer;
688
+ user-select: none;
689
+ font-weight: 400;
690
+ line-height: ${MENU_CONFIG.itemLineHeight};
691
+ transition: ${MENU_CONFIG.itemTransition};
692
+ white-space: nowrap;
693
+ border-radius: ${MENU_CONFIG.itemBorderRadius};
694
+ margin: 2px 4px;
695
+ border-bottom: ${divider ? `1px solid ${styles.item.divider}` : "none"};
696
+ `;
697
+ item.textContent = text;
698
+
699
+ item.addEventListener("mouseenter", () => {
700
+ item.style.backgroundColor = styles.item.hoverBg;
701
+ });
702
+
703
+ item.addEventListener("mouseleave", () => {
704
+ item.style.backgroundColor = "transparent";
705
+ });
706
+
707
+ item.addEventListener("click", (e) => {
708
+ e.preventDefault();
709
+ e.stopPropagation();
710
+ onClick();
711
+ hideContextMenu();
712
+ });
713
+
714
+ return item;
715
+ }
716
+
717
+ function showContextMenu(x, y, items) {
718
+ let contextMenu = document.getElementById(MENU_CONFIG.id);
719
+
720
+ // Always recreate menu to ensure theme is up-to-date
721
+ if (contextMenu) {
722
+ contextMenu.remove();
723
+ }
724
+ contextMenu = createContextMenu();
725
+
726
+ items.forEach((item) => {
727
+ contextMenu.appendChild(item);
728
+ });
729
+
730
+ contextMenu.style.left = x + "px";
731
+ contextMenu.style.top = y + "px";
732
+ contextMenu.style.display = "block";
733
+
734
+ // Adjust position if menu goes off screen
735
+ const rect = contextMenu.getBoundingClientRect();
736
+ if (rect.right > window.innerWidth) {
737
+ contextMenu.style.left = x - rect.width + "px";
738
+ }
739
+ if (rect.bottom > window.innerHeight) {
740
+ contextMenu.style.top = y - rect.height + "px";
741
+ }
742
+ }
743
+
744
+ function hideContextMenu() {
745
+ const contextMenu = document.getElementById(MENU_CONFIG.id);
746
+ if (contextMenu) {
747
+ contextMenu.style.display = "none";
748
+ }
749
+ }
750
+
751
+ function downloadImage(imageUrl) {
752
+ // Convert relative URLs to absolute
753
+ if (imageUrl.startsWith("/")) {
754
+ imageUrl = window.location.origin + imageUrl;
755
+ } else if (imageUrl.startsWith("./")) {
756
+ imageUrl = new URL(imageUrl, window.location.href).href;
757
+ } else if (
758
+ !imageUrl.startsWith("http") &&
759
+ !imageUrl.startsWith("data:") &&
760
+ !imageUrl.startsWith("blob:")
761
+ ) {
762
+ imageUrl = new URL(imageUrl, window.location.href).href;
763
+ }
764
+
765
+ // Generate filename from URL
766
+ const filename = getFilenameFromUrl(imageUrl) || "image";
767
+
768
+ // Handle different URL types
769
+ if (isSpecialDownload(imageUrl)) {
770
+ // Download blob:/data: natively so it works under strict CSP; the Rust
771
+ // on_download handler saves it to the Downloads folder.
772
+ triggerNativeDownload(imageUrl, filename);
773
+ } else {
774
+ // Regular HTTP(S) image
775
+ const userLanguage = getUserLanguage();
776
+ invoke("download_file", {
777
+ params: {
778
+ url: imageUrl,
779
+ filename: filename,
780
+ language: userLanguage,
781
+ },
782
+ }).catch((error) => {
783
+ console.error("Failed to download image:", filename, error);
784
+ showDownloadError(filename);
785
+ });
786
+ }
787
+ }
788
+
789
+ // Check if element is media (image or video)
790
+ function getMediaInfo(target) {
791
+ // Check for img tags
792
+ if (target.tagName.toLowerCase() === "img") {
793
+ return { isMedia: true, url: target.src, type: "image" };
794
+ }
795
+
796
+ // Check for video tags
797
+ if (target.tagName.toLowerCase() === "video") {
798
+ return {
799
+ isMedia: true,
800
+ url: target.src || target.currentSrc,
801
+ type: "video",
802
+ };
803
+ }
804
+
805
+ // Check for elements with background images
806
+ if (target.style && target.style.backgroundImage) {
807
+ const bgImage = target.style.backgroundImage;
808
+ const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/);
809
+ if (urlMatch) {
810
+ return { isMedia: true, url: urlMatch[1], type: "image" };
811
+ }
812
+ }
813
+
814
+ // Check for parent elements with background images
815
+ const parentWithBg =
816
+ target && typeof target.closest === "function"
817
+ ? target.closest('[style*="background-image"]')
818
+ : null;
819
+ if (parentWithBg) {
820
+ const bgImage = parentWithBg.style.backgroundImage;
821
+ const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/);
822
+ if (urlMatch) {
823
+ return { isMedia: true, url: urlMatch[1], type: "image" };
824
+ }
825
+ }
826
+
827
+ return { isMedia: false, url: "", type: "" };
828
+ }
829
+
830
+ // Simplified menu builder
831
+ function buildMenuItems(type, data) {
832
+ const userLanguage = getUserLanguage();
833
+ const items = [];
834
+
835
+ switch (type) {
836
+ case "media":
837
+ const downloadText =
838
+ data.type === "image"
839
+ ? menuTexts.downloadImage
840
+ : menuTexts.downloadVideo;
841
+ items.push(
842
+ createMenuItem(downloadText, () => downloadImage(data.url)),
843
+ createMenuItem(menuTexts.copyAddress, () =>
844
+ navigator.clipboard.writeText(data.url),
845
+ ),
846
+ createMenuItem(menuTexts.openInBrowser, () =>
847
+ invoke("plugin:shell|open", { path: data.url }),
848
+ ),
849
+ );
850
+ break;
851
+
852
+ case "link":
853
+ if (data.isFile) {
854
+ items.push(
855
+ createMenuItem(menuTexts.downloadFile, () => {
856
+ const filename = getFilenameFromUrl(data.url);
857
+ invoke("download_file", {
858
+ params: { url: data.url, filename, language: userLanguage },
859
+ }).catch((error) => {
860
+ console.error("Failed to download file:", filename, error);
861
+ showDownloadError(filename);
862
+ });
863
+ }),
864
+ );
865
+ }
866
+ items.push(
867
+ createMenuItem(menuTexts.copyAddress, () =>
868
+ navigator.clipboard.writeText(data.url),
869
+ ),
870
+ createMenuItem(menuTexts.openInBrowser, () =>
871
+ invoke("plugin:shell|open", { path: data.url }),
872
+ ),
873
+ );
874
+ break;
875
+ }
876
+
877
+ return items;
878
+ }
879
+
880
+ // Handle right-click context menu
881
+ document.addEventListener(
882
+ "contextmenu",
883
+ function (event) {
884
+ const target = event.target;
885
+
886
+ // Check for media elements (images/videos)
887
+ const mediaInfo = getMediaInfo(target);
888
+
889
+ // Check for links (but not if it's media)
890
+ const linkElement =
891
+ target && typeof target.closest === "function"
892
+ ? target.closest("a")
893
+ : null;
894
+ const isLink = linkElement && linkElement.href && !mediaInfo.isMedia;
895
+
896
+ // Only show custom menu for media or links
897
+ if (mediaInfo.isMedia || isLink) {
898
+ event.preventDefault();
899
+ event.stopPropagation();
900
+
901
+ let menuItems = [];
902
+
903
+ if (mediaInfo.isMedia) {
904
+ menuItems = buildMenuItems("media", mediaInfo);
905
+ } else if (isLink) {
906
+ const linkUrl = linkElement.href;
907
+ menuItems = buildMenuItems("link", {
908
+ url: linkUrl,
909
+ isFile: isDownloadableFile(linkUrl),
910
+ });
911
+ }
912
+
913
+ showContextMenu(event.clientX, event.clientY, menuItems);
914
+ }
915
+ // For all other elements, let browser's default context menu handle it
916
+ },
917
+ true,
918
+ );
919
+
920
+ // Hide context menu when clicking elsewhere
921
+ document.addEventListener("click", hideContextMenu);
922
+ document.addEventListener("keydown", (e) => {
923
+ if (e.key === "Escape") {
924
+ hideContextMenu();
925
+ }
926
+ });
927
+ });
928
+
929
+ // Bridge the Web Notification + Web Badging APIs to BghitApp's Rust commands so
930
+ // pages running inside the webview can drive the macOS dock badge (and
931
+ // taskbar badge on Linux/Windows). Installs synchronously instead of waiting
932
+ // for DOMContentLoaded so feature-detection on Notification/setAppBadge
933
+ // returns the polyfill before site scripts run.
934
+ (function () {
935
+ const invoke = window.__TAURI__?.core?.invoke;
936
+ if (!invoke) return;
937
+
938
+ let permVal = "granted";
939
+ let lastNotifTime = 0;
940
+ let lastNotif = null;
941
+ // Pages that drive the badge directly via setAppBadge own its lifecycle;
942
+ // notifications-driven counts auto-clear on the next user interaction.
943
+ let pageManagedBadge = false;
944
+ let autoBadgeActive = false;
945
+
946
+ const normalizeBadgeCount = (count) => {
947
+ if (typeof count !== "number" || !Number.isFinite(count)) {
948
+ throw new TypeError("Badge count must be a finite number.");
949
+ }
950
+ const normalized = Math.floor(count);
951
+ return normalized > 0 ? Math.min(normalized, 99999) : null;
952
+ };
953
+ const setBadge = (count) => {
954
+ pageManagedBadge = true;
955
+ autoBadgeActive = false;
956
+ return invoke("set_dock_badge", { count }).catch(() => {});
957
+ };
958
+ const clearBadge = () => invoke("clear_dock_badge").catch(() => {});
959
+ const setLabel = (label) => {
960
+ pageManagedBadge = true;
961
+ autoBadgeActive = false;
962
+ return invoke("set_dock_badge_label", { label }).catch(() => {});
963
+ };
964
+ const incrementAutoBadge = () => {
965
+ if (pageManagedBadge) return Promise.resolve();
966
+ autoBadgeActive = true;
967
+ return invoke("increment_dock_badge").catch(() => {});
968
+ };
969
+
970
+ window.addEventListener("focus", () => {
971
+ if (lastNotif?.onclick && Date.now() - lastNotifTime < 5000) {
972
+ lastNotif.onclick(new Event("click"));
973
+ lastNotif = null;
974
+ }
975
+ });
976
+
977
+ const clearAutoBadge = () => {
978
+ if (pageManagedBadge || !autoBadgeActive) return;
979
+ autoBadgeActive = false;
980
+ clearBadge();
981
+ };
982
+ document.addEventListener("click", clearAutoBadge, true);
983
+ document.addEventListener("keydown", clearAutoBadge, true);
984
+
985
+ const wrappedNotification = function (title, options) {
986
+ const body = options?.body || "";
987
+ let icon = options?.icon || "";
988
+ if (icon.startsWith("/")) {
989
+ icon = window.location.origin + icon;
990
+ }
991
+
992
+ const notif = {
993
+ onclick: null,
994
+ onclose: null,
995
+ onshow: null,
996
+ onerror: null,
997
+ close: () => {},
998
+ };
999
+
1000
+ lastNotifTime = Date.now();
1001
+ lastNotif = notif;
1002
+ invoke("send_notification", { params: { title, body, icon } })
1003
+ .then(() => incrementAutoBadge())
1004
+ .then(() => {
1005
+ if (notif.onshow) notif.onshow(new Event("show"));
1006
+ });
1007
+
1008
+ return notif;
1009
+ };
1010
+
1011
+ wrappedNotification.requestPermission = async () => "granted";
1012
+ Object.defineProperty(wrappedNotification, "permission", {
1013
+ enumerable: true,
1014
+ get: () => permVal,
1015
+ set: (v) => {
1016
+ permVal = v;
1017
+ },
1018
+ });
1019
+
1020
+ try {
1021
+ Object.defineProperty(window, "Notification", {
1022
+ configurable: true,
1023
+ writable: true,
1024
+ value: wrappedNotification,
1025
+ });
1026
+ } catch (_) {}
1027
+
1028
+ // Web Badging API: https://wicg.github.io/badging/
1029
+ // setAppBadge() with no argument shows an indicator dot; with a number,
1030
+ // shows the count (0 clears). clearAppBadge() removes the badge entirely.
1031
+ const setAppBadge = (count) => {
1032
+ if (count === undefined) return setLabel("•");
1033
+ let normalized;
1034
+ try {
1035
+ normalized = normalizeBadgeCount(count);
1036
+ } catch (error) {
1037
+ return Promise.reject(error);
1038
+ }
1039
+ if (normalized === null) {
1040
+ pageManagedBadge = false;
1041
+ autoBadgeActive = false;
1042
+ return clearBadge();
1043
+ }
1044
+ return setBadge(normalized);
1045
+ };
1046
+ const clearAppBadge = () => {
1047
+ pageManagedBadge = false;
1048
+ autoBadgeActive = false;
1049
+ return clearBadge();
1050
+ };
1051
+ try {
1052
+ Object.defineProperty(navigator, "setAppBadge", {
1053
+ configurable: true,
1054
+ writable: true,
1055
+ value: setAppBadge,
1056
+ });
1057
+ Object.defineProperty(navigator, "clearAppBadge", {
1058
+ configurable: true,
1059
+ writable: true,
1060
+ value: clearAppBadge,
1061
+ });
1062
+ } catch (_) {}
1063
+ })();
1064
+
1065
+ function setDefaultZoom() {
1066
+ const htmlZoom = window.localStorage.getItem("htmlZoom");
1067
+ if (htmlZoom) {
1068
+ setZoom(htmlZoom);
1069
+ } else if (window.bghitappConfig?.zoom && window.bghitappConfig.zoom !== 100) {
1070
+ setZoom(`${window.bghitappConfig.zoom}%`);
1071
+ }
1072
+ }
1073
+
1074
+ function getFilenameFromUrl(url) {
1075
+ try {
1076
+ const urlPath = new URL(url).pathname;
1077
+ let filename = urlPath.substring(urlPath.lastIndexOf("/") + 1);
1078
+
1079
+ // If no filename or no extension, generate one
1080
+ if (!filename || !filename.includes(".")) {
1081
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1082
+
1083
+ // Detect image type from URL or data URI
1084
+ if (url.startsWith("data:image/")) {
1085
+ const mimeType = url.substring(11, url.indexOf(";"));
1086
+ filename = `image-${timestamp}.${mimeType}`;
1087
+ } else {
1088
+ // Default to common image extensions based on common patterns
1089
+ if (url.includes("jpg") || url.includes("jpeg")) {
1090
+ filename = `image-${timestamp}.jpg`;
1091
+ } else if (url.includes("png")) {
1092
+ filename = `image-${timestamp}.png`;
1093
+ } else if (url.includes("gif")) {
1094
+ filename = `image-${timestamp}.gif`;
1095
+ } else if (url.includes("webp")) {
1096
+ filename = `image-${timestamp}.webp`;
1097
+ } else if (url.includes("svg")) {
1098
+ filename = `image-${timestamp}.svg`;
1099
+ } else {
1100
+ filename = `image-${timestamp}.png`; // default
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ return filename;
1106
+ } catch (e) {
1107
+ // Fallback for invalid URLs
1108
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1109
+ return `image-${timestamp}.png`;
1110
+ }
1111
+ }