Last active
January 30, 2026 13:29
-
-
Save morawskidotmy/d923f024cb351328115c6db2b6caf624 to your computer and use it in GitHub Desktop.
hayase.watch build and patch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Hayase Build Script | |
| Clones the open-source Hayase repos and builds binaries for Linux and/or Windows. | |
| Includes optional patches for custom theming, telemetry removal, and local UI bundling. | |
| Usage: | |
| python build.py # Interactive mode - prompts for patches and target OS | |
| python build.py --defaults # Use all default patches, auto-detect target OS | |
| python build.py --build # Skip build confirmation prompt | |
| python build.py --no-build # Prepare only, don't build | |
| python build.py clean # Remove cloned repos and dist folder | |
| """ | |
| import os | |
| import subprocess | |
| import shutil | |
| import sys | |
| import re | |
| import platform | |
| SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| # Detect current OS | |
| def detect_os(): | |
| system = platform.system().lower() | |
| if system == "windows": | |
| return "windows" | |
| elif system == "darwin": | |
| return "macos" | |
| else: | |
| return "linux" | |
| CURRENT_OS = detect_os() | |
| ELECTRON_DIR = os.path.join(SCRIPT_DIR, "electron") | |
| INTERFACE_DIR = os.path.join(SCRIPT_DIR, "interface") | |
| REPOS = { | |
| "electron": "https://github.com/hayase-app/electron.git", | |
| "interface": "https://github.com/hayase-app/interface.git", | |
| } | |
| # Custom theme color (change this to your preferred color) | |
| THEME_BG_COLOR = "#353535" | |
| # Available patches with descriptions | |
| AVAILABLE_PATCHES = { | |
| "discord": { | |
| "name": "Disable Discord Rich Presence", | |
| "description": "Removes Discord RPC integration for privacy", | |
| "default": True | |
| }, | |
| "updater": { | |
| "name": "Disable Auto-Updater", | |
| "description": "Prevents phone home to api.hayase.watch", | |
| "default": True | |
| }, | |
| "terms": { | |
| "name": "Remove Terms/Privacy Agreement", | |
| "description": "Skips the Terms of Service checkbox on setup", | |
| "default": True | |
| }, | |
| "theme": { | |
| "name": "Apply Custom Theme", | |
| "description": f"Changes background color to {THEME_BG_COLOR}", | |
| "default": True | |
| }, | |
| "parallel_search": { | |
| "name": "Parallel Torrent Search", | |
| "description": "Query all torrent extensions in parallel for faster results", | |
| "default": True | |
| }, | |
| "block_hayase_watch": { | |
| "name": "Block hayase.watch", | |
| "description": "Firewall patch to block all requests to hayase.watch domains", | |
| "default": True | |
| } | |
| } | |
| # Store selected patches | |
| SELECTED_PATCHES = {} | |
| def ask_yes_no(prompt, default=True): | |
| """Ask a yes/no question with a default value.""" | |
| default_str = "Y/n" if default else "y/N" | |
| try: | |
| response = input(f"{prompt} [{default_str}]: ").strip().lower() | |
| if response == "": | |
| return default | |
| return response in ("y", "yes") | |
| except (EOFError, KeyboardInterrupt): | |
| print() | |
| return default | |
| def select_patches(): | |
| """Interactive patch selection menu.""" | |
| global SELECTED_PATCHES | |
| print("\n" + "=" * 50) | |
| print(" Patch Selection") | |
| print("=" * 50) | |
| print("\nSelect which patches to apply:\n") | |
| for patch_id, patch_info in AVAILABLE_PATCHES.items(): | |
| enabled = ask_yes_no( | |
| f" {patch_info['name']}?\n ({patch_info['description']})", | |
| patch_info['default'] | |
| ) | |
| SELECTED_PATCHES[patch_id] = enabled | |
| status = "✓ Enabled" if enabled else "✗ Disabled" | |
| print(f" → {status}\n") | |
| print("\nSelected patches:") | |
| for patch_id, enabled in SELECTED_PATCHES.items(): | |
| status = "✓" if enabled else "✗" | |
| print(f" {status} {AVAILABLE_PATCHES[patch_id]['name']}") | |
| print() | |
| def select_target_os(): | |
| """Select which OS to build for.""" | |
| print("\n" + "=" * 50) | |
| print(" Target Platform Selection") | |
| print("=" * 50) | |
| print(f"\nDetected OS: {CURRENT_OS}") | |
| print("\nAvailable targets:") | |
| print(" 1. Linux (deb)") | |
| print(" 2. Windows (exe/nsis)") | |
| print(" 3. Both Linux and Windows") | |
| try: | |
| choice = input("\nSelect target [1-3] (default: current OS): ").strip() | |
| if choice == "1": | |
| return ["linux"] | |
| elif choice == "2": | |
| return ["windows"] | |
| elif choice == "3": | |
| return ["linux", "windows"] | |
| else: | |
| # Default to current OS | |
| if CURRENT_OS == "windows": | |
| return ["windows"] | |
| else: | |
| return ["linux"] | |
| except (EOFError, KeyboardInterrupt): | |
| print() | |
| return ["linux"] if CURRENT_OS != "windows" else ["windows"] | |
| def run(cmd, cwd=None, check=True): | |
| print(f" → {cmd}") | |
| result = subprocess.run(cmd, shell=True, cwd=cwd) | |
| if check and result.returncode != 0: | |
| print(f"Command failed with exit code {result.returncode}") | |
| sys.exit(1) | |
| return result.returncode | |
| def check_dependencies(): | |
| print("\n[1/7] Checking dependencies...") | |
| missing = [] | |
| for cmd in ["git", "node", "pnpm"]: | |
| if shutil.which(cmd) is None: | |
| missing.append(cmd) | |
| if missing: | |
| print(f"Missing dependencies: {', '.join(missing)}") | |
| if "pnpm" in missing: | |
| print("Installing pnpm...") | |
| run("npm install -g pnpm@9.14.4") | |
| if "node" in missing or "git" in missing: | |
| print("Please install node and git first.") | |
| sys.exit(1) | |
| print(" ✓ All dependencies found") | |
| def clone_repos(): | |
| print("\n[2/7] Cloning repositories...") | |
| for name, url in REPOS.items(): | |
| target = os.path.join(SCRIPT_DIR, name) | |
| if os.path.exists(target): | |
| print(f" ✓ {name} already exists, skipping clone") | |
| else: | |
| run(f"git clone --depth 1 {url} {name}", cwd=SCRIPT_DIR) | |
| # Create empty electron-dist to avoid submodule issues | |
| electron_dist = os.path.join(ELECTRON_DIR, "electron-dist") | |
| if not os.path.exists(electron_dist): | |
| os.makedirs(electron_dist) | |
| def hex_to_hsl(hex_color): | |
| """Convert hex color to HSL values for CSS.""" | |
| hex_color = hex_color.lstrip('#') | |
| r, g, b = tuple(int(hex_color[i:i+2], 16) / 255.0 for i in (0, 2, 4)) | |
| max_c = max(r, g, b) | |
| min_c = min(r, g, b) | |
| l = (max_c + min_c) / 2.0 | |
| if max_c == min_c: | |
| h = s = 0.0 | |
| else: | |
| d = max_c - min_c | |
| s = d / (2.0 - max_c - min_c) if l > 0.5 else d / (max_c + min_c) | |
| if max_c == r: | |
| h = (g - b) / d + (6.0 if g < b else 0.0) | |
| elif max_c == g: | |
| h = (b - r) / d + 2.0 | |
| else: | |
| h = (r - g) / d + 4.0 | |
| h /= 6.0 | |
| return f"{h * 360:.0f} {s * 100:.1f}% {l * 100:.1f}%" | |
| def apply_build_patches(target_platforms): | |
| print("\n[3/8] Applying build patches...") | |
| # Patch electron-builder.yml - replace linux section entirely | |
| builder_yml = os.path.join(ELECTRON_DIR, "electron-builder.yml") | |
| if os.path.exists(builder_yml): | |
| with open(builder_yml, "r") as f: | |
| content = f.read() | |
| # Remove electronDist line | |
| content = re.sub(r'^electronDist:.*\n', '', content, flags=re.MULTILINE) | |
| # Remove the exclusion of resources folder so our UI gets included | |
| content = content.replace(' - "!resources/*"\n', '') | |
| # Remove existing extraResources section if it exists (to avoid duplicates) | |
| extra_resources_pattern = r'extraResources:\n( .*\n)*' | |
| content = re.sub(extra_resources_pattern, '', content) | |
| # Remove duplicate resources/ui/** entries from asarUnpack | |
| content = re.sub(r' - resources/ui/\*\*\n', '', content) | |
| content = re.sub(r' - resources/ui/\*\*\n', '', content) # Remove any remaining | |
| # Add resources/ui to asarUnpack so it's accessible as files (only if not present) | |
| if 'resources/ui/**' not in content: | |
| content = content.replace( | |
| 'asarUnpack:\n - resources/**', | |
| 'asarUnpack:\n - resources/**\n - resources/ui/**' | |
| ) | |
| # Add extraResources to copy ui folder outside of asar (before npmRebuild, only if not present) | |
| if 'extraResources:' not in content: | |
| content = content.replace( | |
| 'npmRebuild: true', | |
| '''extraResources: | |
| - from: resources/ui | |
| to: ui | |
| filter: | |
| - "**/*" | |
| - "!**/geoip/**" | |
| npmRebuild: true''' | |
| ) | |
| # Replace entire linux section with deb target | |
| linux_pattern = r'linux:\n( .*\n)*' | |
| linux_replacement = '''linux: | |
| artifactName: hayase-${version}.${ext} | |
| icon: build/icon.icns | |
| target: | |
| - target: deb | |
| arch: | |
| - x64 | |
| maintainer: ThaUnknown | |
| category: AudioVideo;Video | |
| ''' | |
| content = re.sub(linux_pattern, linux_replacement, content) | |
| # Add/update Windows section if targeting Windows | |
| if "windows" in target_platforms: | |
| # Remove existing win section if present | |
| win_pattern = r'win:\n( .*\n)*' | |
| content = re.sub(win_pattern, '', content) | |
| # Remove existing nsis section if present | |
| nsis_pattern = r'nsis:\n( .*\n)*' | |
| content = re.sub(nsis_pattern, '', content) | |
| # Add Windows configuration after linux section | |
| win_config = '''win: | |
| artifactName: hayase-${version}.${ext} | |
| icon: build/icon.ico | |
| target: | |
| - target: nsis | |
| arch: | |
| - x64 | |
| nsis: | |
| oneClick: false | |
| allowToChangeInstallationDirectory: true | |
| installerIcon: build/icon.ico | |
| uninstallerIcon: build/icon.ico | |
| installerHeaderIcon: build/icon.ico | |
| createDesktopShortcut: true | |
| createStartMenuShortcut: true | |
| shortcutName: Hayase | |
| ''' | |
| # Insert after linux section | |
| content = content.replace( | |
| ' category: AudioVideo;Video\n', | |
| ' category: AudioVideo;Video\n' + win_config | |
| ) | |
| print(" ✓ Added Windows build configuration") | |
| with open(builder_yml, "w") as f: | |
| f.write(content) | |
| print(" ✓ Patched electron-builder.yml") | |
| # Patch electron.vite.config.ts to disable bytecode compilation | |
| vite_config = os.path.join(ELECTRON_DIR, "electron.vite.config.ts") | |
| if os.path.exists(vite_config): | |
| with open(vite_config, "r") as f: | |
| content = f.read() | |
| content = content.replace( | |
| "bytecode: process.platform !== 'darwin' && { transformArrowFunctions: false }", | |
| "bytecode: false // Disabled for open source build" | |
| ) | |
| with open(vite_config, "w") as f: | |
| f.write(content) | |
| print(" ✓ Patched electron.vite.config.ts (disabled bytecode)") | |
| def remove_telemetry(): | |
| print("\n[4/8] Applying selected patches...") | |
| any_applied = False | |
| # 1. Disable Discord Rich Presence | |
| if SELECTED_PATCHES.get("discord", False): | |
| discord_file = os.path.join(ELECTRON_DIR, "src/main/discord.ts") | |
| if os.path.exists(discord_file): | |
| stub = '''// Discord RPC disabled for privacy | |
| import type { SessionMetadata } from 'native' | |
| export default class Discord { | |
| position: MediaPositionState | undefined = undefined | |
| playback: 'none' | 'paused' | 'playing' = 'none' | |
| session: SessionMetadata | undefined = undefined | |
| mediaId: number | undefined = undefined | |
| allowDiscordDetails = false | |
| debouncedDiscordRPC () {} | |
| loginRPC () {} | |
| setDiscordRPC () {} | |
| } | |
| ''' | |
| with open(discord_file, "w") as f: | |
| f.write(stub) | |
| print(" ✓ Disabled Discord Rich Presence") | |
| any_applied = True | |
| else: | |
| print(" ⊘ Skipping Discord Rich Presence patch") | |
| # 2. Disable auto-updater (phones home to api.hayase.watch) | |
| if SELECTED_PATCHES.get("updater", False): | |
| updater_file = os.path.join(ELECTRON_DIR, "src/main/updater.ts") | |
| if os.path.exists(updater_file): | |
| stub = '''// Auto-updater disabled - update manually from source | |
| export default class Updater { | |
| hasUpdate = false | |
| install (forceRunAfter = false) { | |
| return false | |
| } | |
| } | |
| ''' | |
| with open(updater_file, "w") as f: | |
| f.write(stub) | |
| print(" ✓ Disabled auto-updater (no phone home)") | |
| any_applied = True | |
| # Disable update logging in app.ts (idempotent - check if not already disabled) | |
| app_file = os.path.join(ELECTRON_DIR, "src/main/app.ts") | |
| if os.path.exists(app_file): | |
| with open(app_file, "r") as f: | |
| content = f.read() | |
| # Only apply if not already disabled (check for exact original string) | |
| if "autoUpdater.logger = log" in content and "// autoUpdater.logger = log" not in content: | |
| content = content.replace( | |
| "autoUpdater.logger = log", | |
| "// autoUpdater.logger = log // Disabled" | |
| ) | |
| with open(app_file, "w") as f: | |
| f.write(content) | |
| print(" ✓ Disabled update logging") | |
| # Disable autoUpdater in ipc.ts - stub out update methods | |
| ipc_file = os.path.join(ELECTRON_DIR, "src/main/ipc.ts") | |
| if os.path.exists(ipc_file): | |
| with open(ipc_file, "r") as f: | |
| content = f.read() | |
| modified = False | |
| # Comment out the autoUpdater import | |
| if "import { autoUpdater } from 'electron-updater'" in content and "// import { autoUpdater }" not in content: | |
| content = content.replace( | |
| "import { autoUpdater } from 'electron-updater'", | |
| "// import { autoUpdater } from 'electron-updater' // Disabled - auto-updates removed" | |
| ) | |
| modified = True | |
| # Stub updateProgress method | |
| if "autoUpdater.on('download-progress'" in content: | |
| content = re.sub( | |
| r"updateProgress \(\) \{\s*autoUpdater\.on\('download-progress'.*?\n.*?\n.*?\n.*?\n.*?\}", | |
| "updateProgress () {\n // Auto-updates disabled - no-op\n }", | |
| content, | |
| flags=re.DOTALL | |
| ) | |
| modified = True | |
| # Stub checkUpdate method | |
| if "autoUpdater.checkForUpdates()" in content and "checkUpdate ()" in content: | |
| content = re.sub( | |
| r"checkUpdate \(\) \{\s*autoUpdater\.checkForUpdates\(\)\s*\}", | |
| "checkUpdate () {\n // Auto-updates disabled - no-op\n }", | |
| content | |
| ) | |
| modified = True | |
| # Stub updateAndRestart method | |
| if "updateAndRestart ()" in content and "this.app.destroy(true)" in content: | |
| content = re.sub( | |
| r"updateAndRestart \(\) \{\s*this\.app\.destroy\(true\)\s*\}", | |
| "updateAndRestart () {\n // Auto-updates disabled - just restart\n this.app.destroy(false)\n }", | |
| content | |
| ) | |
| modified = True | |
| # Stub updateReady method | |
| if "async updateReady ()" in content and "autoUpdater.checkForUpdates()" in content: | |
| content = re.sub( | |
| r"async updateReady \(\) \{\s*const update = await autoUpdater\.checkForUpdates\(\).*?await update\.downloadPromise\s*\}", | |
| "async updateReady () {\n // Auto-updates disabled - never ready\n throw new Error('Auto-updates disabled in open source build')\n }", | |
| content, | |
| flags=re.DOTALL | |
| ) | |
| modified = True | |
| if modified: | |
| with open(ipc_file, "w") as f: | |
| f.write(content) | |
| print(" ✓ Disabled autoUpdater in ipc.ts") | |
| # Disable update checks in interface update.ts | |
| update_module = os.path.join(INTERFACE_DIR, "src/lib/modules/update.ts") | |
| if os.path.exists(update_module): | |
| stub = '''// Update checks disabled for open source build | |
| // No version comparison to prevent "Update Required" blocking | |
| export const outdatedComponent = Promise.resolve(undefined) | |
| export const uiUpdate = new Promise(resolve => { | |
| if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }) | |
| }) | |
| ''' | |
| with open(update_module, "w") as f: | |
| f.write(stub) | |
| print(" ✓ Disabled update checks in interface") | |
| else: | |
| print(" ⊘ Skipping auto-updater patch") | |
| # 3. Remove Terms/Privacy agreement requirement from setup | |
| if SELECTED_PATCHES.get("terms", False): | |
| setup_page = os.path.join(INTERFACE_DIR, "src/routes/setup/+page.svelte") | |
| if os.path.exists(setup_page): | |
| # Remove the checkbox and terms/privacy links, enable the button directly | |
| new_content = '''<script lang='ts'> | |
| import Logo from '$lib/components/icons/Logo.svelte' | |
| import { Button } from '$lib/components/ui/button' | |
| import { dragScroll } from '$lib/modules/navigate' | |
| </script> | |
| <div class='space-y-3 lg:max-w-4xl h-full overflow-y-auto w-full py-8 flex flex-col items-center justify-center' use:dragScroll> | |
| <Logo class='w-52 h-52 object-contain mb-14 shrink-0' /> | |
| <div class='font-bold text-5xl text-center w-full overflow-x-clip flex justify-center'> | |
| <div class='relative'> | |
| Welcome to Hayase | |
| <div class='animate-[hearbeat_1.5s_ease-in-out_infinite_alternate] absolute text-lg text-theme right-0 -top-5 xs:-right-20 xs:-top-2 rotate-12'>Open Source Build</div> | |
| </div> | |
| </div> | |
| <div class='text-muted-foreground pt-3 text-center px-3'>Let's set up your perfect streaming environment.</div> | |
| <div class='pt-8'> | |
| <Button id='start-setup' class='text-lg font-bold shrink-0' size='lg' href='./storage' data-sveltekit-replacestate>Start Setup</Button> | |
| </div> | |
| </div> | |
| ''' | |
| with open(setup_page, "w") as f: | |
| f.write(new_content) | |
| print(" ✓ Removed Terms/Privacy agreement requirement") | |
| any_applied = True | |
| else: | |
| print(" ⊘ Skipping Terms/Privacy agreement patch") | |
| # 4. Parallel torrent search - query all extensions simultaneously | |
| if SELECTED_PATCHES.get("parallel_search", False): | |
| extensions_file = os.path.join(INTERFACE_DIR, "src/lib/modules/extensions/extensions.ts") | |
| if os.path.exists(extensions_file): | |
| with open(extensions_file, "r") as f: | |
| content = f.read() | |
| # Check if already patched | |
| if "extensionPromises" not in content and "for (const [id, worker] of extensions.entries())" in content: | |
| # Replace sequential loop with parallel execution | |
| old_loop = ''' for (const [id, worker] of extensions.entries()) { | |
| const thisExtOpts = extopts[id]! | |
| if (!thisExtOpts.enabled) continue | |
| if (configs[id]!.type !== 'torrent') continue | |
| try { | |
| const promises: Array<Promise<TorrentResult[]>> = [] | |
| promises.push(worker.single(options, thisExtOpts.options)) | |
| if (checkMovie) promises.push(worker.movie(options, thisExtOpts.options)) | |
| if (checkBatch) promises.push(worker.batch(options, thisExtOpts.options)) | |
| for (const result of await Promise.allSettled(promises)) { | |
| if (result.status === 'fulfilled') { | |
| results.push(...result.value.map(v => ({ ...v, extension: new Set([id]), parseObject: {} as unknown as AnitomyResult }))) | |
| } else { | |
| console.error(result.reason, id) | |
| errors.push({ error: result.reason as unknown as Error, extension: id }) | |
| } | |
| } | |
| } catch (error) { | |
| errors.push({ error: error as Error, extension: id }) | |
| } | |
| }''' | |
| new_loop = ''' // Query all extensions in parallel for faster results | |
| const extensionPromises: Array<Promise<void>> = [] | |
| for (const [id, worker] of extensions.entries()) { | |
| const thisExtOpts = extopts[id]! | |
| if (!thisExtOpts.enabled) continue | |
| if (configs[id]!.type !== 'torrent') continue | |
| const extensionPromise = (async () => { | |
| try { | |
| const promises: Array<Promise<TorrentResult[]>> = [] | |
| promises.push(worker.single(options, thisExtOpts.options)) | |
| if (checkMovie) promises.push(worker.movie(options, thisExtOpts.options)) | |
| if (checkBatch) promises.push(worker.batch(options, thisExtOpts.options)) | |
| for (const result of await Promise.allSettled(promises)) { | |
| if (result.status === 'fulfilled') { | |
| results.push(...result.value.map(v => ({ ...v, extension: new Set([id]), parseObject: {} as unknown as AnitomyResult }))) | |
| } else { | |
| console.error(result.reason, id) | |
| errors.push({ error: result.reason as unknown as Error, extension: id }) | |
| } | |
| } | |
| } catch (error) { | |
| errors.push({ error: error as Error, extension: id }) | |
| } | |
| })() | |
| extensionPromises.push(extensionPromise) | |
| } | |
| // Wait for all extensions to complete in parallel | |
| await Promise.all(extensionPromises)''' | |
| content = content.replace(old_loop, new_loop) | |
| with open(extensions_file, "w") as f: | |
| f.write(content) | |
| print(" ✓ Enabled parallel torrent search") | |
| any_applied = True | |
| else: | |
| print(" ✓ Parallel torrent search already applied") | |
| else: | |
| print(" ⊘ Skipping parallel torrent search patch") | |
| # 5. Block hayase.watch - inbuilt firewall patch | |
| if SELECTED_PATCHES.get("block_hayase_watch", False): | |
| app_ts = os.path.join(ELECTRON_DIR, "src/main/app.ts") | |
| if os.path.exists(app_ts): | |
| with open(app_ts, "r") as f: | |
| content = f.read() | |
| modified = False | |
| # Add request blocking for hayase.watch domains | |
| firewall_code = ''' | |
| // Firewall: Block all requests to hayase.watch domains | |
| session.defaultSession.webRequest.onBeforeRequest({ urls: ['*://*.hayase.watch/*', '*://hayase.watch/*'] }, (details, callback) => { | |
| console.log('[Firewall] Blocked request to:', details.url) | |
| callback({ cancel: true }) | |
| }) | |
| ''' | |
| if "Firewall: Block all requests to hayase.watch" not in content: | |
| # Find the app.whenReady block to insert the firewall | |
| if "app.whenReady().then(async () => {" in content: | |
| content = content.replace( | |
| "app.whenReady().then(async () => {", | |
| "app.whenReady().then(async () => {" + firewall_code | |
| ) | |
| modified = True | |
| elif "electronApp.on('ready'" in content: | |
| # Alternative pattern | |
| ready_pattern = r"(electronApp\.on\('ready'.*?\{)" | |
| match = re.search(ready_pattern, content, re.DOTALL) | |
| if match: | |
| content = content.replace(match.group(0), match.group(0) + firewall_code) | |
| modified = True | |
| if modified: | |
| with open(app_ts, "w") as f: | |
| f.write(content) | |
| print(" ✓ Added firewall to block hayase.watch domains") | |
| any_applied = True | |
| else: | |
| if "Firewall: Block all requests to hayase.watch" in content: | |
| print(" ✓ Firewall for hayase.watch already applied") | |
| else: | |
| print(" ⚠ Could not find app ready block to insert firewall") | |
| else: | |
| print(" ⚠ app.ts not found, skipping firewall patch") | |
| else: | |
| print(" ⊘ Skipping hayase.watch firewall patch") | |
| if not any_applied: | |
| print(" (No telemetry patches selected)") | |
| def apply_theme(): | |
| if not SELECTED_PATCHES.get("theme", False): | |
| print("\n[5/8] Skipping custom theme (not selected)") | |
| return | |
| print(f"\n[5/8] Applying custom theme ({THEME_BG_COLOR})...") | |
| hsl = hex_to_hsl(THEME_BG_COLOR) | |
| app_css = os.path.join(INTERFACE_DIR, "src/app.css") | |
| if os.path.exists(app_css): | |
| with open(app_css, "r") as f: | |
| content = f.read() | |
| # Replace dark mode background color | |
| content = content.replace( | |
| "--background: 240 10% 3.9%;", | |
| f"--background: {hsl};" | |
| ) | |
| content = content.replace( | |
| "--card: 240 10% 3.9%;", | |
| f"--card: {hsl};" | |
| ) | |
| content = content.replace( | |
| "--popover: 240 10% 3.9%;", | |
| f"--popover: {hsl};" | |
| ) | |
| # Update muted/secondary colors | |
| content = content.replace("--muted: 240 3.7% 15.9%;", "--muted: 0 0% 25%;") | |
| content = content.replace("--secondary: 240 3.7% 15.9%;", "--secondary: 0 0% 28%;") | |
| content = content.replace("--accent: 240 3.7% 15.9%;", "--accent: 0 0% 28%;") | |
| content = content.replace("--border: 240 3.7% 15.9%;", "--border: 0 0% 30%;") | |
| content = content.replace("--input: 240 3.7% 15.9%;", "--input: 0 0% 30%;") | |
| # Update hardcoded colors | |
| content = content.replace("#202020", "#3a3a3a") | |
| content = content.replace("#2a2a2a", "#454545") | |
| content = content.replace("#1e1e1e", "#353535") | |
| content = content.replace("#161616", "#2d2d2d") | |
| content = content.replace( | |
| "background: black !important;", | |
| f"background: {THEME_BG_COLOR} !important;" | |
| ) | |
| with open(app_css, "w") as f: | |
| f.write(content) | |
| print(" ✓ Patched app.css with custom colors") | |
| # Patch electron app background color | |
| app_ts = os.path.join(ELECTRON_DIR, "src/main/app.ts") | |
| if os.path.exists(app_ts): | |
| with open(app_ts, "r") as f: | |
| content = f.read() | |
| content = content.replace( | |
| "backgroundColor: '#000000'", | |
| f"backgroundColor: '{THEME_BG_COLOR}'" | |
| ) | |
| with open(app_ts, "w") as f: | |
| f.write(content) | |
| print(" ✓ Patched electron window background color") | |
| # Patch svelte files | |
| for root, dirs, files in os.walk(os.path.join(INTERFACE_DIR, "src")): | |
| dirs[:] = [d for d in dirs if d != "node_modules"] | |
| for fname in files: | |
| if fname.endswith(".svelte"): | |
| fpath = os.path.join(root, fname) | |
| with open(fpath, "r") as f: | |
| content = f.read() | |
| if "neutral-950" in content or "neutral-900" in content: | |
| content = content.replace("bg-neutral-950", "bg-[#353535]") | |
| content = content.replace("bg-neutral-900", "bg-[#404040]") | |
| with open(fpath, "w") as f: | |
| f.write(content) | |
| print(" ✓ Patched Svelte components with custom colors") | |
| def build_interface(): | |
| print("\n[6/8] Building interface (local UI)...") | |
| # Install interface dependencies | |
| run("pnpm install", cwd=INTERFACE_DIR) | |
| # Build the interface as static site | |
| run("pnpm run build", cwd=INTERFACE_DIR) | |
| # Copy built interface to electron resources | |
| interface_build = os.path.join(INTERFACE_DIR, "build") | |
| electron_ui = os.path.join(ELECTRON_DIR, "resources", "ui") | |
| if os.path.exists(electron_ui): | |
| shutil.rmtree(electron_ui) | |
| if os.path.exists(interface_build): | |
| shutil.copytree(interface_build, electron_ui) | |
| print(" ✓ Copied interface build to electron/resources/ui") | |
| # Create protocol handler file (always overwrite to ensure correct version) | |
| protocol_handler = os.path.join(ELECTRON_DIR, "src/main/local-ui-protocol.ts") | |
| handler_code = '''import { protocol } from 'electron' | |
| import { join, extname } from 'node:path' | |
| import { readFileSync, existsSync } from 'node:fs' | |
| const MIME_TYPES: Record<string, string> = { | |
| '.html': 'text/html', | |
| '.js': 'text/javascript', | |
| '.mjs': 'text/javascript', | |
| '.css': 'text/css', | |
| '.json': 'application/json', | |
| '.png': 'image/png', | |
| '.jpg': 'image/jpeg', | |
| '.jpeg': 'image/jpeg', | |
| '.gif': 'image/gif', | |
| '.svg': 'image/svg+xml', | |
| '.ico': 'image/x-icon', | |
| '.woff': 'font/woff', | |
| '.woff2': 'font/woff2', | |
| '.ttf': 'font/ttf', | |
| '.eot': 'application/vnd.ms-fontobject', | |
| '.webp': 'image/webp', | |
| '.webm': 'video/webm', | |
| '.mp4': 'video/mp4', | |
| '.mp3': 'audio/mpeg', | |
| '.wav': 'audio/wav', | |
| '.ogg': 'audio/ogg', | |
| '.wasm': 'application/wasm', | |
| } | |
| let protocolRegistered = false | |
| export function registerLocalUIProtocol() { | |
| // Prevent double registration | |
| if (protocolRegistered) { | |
| console.log('[LocalUI] Protocol already registered, skipping') | |
| return | |
| } | |
| console.log('[Hayase] doing local') | |
| // extraResources copies to the resources folder next to app.asar | |
| const uiPath = join(process.resourcesPath, 'ui') | |
| console.log('[LocalUI] Registering protocol handler, UI path:', uiPath) | |
| console.log('[LocalUI] UI path exists:', existsSync(uiPath)) | |
| try { | |
| protocol.handle('local-ui', (request) => { | |
| try { | |
| let url = request.url.replace('local-ui://app/', '').replace('local-ui:///', '') | |
| // Remove query string and hash | |
| url = url.split('?')[0].split('#')[0] | |
| // Default to index.html for root or paths without extension | |
| if (!url || url === '' || !extname(url)) { | |
| // For SvelteKit routes, try the path as a directory with index.html | |
| const pathWithIndex = join(uiPath, url, 'index.html') | |
| if (existsSync(pathWithIndex)) { | |
| url = join(url, 'index.html') | |
| } else { | |
| url = 'index.html' | |
| } | |
| } | |
| // Remove leading slash if present | |
| if (url.startsWith('/')) { | |
| url = url.substring(1) | |
| } | |
| const filePath = join(uiPath, url) | |
| if (!existsSync(filePath)) { | |
| // Fallback to index.html for SPA routing | |
| const indexPath = join(uiPath, 'index.html') | |
| if (existsSync(indexPath)) { | |
| console.log('[LocalUI] Serving index.html for:', request.url) | |
| const data = readFileSync(indexPath) | |
| return new Response(data, { | |
| headers: { 'Content-Type': 'text/html' } | |
| }) | |
| } | |
| console.error('[LocalUI] File not found:', filePath, 'for URL:', request.url) | |
| return new Response('Not Found', { status: 404 }) | |
| } | |
| const data = readFileSync(filePath) | |
| const ext = extname(filePath).toLowerCase() | |
| const mimeType = MIME_TYPES[ext] || 'application/octet-stream' | |
| return new Response(data, { | |
| headers: { 'Content-Type': mimeType } | |
| }) | |
| } catch (error) { | |
| console.error('[LocalUI] Error serving file:', error, 'for URL:', request.url) | |
| return new Response('Internal Server Error', { status: 500 }) | |
| } | |
| }) | |
| protocolRegistered = true | |
| console.log('[LocalUI] Protocol handler registered') | |
| } catch (error) { | |
| console.error('[LocalUI] Failed to register protocol:', error) | |
| // If registration fails, it might already be registered, mark it as such | |
| protocolRegistered = true | |
| } | |
| } | |
| ''' | |
| with open(protocol_handler, "w") as f: | |
| f.write(handler_code) | |
| print(" ✓ Created local-ui protocol handler") | |
| # Patch electron to load local UI using custom protocol (idempotent) | |
| app_ts = os.path.join(ELECTRON_DIR, "src/main/app.ts") | |
| if os.path.exists(app_ts): | |
| with open(app_ts, "r") as f: | |
| content = f.read() | |
| modified = False | |
| # Add local-ui protocol scheme registration (only if not already present) | |
| if "scheme: 'local-ui'" not in content: | |
| content = content.replace( | |
| "protocol.registerSchemesAsPrivileged([", | |
| """protocol.registerSchemesAsPrivileged([ | |
| { scheme: 'local-ui', privileges: { standard: true, secure: true, bypassCSP: false, allowServiceWorkers: true, supportFetchAPI: true, corsEnabled: true, stream: true } },""" | |
| ) | |
| modified = True | |
| # Fix security: disable bypassCSP on existing local-ui scheme if present | |
| if "bypassCSP: true" in content: | |
| content = content.replace("bypassCSP: true", "bypassCSP: false") | |
| modified = True | |
| # Fix security: disable allowRunningInsecureContent | |
| if "allowRunningInsecureContent: true" in content: | |
| content = content.replace("allowRunningInsecureContent: true", "allowRunningInsecureContent: false") | |
| modified = True | |
| # Add CORS headers for external APIs (since we disabled bypassCSP) | |
| cors_injection = ''' if (details.responseHeaders) { | |
| const needsCors = | |
| details.url.startsWith('http://localhost') || | |
| details.url.startsWith('http://127.0.0.1') || | |
| details.url.startsWith('https://nyaa.si') || | |
| details.url.startsWith('https://sukebei.nyaa.si') || | |
| details.url.startsWith('https://www.tokyotosho.info') || | |
| details.url.startsWith('https://hayase.ani.zip') || | |
| details.url.startsWith('https://api.ani.zip') || | |
| details.url.startsWith('https://feed.animetosho.org') || | |
| details.url.startsWith('https://animetosho.org') || | |
| details.url.startsWith('https://raw.githubusercontent.com') || | |
| details.url.startsWith('https://esm.sh') || | |
| details.url.startsWith('https://kitsu.app') || | |
| details.url.startsWith('https://subsplease.org') | |
| if (needsCors && !details.responseHeaders['Access-Control-Allow-Origin'] && !details.responseHeaders['access-control-allow-origin']) { | |
| details.responseHeaders['Access-Control-Allow-Origin'] = ['*'] | |
| details.responseHeaders['Access-Control-Allow-Methods'] = ['GET, POST, OPTIONS, HEAD'] | |
| details.responseHeaders['Access-Control-Allow-Headers'] = ['*'] | |
| } | |
| } | |
| ''' | |
| if "needsCors" not in content and "onHeadersReceived((details, callback)" in content: | |
| content = content.replace( | |
| "session.defaultSession.webRequest.onHeadersReceived((details, callback) => {", | |
| "session.defaultSession.webRequest.onHeadersReceived((details, callback) => {\n" + cors_injection | |
| ) | |
| modified = True | |
| # Change BASE_URL to use custom protocol (only if still pointing to hayase.app) | |
| if "'https://hayase.app/'" in content: | |
| content = content.replace( | |
| "const BASE_URL = is.dev ? 'http://localhost:7344/' : 'https://hayase.app/'", | |
| "const BASE_URL = is.dev ? 'http://localhost:7344/' : 'local-ui://app/'" | |
| ) | |
| modified = True | |
| # Add import for registerLocalUIProtocol (only if not already present) | |
| if "import { registerLocalUIProtocol }" not in content: | |
| content = content.replace( | |
| "import Updater from './updater.ts'", | |
| "import Updater from './updater.ts'\nimport { registerLocalUIProtocol } from './local-ui-protocol.ts'" | |
| ) | |
| modified = True | |
| # Register protocol call (only if not already present) | |
| if "registerLocalUIProtocol()" not in content: | |
| if "electronApp.setAppUserModelId('com.github.hayase-app')" in content: | |
| content = content.replace( | |
| " electronApp.setAppUserModelId('com.github.hayase-app')", | |
| " electronApp.setAppUserModelId('com.github.hayase-app')\n if (!is.dev) registerLocalUIProtocol()" | |
| ) | |
| modified = True | |
| elif "electronApp.setAppUserModelId('com.electron')" in content: | |
| content = content.replace( | |
| " electronApp.setAppUserModelId('com.electron')", | |
| " electronApp.setAppUserModelId('com.electron')\n if (!is.dev) registerLocalUIProtocol()" | |
| ) | |
| modified = True | |
| # Fix DoH error with empty string | |
| if "setDOH (dns: string) {" in content and "if (!dns || dns.trim() === '') return" not in content: | |
| content = content.replace( | |
| "setDOH (dns: string) {\n try {", | |
| "setDOH (dns: string) {\n if (!dns || dns.trim() === '') return\n try {" | |
| ) | |
| modified = True | |
| if modified: | |
| with open(app_ts, "w") as f: | |
| f.write(content) | |
| print(" ✓ Patched app.ts for local-ui protocol") | |
| print(" ✓ Linking local interface (local-ui://app/) instead of online (hayase.app)") | |
| else: | |
| print(" ✓ Local interface already linked (local-ui://app/)") | |
| def install_electron_deps(): | |
| print("\n[7/8] Installing electron dependencies...") | |
| run("pnpm install", cwd=ELECTRON_DIR) | |
| def build_app(target_platforms): | |
| import glob | |
| print("\n[8/8] Building Hayase...") | |
| run("pnpm run build", cwd=ELECTRON_DIR) | |
| os.makedirs(os.path.join(SCRIPT_DIR, "dist"), exist_ok=True) | |
| # Build for each target platform | |
| for target in target_platforms: | |
| if target == "linux": | |
| print(f"\n Building for Linux...") | |
| run("pnpm exec electron-builder --linux", cwd=ELECTRON_DIR) | |
| deb_files = glob.glob(os.path.join(ELECTRON_DIR, "dist", "*.deb")) | |
| for deb in deb_files: | |
| dst = os.path.join(SCRIPT_DIR, "dist", os.path.basename(deb)) | |
| shutil.copy2(deb, dst) | |
| print(f" ✓ Linux package: {dst}") | |
| print(f" Install with: sudo dpkg -i {dst}") | |
| elif target == "windows": | |
| print(f"\n Building for Windows...") | |
| run("pnpm exec electron-builder --win", cwd=ELECTRON_DIR) | |
| exe_files = glob.glob(os.path.join(ELECTRON_DIR, "dist", "*.exe")) | |
| for exe in exe_files: | |
| dst = os.path.join(SCRIPT_DIR, "dist", os.path.basename(exe)) | |
| shutil.copy2(exe, dst) | |
| print(f" ✓ Windows package: {dst}") | |
| print("\n✓ Build complete!") | |
| print(f" Output directory: {os.path.join(SCRIPT_DIR, 'dist')}") | |
| def clean(): | |
| """Remove cloned repos and rebuild from scratch.""" | |
| for name in REPOS.keys(): | |
| target = os.path.join(SCRIPT_DIR, name) | |
| if os.path.exists(target): | |
| print(f" Removing {name}...") | |
| shutil.rmtree(target) | |
| dist = os.path.join(SCRIPT_DIR, "dist") | |
| if os.path.exists(dist): | |
| print(" Removing dist...") | |
| shutil.rmtree(dist) | |
| def main(): | |
| global SELECTED_PATCHES | |
| print("=" * 50) | |
| print(" Hayase Open Source Build Script") | |
| print(f" Detected OS: {CURRENT_OS}") | |
| print(" UI: Local (bundled)") | |
| print("=" * 50) | |
| if len(sys.argv) > 1 and sys.argv[1] == "clean": | |
| print("\nCleaning...") | |
| clean() | |
| print("Done. Run again without 'clean' to rebuild.") | |
| return | |
| # Handle --defaults flag for non-interactive mode | |
| use_defaults = "--defaults" in sys.argv | |
| if use_defaults: | |
| # Use all defaults without prompting | |
| SELECTED_PATCHES = {k: v["default"] for k, v in AVAILABLE_PATCHES.items()} | |
| target_platforms = ["linux"] if CURRENT_OS != "windows" else ["windows"] | |
| print("\nUsing default settings (--defaults flag)") | |
| print("\nSelected patches:") | |
| for patch_id, enabled in SELECTED_PATCHES.items(): | |
| status = "✓" if enabled else "✗" | |
| print(f" {status} {AVAILABLE_PATCHES[patch_id]['name']}") | |
| print(f"\nTarget platform(s): {', '.join(target_platforms)}") | |
| else: | |
| # Interactive mode - ask for patches and target | |
| select_patches() | |
| target_platforms = select_target_os() | |
| print("\n" + "=" * 50) | |
| check_dependencies() | |
| clone_repos() | |
| apply_build_patches(target_platforms) | |
| remove_telemetry() | |
| apply_theme() | |
| build_interface() | |
| install_electron_deps() | |
| print("\n" + "=" * 50) | |
| # Check if --build flag is provided or if running non-interactively | |
| should_build = False | |
| if "--build" in sys.argv: | |
| should_build = True | |
| elif "--no-build" in sys.argv: | |
| should_build = False | |
| else: | |
| # Try to get user input, but handle non-interactive environments | |
| try: | |
| response = input("Build the app now? [Y/n]: ").strip().lower() | |
| should_build = response in ("", "y", "yes") | |
| except (EOFError, KeyboardInterrupt): | |
| # Non-interactive environment or Ctrl+C - default to not building | |
| print("\nNon-interactive mode detected. Skipping build.") | |
| print("Use --build flag to build automatically, or run manually:") | |
| should_build = False | |
| if should_build: | |
| build_app(target_platforms) | |
| else: | |
| platforms_str = " ".join([f"--{p}" for p in target_platforms]) | |
| print("\nSkipped build. Run this script again with --build flag or manually:") | |
| print(f" cd {ELECTRON_DIR}") | |
| print(f" pnpm run build && pnpm exec electron-builder {platforms_str}") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment