Skip to content

Instantly share code, notes, and snippets.

@morawskidotmy
Last active January 30, 2026 13:29
Show Gist options
  • Select an option

  • Save morawskidotmy/d923f024cb351328115c6db2b6caf624 to your computer and use it in GitHub Desktop.

Select an option

Save morawskidotmy/d923f024cb351328115c6db2b6caf624 to your computer and use it in GitHub Desktop.
hayase.watch build and patch
#!/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