Compare commits

..

13 Commits

Author SHA1 Message Date
Nexmoe
877f088136 chore: release v1.1.8 2026-01-12 10:14:49 +08:00
Nexmoe
5ee08a3e62 chore(build): add macOS signing secrets and validation
- Introduced new secrets for macOS signing in the build workflow.
- Added validation to check for the presence of required secrets before proceeding with code signing setup.
2026-01-12 10:13:58 +08:00
Nexmoe
137a07cf4e chore(build): enhance macOS signing and notarization process
- Updated electron-builder configuration to enable hardened runtime and notarization for macOS builds.
- Added a new GitHub Actions step to set up macOS signing with necessary certificates and keys.
2026-01-12 09:58:44 +08:00
Nexmoe
21a5adbdf2 fix(update): localize update toast (#82) 2026-01-11 16:56:34 +08:00
Nexmoe
81f52f86e5 fix(about): hide check updates when available (#80) 2026-01-11 16:34:48 +08:00
Nexmoe
c8147cafe3 fix(download): inline progress details (#81) 2026-01-11 16:34:40 +08:00
Nexmoe
7074f698e5 chore: release v1.1.7 2026-01-11 15:46:37 +08:00
Nexmoe
b254bb6dba chore(build): reduce packaged artifacts (#79) 2026-01-11 15:44:20 +08:00
Nexmoe
1caca0ea9a feat(settings): add embed features (#76)
* feat(settings): add embed options

* feat(settings): default embed on

* refactor(setup-dev-binaries): enhance download error handling and version checks for binaries
2026-01-11 15:24:33 +08:00
Nexmoe
12ec142a77 ci(release): notify Cloudflare Pages (#78) 2026-01-11 15:21:40 +08:00
Nexmoe
0136da410d chore: release v1.1.6 2026-01-11 10:21:42 +08:00
Nexmoe
4971a5fe8a feat(settings): improve cookie profile handling (#74)
* feat(settings): improve cookie profile handling

* fix(settings): normalize profile input
2026-01-11 10:16:28 +08:00
Nexmoe
d2806164a2 feat(extension): integrate local video info API (#67)
* feat(extension): integrate local video info API

* chore(repo): clean up popup styles

* feat(extension): revamp popup UI

* chore(repo): revert docs and drop content script
2026-01-11 00:05:46 +08:00
43 changed files with 5832 additions and 206 deletions

View File

@@ -8,6 +8,17 @@ on:
type: boolean
default: false
description: 'Whether to upload build artifacts'
secrets:
MAC_CERT_P12_BASE64:
required: false
MAC_CERT_P12_PASSWORD:
required: false
APPLE_API_KEY_ID:
required: false
APPLE_API_ISSUER:
required: false
APPLE_API_KEY_P8_BASE64:
required: false
jobs:
build:
@@ -68,6 +79,8 @@ jobs:
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
Copy-Item -Path $source -Destination $destination -Force
Remove-Item ffmpeg.zip -Force
Remove-Item ffmpeg -Recurse -Force
- name: Download ffmpeg binary (macOS)
if: matrix.platform == 'macos'
@@ -112,6 +125,7 @@ jobs:
tar -xf ffmpeg.tar.xz -C ffmpeg
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
chmod +x "resources/${{ matrix.ffmpeg_output }}"
rm -rf ffmpeg.tar.xz ffmpeg
- name: Download yt-dlp binary
shell: bash
@@ -124,6 +138,46 @@ jobs:
- name: Lint and format check
run: pnpm run check && pnpm run typecheck
- name: Setup macOS signing
if: matrix.platform == 'macos'
shell: bash
env:
MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}
MAC_CERT_P12_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
set -euo pipefail
# Check if all required secrets are present
if [[ -z "$MAC_CERT_P12_BASE64" ]] || [[ -z "$MAC_CERT_P12_PASSWORD" ]] || \
[[ -z "$APPLE_API_KEY_ID" ]] || [[ -z "$APPLE_API_ISSUER" ]] || \
[[ -z "$APPLE_API_KEY_P8_BASE64" ]]; then
echo "::notice::macOS signing secrets not available, skipping code signing setup"
exit 0
fi
CERT_PATH="$RUNNER_TEMP/mac_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
echo "$MAC_CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$API_KEY_PATH"
security create-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MAC_CERT_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productbuild
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
echo "CSC_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "CSC_KEY_PASSWORD=$MAC_CERT_P12_PASSWORD" >> "$GITHUB_ENV"
echo "APPLE_API_KEY=$API_KEY_PATH" >> "$GITHUB_ENV"
echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" >> "$GITHUB_ENV"
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" >> "$GITHUB_ENV"
- name: Build application
run: ${{ matrix.build_script }}

View File

@@ -10,6 +10,7 @@ jobs:
uses: ./.github/workflows/build.yml
with:
upload_artifacts: true
secrets: inherit
release:
needs: [build]
@@ -42,3 +43,9 @@ jobs:
dist/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- name: Notify Cloudflare Pages
env:
CLOUDFLARE_WEBHOOK_URL: ${{ secrets.CLOUDFLARE_WEBHOOK_URL }}
run: |
curl -X POST "$CLOUDFLARE_WEBHOOK_URL"

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@ node_modules
/dist
out
.conductor/
.wxt
.output
.DS_Store
.eslintcache
*.log*

View File

@@ -8,11 +8,16 @@ protocols:
- vidbee
files:
- '!**/.vscode/*'
- '!**/.context/**'
- '!**/.github/**'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!extension/**'
- '!monkey/**'
- '!screenshots/**'
asarUnpack:
- resources/**
win:
@@ -23,9 +28,10 @@ nsis:
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
identity: null
hardenedRuntime: true
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
notarize: false
notarize: true
artifactName: ${name}-${version}-${arch}.${ext}
target:
- target: zip

26
extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
extension/README.md Normal file
View File

@@ -0,0 +1,3 @@
# WXT + React
This template should help get you started developing with React in WXT.

View File

@@ -0,0 +1,163 @@
.vidbee-download-container {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 9999;
transition: all 0.2s ease;
overflow: visible;
}
.vidbee-download-container.vidbee-hidden {
opacity: 0;
pointer-events: none;
transform: scale(0);
}
.vidbee-download-button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 0;
transition: all 0.2s ease;
opacity: 0.6;
overflow: visible;
}
.vidbee-download-button:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.7);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transform: scale(1.1);
}
.vidbee-download-button:active {
transform: scale(0.95);
}
.vidbee-download-button svg {
flex-shrink: 0;
stroke: currentColor;
width: 16px;
height: 16px;
}
.vidbee-tooltip {
position: absolute;
right: calc(100% + 8px);
top: 50%;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: white;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
transform: translateY(-50%) translateX(4px);
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
line-height: 1;
}
.vidbee-tooltip::after {
content: '';
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
border: 4px solid transparent;
border-left-color: rgba(0, 0, 0, 0.9);
}
.vidbee-download-button:hover .vidbee-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.vidbee-close-button {
position: absolute;
top: -6px;
right: -6px;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: rgba(255, 77, 77, 0.9);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
cursor: pointer;
font-size: 0;
transition: all 0.2s ease;
z-index: 1;
opacity: 0;
pointer-events: none;
overflow: visible;
}
.vidbee-download-container:hover .vidbee-close-button {
opacity: 1;
pointer-events: auto;
}
.vidbee-close-button:hover {
background: rgba(255, 77, 77, 1);
transform: scale(1.15);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
}
.vidbee-close-button:active {
transform: scale(0.9);
}
.vidbee-close-button svg {
flex-shrink: 0;
stroke: currentColor;
width: 10px;
height: 10px;
}
.vidbee-close-button .vidbee-tooltip {
right: calc(100% + 6px);
top: 50%;
left: auto;
transform: translateY(-50%) translateX(4px);
}
.vidbee-close-button .vidbee-tooltip::after {
left: 100%;
top: 50%;
right: auto;
transform: translateY(-50%);
border-left-color: rgba(0, 0, 0, 0.9);
border-top-color: transparent;
}
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}

View File

@@ -0,0 +1,203 @@
type VideoFormat = {
format_id?: string
ext?: string
format_note?: string
resolution?: string
width?: number
height?: number
fps?: number
vcodec?: string
acodec?: string
filesize?: number
filesize_approx?: number
tbr?: number
}
type VideoInfo = {
title?: string
thumbnail?: string
duration?: number
formats?: VideoFormat[]
}
type VideoInfoCacheEntry = {
url: string
status: 'pending' | 'ready' | 'error'
fetchedAt: number
info?: VideoInfo
error?: string
}
const PORT_RANGE_START = 27100
const PORT_RANGE_END = 27120
const STATUS_TIMEOUT_MS = 800
const INFO_TIMEOUT_MS = 60000
const CACHE_TTL_MS = 5 * 60 * 1000
const pendingRequests = new Map<string, Promise<void>>()
const defaultIconPaths = {
16: 'icon/16.png',
32: 'icon/32.png',
48: 'icon/48.png',
128: 'icon/128.png'
}
const loadingIconPaths = {
16: 'icon/icon-loading-16.png',
32: 'icon/icon-loading-32.png',
48: 'icon/icon-loading-48.png',
128: 'icon/icon-loading-128.png'
}
const successIconPaths = {
16: 'icon/icon-success-16.png',
32: 'icon/icon-success-32.png',
48: 'icon/icon-success-48.png',
128: 'icon/icon-success-128.png'
}
const setActionIcon = (status: 'default' | 'loading' | 'success', tabId?: number): void => {
const paths =
status === 'loading'
? loadingIconPaths
: status === 'success'
? successIconPaths
: defaultIconPaths
const options = tabId ? { path: paths, tabId } : { path: paths }
void browser.action.setIcon(options)
}
const fetchJson = async <T>(url: string, timeoutMs: number): Promise<T> => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs)
try {
const response = await fetch(url, { signal: controller.signal })
const data = (await response.json().catch(() => null)) as (T & { error?: string }) | null
if (!response.ok) {
const message = data && typeof data === 'object' && 'error' in data ? data.error : null
const details = data && typeof data === 'object' && 'details' in data ? data.details : null
const combined = [message, details].filter(Boolean).join('\n\n')
throw new Error(combined || `Request failed: ${response.status}`)
}
return data as T
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('Request timed out.')
}
if (error instanceof Error && error.message.includes('signal is aborted')) {
throw new Error('Request timed out.')
}
if (error instanceof Error && error.message.includes('Failed to fetch')) {
throw new Error('VidBee app not responding on this port.')
}
throw error
} finally {
clearTimeout(timeoutId)
}
}
const findAvailablePort = async (): Promise<number | null> => {
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
const baseUrl = `http://127.0.0.1:${port}`
try {
await fetchJson<{ ok: boolean }>(`${baseUrl}/status`, STATUS_TIMEOUT_MS)
return port
} catch {
// Keep scanning.
}
}
return null
}
const requestVideoInfo = async (targetUrl: string): Promise<VideoInfo> => {
const port = await findAvailablePort()
if (!port) {
throw new Error('VidBee app not found on localhost.')
}
const baseUrl = `http://127.0.0.1:${port}`
const tokenResponse = await fetchJson<{ token?: string }>(`${baseUrl}/token`, STATUS_TIMEOUT_MS)
if (!tokenResponse.token) {
throw new Error('Failed to acquire token from VidBee.')
}
return fetchJson<VideoInfo>(
`${baseUrl}/video-info?url=${encodeURIComponent(targetUrl)}&token=${encodeURIComponent(
tokenResponse.token
)}`,
INFO_TIMEOUT_MS
)
}
const getCacheMap = async (): Promise<Record<string, VideoInfoCacheEntry>> => {
const data = await browser.storage.local.get('videoInfoCacheByUrl')
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
if (!map) return {}
return map
}
const pruneCache = (map: Record<string, VideoInfoCacheEntry>): void => {
const now = Date.now()
for (const [key, entry] of Object.entries(map)) {
if (now - entry.fetchedAt > CACHE_TTL_MS) {
delete map[key]
}
}
}
const loadCache = async (url: string): Promise<VideoInfoCacheEntry | null> => {
const map = await getCacheMap()
pruneCache(map)
const cache = map[url]
if (!cache) return null
return cache
}
const saveCacheEntry = async (cache: VideoInfoCacheEntry): Promise<void> => {
const map = await getCacheMap()
pruneCache(map)
map[cache.url] = cache
await browser.storage.local.set({ videoInfoCacheByUrl: map })
}
const fetchAndCache = async (url: string, tabId?: number): Promise<void> => {
if (pendingRequests.has(url)) {
return pendingRequests.get(url) as Promise<void>
}
const task = (async () => {
const existing = await loadCache(url)
if (existing?.status === 'ready') {
setActionIcon('success', tabId)
return
}
setActionIcon('loading', tabId)
await saveCacheEntry({ url, status: 'pending', fetchedAt: Date.now() })
try {
const info = await requestVideoInfo(url)
await saveCacheEntry({ url, status: 'ready', fetchedAt: Date.now(), info })
setActionIcon('success', tabId)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch video info.'
await saveCacheEntry({ url, status: 'error', fetchedAt: Date.now(), error: message })
setActionIcon('default', tabId)
}
})()
pendingRequests.set(url, task)
try {
await task
} finally {
pendingRequests.delete(url)
}
}
export default defineBackground(() => {
browser.runtime.onMessage.addListener((message: { type?: string; url?: string }, sender) => {
if (message.type !== 'video-info:fetch' || !message.url) {
return
}
void fetchAndCache(message.url, sender.tab?.id)
})
})

View File

@@ -0,0 +1,291 @@
:root {
--bg: #ffffff;
--fg: #111111;
--fg-secondary: #757575;
--border: #f0f0f0;
--accent: #000000;
--error: #e00000;
--success: #00c853;
--warning: #ffd600;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: var(--bg);
color: var(--fg);
}
#root {
width: 360px;
min-height: 200px;
padding: 24px;
box-sizing: border-box;
}
.app {
display: flex;
flex-direction: column;
gap: 24px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-size: 13px;
font-weight: 600;
margin: 0;
letter-spacing: -0.01em;
color: var(--fg);
}
.status-indicator {
font-size: 11px;
font-weight: 500;
color: var(--fg-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--border);
}
.status-dot.loading { background-color: var(--warning); box-shadow: 0 0 4px var(--warning); }
.status-dot.ok { background-color: var(--success); }
.status-dot.error { background-color: var(--error); }
.video-info {
display: grid;
grid-template-columns: 1fr 90px;
gap: 20px;
align-items: start;
}
.video-details h2 {
font-size: 15px;
font-weight: 500;
line-height: 1.4;
margin: 0 0 8px 0;
color: var(--fg);
}
.meta-row {
font-size: 12px;
color: var(--fg-secondary);
margin: 0 0 4px 0;
display: flex;
align-items: center;
gap: 8px;
}
.thumbnail {
width: 90px;
height: 50px;
background: var(--border);
object-fit: cover;
border-radius: 6px;
display: block;
}
.formats-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.format-group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Sticky headers for long lists */
.sticky-title {
position: sticky;
top: 0;
background: var(--bg);
padding: 4px 0;
z-index: 10;
}
.group-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-secondary);
font-weight: 600;
border-bottom: 2px solid var(--border);
padding-bottom: 4px;
}
.format-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.format-table th {
text-align: left;
font-weight: 400;
color: var(--fg-secondary);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
font-size: 10px;
text-transform: uppercase;
padding-top: 6px;
}
.format-table td {
padding: 6px 0;
border-bottom: 1px solid var(--border);
color: var(--fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.format-table tr:last-child td {
border-bottom: none;
}
.col-id { width: 50px; color: var(--fg-secondary); }
.col-ext { width: 50px; }
.col-size { width: 60px; text-align: right; }
.format-table th.col-size { text-align: right; }
.empty-state {
font-size: 12px;
color: var(--fg-secondary);
padding: 12px 0;
font-style: italic;
text-align: center;
}
.error-banner {
font-size: 12px;
color: var(--error);
line-height: 1.5;
background: rgba(224, 0, 0, 0.05);
padding: 12px;
border-radius: 6px;
}
.primary-button {
background: var(--fg);
color: var(--bg);
border: none;
border-radius: 8px;
padding: 12px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
width: 100%;
transition: opacity 0.2s;
}
.primary-button:hover {
opacity: 0.9;
}
.primary-button:active {
transform: scale(0.98);
}
.error-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.troubleshoot-box {
background: var(--border);
padding: 12px;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 8px;
}
.troubleshoot-title {
font-size: 11px;
font-weight: 600;
color: var(--fg-secondary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.troubleshoot-actions {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.link-button {
background: none;
border: none;
padding: 0;
color: var(--accent);
text-decoration: none;
font-weight: 500;
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
.link-button:hover {
text-decoration: underline;
}
.divider {
color: var(--fg-secondary);
}
.loading-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 200px;
gap: 16px;
flex: 1;
width: 100%;
}
.spinner {
width: 24px;
height: 24px;
border: 2.5px solid var(--border);
border-top-color: var(--fg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
font-size: 13px;
font-weight: 500;
color: var(--fg-secondary);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}

View File

@@ -0,0 +1,415 @@
import { useEffect, useMemo, useState } from 'react'
import './App.css'
type VideoFormat = {
format_id?: string
ext?: string
format_note?: string
resolution?: string
width?: number
height?: number
fps?: number
vcodec?: string
acodec?: string
filesize?: number
filesize_approx?: number
tbr?: number
}
type VideoInfo = {
title?: string
thumbnail?: string
duration?: number
formats?: VideoFormat[]
}
const CACHE_TTL_MS = 60 * 60 * 1000
const isValidHttpUrl = (value?: string): boolean => {
if (!value) return false
return value.startsWith('http://') || value.startsWith('https://')
}
const formatDuration = (value?: number): string => {
if (!value || value <= 0) return 'Unknown'
const totalSeconds = Math.round(value)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const paddedSeconds = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${minutes}:${paddedSeconds}`
}
const formatBytes = (value?: number): string => {
if (!value || value <= 0) return '-'
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size.toFixed(size >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
const isVideoFormat = (format: VideoFormat): boolean => {
if (format.vcodec && format.vcodec !== 'none') {
return true
}
return Boolean(format.resolution || format.width || format.height)
}
const isAudioFormat = (format: VideoFormat): boolean => {
return Boolean(format.acodec && format.acodec !== 'none' && !isVideoFormat(format))
}
type VideoInfoCacheEntry = {
url: string
status: 'pending' | 'ready' | 'error'
fetchedAt: number
info?: VideoInfo
error?: string
}
type VideoGroup = {
label: string
height: number
formats: VideoFormat[]
}
const loadCachedInfo = async (url: string): Promise<VideoInfoCacheEntry | null> => {
const data = await browser.storage.local.get('videoInfoCacheByUrl')
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
if (!map) return null
const cached = map[url]
if (!cached) return null
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) return null
return cached
}
const sanitizeError = (error: string): string => {
const message = error.toLowerCase()
if (
message.includes('localhost') ||
message.includes('fetch') ||
message.includes('network') ||
message.includes('connect') ||
message.includes('failed to request')
) {
return 'Client connection failed'
}
return error
}
function App() {
const [info, setInfo] = useState<VideoInfo | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [currentUrl, setCurrentUrl] = useState<string>('')
const [retryTrigger, setRetryTrigger] = useState(0)
useEffect(() => {
let active = true
const targetState = { url: '' }
const handleStorageChange = (
changes: Record<string, browser.storage.StorageChange>,
areaName: string
) => {
if (!active || areaName !== 'local') return
const change = changes.videoInfoCacheByUrl
if (!change?.newValue) return
const map = change.newValue as Record<string, VideoInfoCacheEntry>
const next = map[targetState.url]
if (!next) return
if (next.status === 'ready' && next.info) {
setInfo(next.info)
setError(null)
setLoading(false)
} else if (next.status === 'error' && next.error) {
setError(sanitizeError(next.error))
setInfo(null)
setLoading(false)
} else if (next.status === 'pending') {
setLoading(true)
}
}
browser.storage.onChanged.addListener(handleStorageChange)
const loadInfo = async () => {
setLoading(true)
setError(null)
setInfo(null)
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
if (!isValidHttpUrl(tab?.url)) {
setError('Please open a valid video page first.')
setLoading(false)
return
}
const targetUrl = tab.url as string
targetState.url = targetUrl
setCurrentUrl(targetUrl)
const cached = await loadCachedInfo(targetUrl)
const shouldBypassCache = retryTrigger > 0
if (cached && !shouldBypassCache) {
if (cached.status === 'ready' && cached.info) {
setInfo(cached.info)
setLoading(false)
return
}
if (cached.status === 'error' && cached.error) {
setError(sanitizeError(cached.error))
setLoading(false)
return
}
}
try {
await browser.runtime.sendMessage({
type: 'video-info:fetch',
url: targetUrl
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to request video info.'
setError(sanitizeError(message))
setLoading(false)
}
const latest = await loadCachedInfo(targetUrl)
if (latest && latest.status === 'ready' && latest.info) {
setInfo(latest.info)
setError(null)
setLoading(false)
} else if (latest && latest.status === 'error' && latest.error) {
setError(sanitizeError(latest.error))
setInfo(null)
setLoading(false)
}
}
void loadInfo()
return () => {
active = false
browser.storage.onChanged.removeListener(handleStorageChange)
}
}, [retryTrigger])
const formats = useMemo(() => info?.formats ?? [], [info])
const groupedFormats = useMemo(() => {
const video: VideoFormat[] = []
const audio: VideoFormat[] = []
const other: VideoFormat[] = []
for (const format of formats) {
if (isVideoFormat(format)) {
video.push(format)
} else if (isAudioFormat(format)) {
audio.push(format)
} else {
other.push(format)
}
}
return { video, audio, other }
}, [formats])
const groupedVideoFormats = useMemo(() => {
const raw = groupedFormats.video
if (!raw.length) return []
const groups: Record<number, VideoFormat[]> = {}
const noHeight: VideoFormat[] = []
for (const f of raw) {
const h = f.height || f.resolution?.match(/x(\d+)/)?.[1]
const heightVal = h ? Number(h) : 0
if (heightVal > 0) {
if (!groups[heightVal]) groups[heightVal] = []
groups[heightVal].push(f)
} else {
noHeight.push(f)
}
}
const sortedLabels = Object.keys(groups)
.map(Number)
.sort((a, b) => b - a)
const result: VideoGroup[] = sortedLabels.map((h) => ({
label: `${h}p`,
height: h,
formats: groups[h].sort((a, b) => {
const sa = a.filesize || a.filesize_approx || 0
const sb = b.filesize || b.filesize_approx || 0
return sb - sa
})
}))
if (noHeight.length > 0) {
result.push({
label: 'Other',
height: 0,
formats: noHeight
})
}
return result
}, [groupedFormats.video])
const handleOpenClient = () => {
if (!currentUrl) return
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
window.location.href = deepLink
}
const renderStatus = () => {
// if (loading) return null
if (error)
return (
<span className="status-indicator">
<div className="status-dot error" /> Error
</span>
)
if (info)
return (
<span className="status-indicator">
<div className="status-dot ok" /> Ready
</span>
)
return (
<span className="status-indicator">
<div className="status-dot" /> Idle
</span>
)
}
return (
<div className="app">
<header>
<h1>VidBee</h1>
{renderStatus()}
</header>
{loading && (
<div className="loading-container">
<div className="spinner" />
<div className="loading-text">Analyzing video...</div>
</div>
)}
{!loading && error && (
<div className="error-container">
<div className="error-banner">{error}</div>
<div className="troubleshoot-box">
<p className="troubleshoot-title">Having trouble?</p>
<div className="troubleshoot-actions">
<button
type="button"
className="link-button"
onClick={() => {
window.location.href = 'vidbee://'
setTimeout(() => setRetryTrigger((c) => c + 1), 100)
}}
>
Open Client
</button>
<span className="divider"></span>
<a
href="https://vidbee.app"
target="_blank"
rel="noopener noreferrer"
className="link-button"
>
Download
</a>
</div>
</div>
</div>
)}
{!loading && !error && info && (
<>
<section className="video-info">
<div className="video-details">
<h2>{info.title || 'Untitled video'}</h2>
<div className="meta-row">
<span>{formatDuration(info.duration)}</span>
<span></span>
<span>{formats.length} formats</span>
</div>
</div>
{info.thumbnail && <img className="thumbnail" src={info.thumbnail} alt="" />}
</section>
<button type="button" className="primary-button" onClick={handleOpenClient}>
Download with VidBee
</button>
<section className="formats-section">
{groupedVideoFormats.map((group) => (
<div className="format-group" key={group.label}>
<div className="group-title sticky-title">{group.label}</div>
<table className="format-table">
<thead>
<tr>
<th className="col-id">ID</th>
<th className="col-ext">Ext</th>
<th className="col-size">Size</th>
</tr>
</thead>
<tbody>
{group.formats.map((f) => (
<tr key={`vg-${group.label}-${f.format_id ?? f.ext ?? 'video'}`}>
<td className="col-id">{f.format_id || '-'}</td>
<td className="col-ext">{f.ext || '-'}</td>
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
{groupedVideoFormats.length === 0 && groupedFormats.audio.length === 0 && (
<div className="empty-state">No compatible formats.</div>
)}
{groupedFormats.audio.length > 0 && (
<div className="format-group">
<div className="group-title">Audio Only</div>
<table className="format-table">
<thead>
<tr>
<th className="col-id">ID</th>
<th className="col-ext">Ext</th>
<th className="col-size">Size</th>
</tr>
</thead>
<tbody>
{groupedFormats.audio.map((f) => (
<tr key={`a-${f.format_id ?? f.ext ?? 'audio'}`}>
<td className="col-id">{f.format_id || '-'}</td>
<td className="col-ext">{f.ext || '-'}</td>
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
)}
</div>
)
}
export default App

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
const root = document.getElementById('root')
if (root) {
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
}

28
extension/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "wxt-react-starter",
"description": "manifest.json description",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare"
},
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@wxt-dev/module-react": "^1.1.5",
"typescript": "^5.9.3",
"wxt": "^0.20.6"
}
}

3541
extension/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
{
"downloadWithVidBee": {
"message": "Download with VidBee"
},
"hideButton": {
"message": "Hide"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

7
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx"
}
}

11
extension/wxt.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'wxt'
// See https://wxt.dev/api/config.html
export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifest: {
default_locale: 'en',
host_permissions: ['http://127.0.0.1/*'],
permissions: ['activeTab', 'storage']
}
})

View File

@@ -1,6 +1,6 @@
{
"name": "vidbee",
"version": "1.1.5",
"version": "1.1.8",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",

View File

@@ -8,7 +8,7 @@
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
const { execSync } = require('node:child_process')
const { execSync, spawnSync } = require('node:child_process')
const https = require('node:https')
const http = require('node:http')
@@ -33,7 +33,7 @@ const PLATFORM_CONFIG = {
extract: 'unzip',
release: {
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
assetPattern: /win64.*gpl.*\.zip$/i,
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
binaryName: 'ffmpeg.exe'
}
}
@@ -79,7 +79,7 @@ const PLATFORM_CONFIG = {
extract: 'tar',
release: {
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
binaryName: 'ffmpeg'
}
}
@@ -125,24 +125,43 @@ function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const file = fs.createWriteStream(dest)
let downloadedBytes = 0
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
file.close()
safeUnlink(dest)
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
const redirectUrl = response.headers.location
if (!redirectUrl) {
return reject(new Error(`Redirect without location for ${url}`))
}
log(`Redirected to ${redirectUrl}`, 'info')
return downloadFile(redirectUrl, dest).then(resolve).catch(reject)
}
const contentLength = response.headers['content-length']
if (response.statusCode !== 200) {
file.close()
safeUnlink(dest)
return reject(new Error(`Failed to download: ${response.statusCode}`))
return reject(
new Error(
`Failed to download ${url}: ${response.statusCode} (length: ${contentLength || 'unknown'})`
)
)
}
response.on('data', (chunk) => {
downloadedBytes += chunk.length
})
response.pipe(file)
file.on('finish', () => {
file.close()
log(
`Downloaded ${formatBytes(downloadedBytes)} from ${url}`,
downloadedBytes ? 'success' : 'warn'
)
resolve()
})
})
@@ -154,6 +173,7 @@ function downloadFile(url, dest) {
request.on('error', (err) => {
file.close()
safeUnlink(dest)
log(`Download error for ${url}: ${err.message}`, 'error')
reject(err)
})
})
@@ -163,6 +183,7 @@ async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
let lastError
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
log(`Downloading ${url} (attempt ${attempt}/${retries})...`, 'download')
await downloadFile(url, dest)
return
} catch (error) {
@@ -170,7 +191,7 @@ async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
safeUnlink(dest)
if (attempt < retries) {
const backoff = delayMs * attempt
log(`Download failed (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
await new Promise((resolve) => setTimeout(resolve, backoff))
}
}
@@ -298,6 +319,37 @@ function fileExists(filePath) {
return fs.existsSync(filePath)
}
function formatBytes(bytes) {
if (!bytes || bytes <= 0) {
return 'unknown size'
}
if (bytes >= 1024 * 1024) {
return `${Math.round(bytes / (1024 * 1024))} MB`
}
return `${Math.round(bytes / 1024)} KB`
}
function checkBinary(filePath, args, label) {
const result = spawnSync(filePath, args, {
encoding: 'utf8',
timeout: 8000,
windowsHide: true
})
if (result.error) {
return { ok: false, message: result.error.message }
}
if (result.status !== 0) {
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
return { ok: false, message: output || `exit code ${result.status}` }
}
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
const firstLine = output.split(/\r?\n/).find((line) => line.trim())
return { ok: true, message: firstLine ? firstLine.trim() : `${label} version check ok` }
}
function getDenoAssetName(platform, arch) {
if (platform === 'win32') {
if (arch === 'arm64') {
@@ -330,6 +382,10 @@ async function downloadYtDlp(config) {
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
if (!validation.ok) {
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
}
log(`${output} already exists, skipping download`, 'info')
return
}
@@ -342,6 +398,11 @@ async function downloadYtDlp(config) {
await downloadFileWithRetry(url, tempPath)
fs.renameSync(tempPath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
} catch (error) {
if (fs.existsSync(tempPath)) {
@@ -356,6 +417,10 @@ async function downloadFfmpegWindows(config) {
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
}
log(`${output} already exists, skipping download`, 'info')
return
}
@@ -392,6 +457,11 @@ async function downloadFfmpegWindows(config) {
}
fs.copyFileSync(sourcePath, outputPath)
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
@@ -416,6 +486,10 @@ async function downloadFfmpegMac(config) {
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
}
log(`${output} already exists, skipping download`, 'info')
return
}
@@ -448,6 +522,11 @@ async function downloadFfmpegMac(config) {
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
@@ -465,6 +544,10 @@ async function downloadFfmpegLinux(config) {
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
}
log(`${output} already exists, skipping download`, 'info')
return
}
@@ -502,6 +585,11 @@ async function downloadFfmpegLinux(config) {
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
@@ -528,6 +616,10 @@ async function downloadDenoRuntime() {
const outputPath = path.join(RESOURCES_DIR, outputName)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['--version'], 'deno')
if (!validation.ok) {
log(`Existing ${outputName} failed version check: ${validation.message}`, 'warn')
}
log(`${outputName} already exists, skipping download`, 'info')
return
}
@@ -549,6 +641,11 @@ async function downloadDenoRuntime() {
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['--version'], 'deno')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${outputName} failed version check: ${validation.message}`)
}
log(`Downloaded ${outputName} successfully`, 'success')
fs.unlinkSync(tempZip)

View File

@@ -68,7 +68,7 @@ export const buildDownloadArgs = (
settings: AppSettings,
jsRuntimeArgs: string[] = []
): string[] => {
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
const args: string[] = ['--no-playlist', '--no-mtime']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
@@ -95,11 +95,26 @@ export const buildDownloadArgs = (
args.push('--download-sections', `*${start}-${end || ''}`)
}
const embedSubs = settings.embedSubs
const embedMetadata = settings.embedMetadata
const embedChapters = settings.embedChapters
// Subtitles
if (options.downloadSubs) {
args.push('--write-subs', '--sub-langs', 'all')
if (options.downloadSubs || embedSubs) {
args.push('--sub-langs', 'all')
}
if (options.downloadSubs) {
args.push('--write-subs')
}
args.push(embedSubs ? '--embed-subs' : '--no-embed-subs')
if (process.platform !== 'darwin') {
args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')
}
args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')
args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')
// Output path with proper encoding handling
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
const filenameTemplate = sanitizeFilenameTemplate(

View File

@@ -20,6 +20,7 @@ import { ffmpegManager } from './lib/ffmpeg-manager'
import { subscriptionManager } from './lib/subscription-manager'
import { subscriptionScheduler } from './lib/subscription-scheduler'
import { ytdlpManager } from './lib/ytdlp-manager'
import { startExtensionApiServer, stopExtensionApiServer } from './local-api'
import { settingsManager } from './settings'
import { createTray, destroyTray } from './tray'
import { applyAutoLaunchSetting } from './utils/auto-launch'
@@ -363,12 +364,6 @@ function initAutoUpdater(): void {
autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded:', info.version)
mainWindow?.webContents.send('update:downloaded', info)
if (mainWindow) {
mainWindow.webContents.send('update:show-notification', {
version: info.version
})
}
})
log.info('Auto-updater initialized successfully')
@@ -462,6 +457,8 @@ app.whenReady().then(async () => {
log.error('Failed to initialize yt-dlp:', error)
}
await startExtensionApiServer()
applyDockVisibility(settingsManager.get('hideDockIcon'))
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
@@ -509,6 +506,7 @@ app.on('window-all-closed', () => {
// Cleanup tray on quit
app.on('will-quit', () => {
destroyTray()
void stopExtensionApiServer()
})
// In this file you can include the rest of your app's specific main process

View File

@@ -1,5 +1,6 @@
import { createServices, type MergeIpcService } from 'electron-ipc-decorator'
import { AppService } from './services/app-service'
import { BrowserCookiesService } from './services/browser-cookies-service'
import { DownloadService } from './services/download-service'
import { FileSystemService } from './services/file-system-service'
import { HistoryService } from './services/history-service'
@@ -12,6 +13,7 @@ import { WindowService } from './services/window-service'
// Create services with automatic type inference
export const services = createServices([
AppService,
BrowserCookiesService,
DownloadService,
FileSystemService,
HistoryService,

View File

@@ -0,0 +1,270 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { resolvePathWithHome } from '../../utils/path-helpers'
class BrowserCookiesService extends IpcService {
static readonly groupName = 'browserCookies'
private buildValidationResult(valid: boolean, reason?: string) {
if (valid) {
return { valid }
}
return { valid, reason }
}
private isDirectory(target: string): boolean {
try {
return fs.statSync(target).isDirectory()
} catch {
return false
}
}
private pickFirstDirectory(paths: string[]): string {
for (const candidate of paths) {
if (this.isDirectory(candidate)) {
return candidate
}
}
return ''
}
private normalizeProfileInput(value: string): string {
return value.trim().replace(/^['"]|['"]$/g, '')
}
private getBrowserProfileBaseDirs(platform: string, homeDir: string, browser: string): string[] {
if (platform === 'win32') {
if (browser === 'edge') {
return [path.join(homeDir, 'AppData', 'Local', 'Microsoft', 'Edge', 'User Data')]
}
if (browser === 'chrome') {
return [path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data')]
}
if (browser === 'chromium') {
return [path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data')]
}
if (browser === 'brave') {
return [
path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data')
]
}
if (browser === 'vivaldi') {
return [path.join(homeDir, 'AppData', 'Local', 'Vivaldi', 'User Data')]
}
if (browser === 'whale') {
return [path.join(homeDir, 'AppData', 'Local', 'Naver', 'Whale', 'User Data')]
}
if (browser === 'opera') {
return [path.join(homeDir, 'AppData', 'Roaming', 'Opera Software', 'Opera Stable')]
}
if (browser === 'firefox') {
return [path.join(homeDir, 'AppData', 'Roaming', 'Mozilla', 'Firefox', 'Profiles')]
}
}
if (platform === 'darwin') {
if (browser === 'edge') {
return [path.join(homeDir, 'Library', 'Application Support', 'Microsoft Edge')]
}
if (browser === 'chrome') {
return [path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome')]
}
if (browser === 'chromium') {
return [path.join(homeDir, 'Library', 'Application Support', 'Chromium')]
}
if (browser === 'brave') {
return [
path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser')
]
}
if (browser === 'vivaldi') {
return [path.join(homeDir, 'Library', 'Application Support', 'Vivaldi')]
}
if (browser === 'whale') {
return [
path.join(homeDir, 'Library', 'Application Support', 'Whale'),
path.join(homeDir, 'Library', 'Application Support', 'Naver Whale')
]
}
if (browser === 'opera') {
return [
path.join(homeDir, 'Library', 'Application Support', 'com.operasoftware.Opera'),
path.join(homeDir, 'Library', 'Application Support', 'Opera Software', 'Opera Stable')
]
}
if (browser === 'firefox') {
return [path.join(homeDir, 'Library', 'Application Support', 'Firefox', 'Profiles')]
}
if (browser === 'safari') {
return [path.join(homeDir, 'Library', 'Safari')]
}
}
if (platform === 'linux') {
if (browser === 'edge') {
return [path.join(homeDir, '.config', 'microsoft-edge')]
}
if (browser === 'chrome') {
return [path.join(homeDir, '.config', 'google-chrome')]
}
if (browser === 'chromium') {
return [path.join(homeDir, '.config', 'chromium')]
}
if (browser === 'brave') {
return [path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser')]
}
if (browser === 'vivaldi') {
return [path.join(homeDir, '.config', 'vivaldi')]
}
if (browser === 'whale') {
return [path.join(homeDir, '.config', 'naver-whale')]
}
if (browser === 'opera') {
return [path.join(homeDir, '.config', 'opera')]
}
if (browser === 'firefox') {
return [path.join(homeDir, '.mozilla', 'firefox')]
}
}
if (platform === 'freebsd') {
if (browser === 'firefox') {
return [path.join(homeDir, '.mozilla', 'firefox')]
}
}
return []
}
private getDefaultProfilePath(baseDirs: string[], browser: string): string {
const base = baseDirs[0]
if (!base) {
return ''
}
if (browser === 'firefox' || browser === 'safari' || browser === 'opera') {
return base
}
return path.join(base, 'Default')
}
private findFirefoxProfilePath(profilesDir: string): string {
if (!this.isDirectory(profilesDir)) {
return ''
}
const entries = fs
.readdirSync(profilesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b))
const preferred =
entries.find((name) => name.endsWith('.default-release')) ??
entries.find((name) => name.endsWith('.default')) ??
entries[0]
return preferred ? path.join(profilesDir, preferred) : ''
}
@IpcMethod()
getBrowserProfilePath(_context: IpcContext, browser: string): string {
if (!browser || browser === 'none') {
return ''
}
const homeDir = os.homedir()
const platform = os.platform()
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
const fallbackPath = this.getDefaultProfilePath(baseDirs, browser)
if (browser === 'firefox') {
const profilesDir = baseDirs[0]
const profilePath = profilesDir ? this.findFirefoxProfilePath(profilesDir) : ''
return profilePath || fallbackPath
}
if (browser === 'safari') {
const safariPath = baseDirs[0]
if (safariPath && this.isDirectory(safariPath)) {
return safariPath
}
return fallbackPath
}
if (baseDirs.length === 0) {
return fallbackPath
}
let detectedPath = ''
for (const baseDir of baseDirs) {
if (!baseDir) {
continue
}
const candidates =
browser === 'opera'
? [baseDir, path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
: [path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
detectedPath = this.pickFirstDirectory(candidates)
if (detectedPath) {
break
}
}
return detectedPath || fallbackPath
}
@IpcMethod()
validateBrowserProfilePath(
_context: IpcContext,
browser: string,
profilePath: string
): { valid: boolean; reason?: string } {
if (!browser || browser === 'none') {
return this.buildValidationResult(false, 'browserUnsupported')
}
const normalizedInput = this.normalizeProfileInput(profilePath)
if (!normalizedInput) {
return this.buildValidationResult(false, 'empty')
}
const resolvedInput = resolvePathWithHome(normalizedInput)
if (resolvedInput && this.isDirectory(resolvedInput)) {
return this.buildValidationResult(true)
}
const looksLikePath =
resolvedInput &&
(path.isAbsolute(resolvedInput) ||
resolvedInput.includes('/') ||
resolvedInput.includes('\\'))
if (looksLikePath) {
return this.buildValidationResult(false, 'pathNotFound')
}
const platform = os.platform()
const homeDir = os.homedir()
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
if (baseDirs.length === 0) {
return this.buildValidationResult(false, 'browserUnsupported')
}
for (const baseDir of baseDirs) {
if (!baseDir) {
continue
}
const candidate = path.join(baseDir, normalizedInput)
if (this.isDirectory(candidate)) {
return this.buildValidationResult(true)
}
}
return this.buildValidationResult(false, 'profileNotFound')
}
}
export { BrowserCookiesService }

191
src/main/local-api.ts Normal file
View File

@@ -0,0 +1,191 @@
import crypto from 'node:crypto'
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import log from 'electron-log/main'
import { downloadEngine } from './lib/download-engine'
const PORT_RANGE_START = 27100
const PORT_RANGE_END = 27120
const TOKEN_TTL_MS = 60_000
type TokenRecord = {
expiresAt: number
}
let server: http.Server | null = null
let serverPort: number | null = null
const tokens = new Map<string, TokenRecord>()
const isLoopbackAddress = (address?: string | null): boolean => {
if (!address) return false
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
}
const writeJson = (res: http.ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
})
res.end(JSON.stringify(body))
}
const writeEmpty = (res: http.ServerResponse, status: number): void => {
res.writeHead(status, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
})
res.end()
}
const issueToken = (): string => {
const token = crypto.randomBytes(16).toString('hex')
tokens.set(token, { expiresAt: Date.now() + TOKEN_TTL_MS })
return token
}
const consumeToken = (token?: string | null): boolean => {
if (!token) return false
const record = tokens.get(token)
if (!record) return false
if (Date.now() > record.expiresAt) {
tokens.delete(token)
return false
}
tokens.delete(token)
return true
}
const handleRequest = async (
req: http.IncomingMessage,
res: http.ServerResponse
): Promise<void> => {
try {
if (!isLoopbackAddress(req.socket.remoteAddress)) {
writeJson(res, 403, { error: 'Forbidden' })
return
}
if (req.method === 'OPTIONS') {
writeEmpty(res, 204)
return
}
if (!req.url) {
writeJson(res, 400, { error: 'Missing URL' })
return
}
const requestUrl = new URL(req.url, 'http://127.0.0.1')
const pathname = requestUrl.pathname
if (req.method !== 'GET') {
writeJson(res, 405, { error: 'Method not allowed' })
return
}
if (pathname === '/token') {
const token = issueToken()
writeJson(res, 200, { token, expiresInMs: TOKEN_TTL_MS })
return
}
if (pathname === '/video-info') {
const token = requestUrl.searchParams.get('token')
if (!consumeToken(token)) {
writeJson(res, 401, { error: 'Invalid token' })
return
}
const targetUrl = requestUrl.searchParams.get('url')
if (!targetUrl || !targetUrl.trim()) {
writeJson(res, 400, { error: 'Missing url' })
return
}
try {
const info = await downloadEngine.getVideoInfo(targetUrl.trim())
writeJson(res, 200, {
title: info.title,
thumbnail: info.thumbnail,
duration: info.duration,
formats: info.formats ?? []
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch video info'
const details =
error instanceof Error
? error.stack
: typeof error === 'object' && error && 'stderr' in error
? String((error as { stderr?: unknown }).stderr ?? '')
: undefined
writeJson(res, 500, { error: message, details })
}
return
}
if (pathname === '/status') {
writeJson(res, 200, { ok: true })
return
}
writeJson(res, 404, { error: 'Not found' })
} catch (error) {
const message = error instanceof Error ? error.message : 'Unhandled request error'
writeJson(res, 500, { error: message })
}
}
const startServerOnPort = (port: number): Promise<http.Server> =>
new Promise((resolve, reject) => {
const httpServer = http.createServer((req, res) => {
void handleRequest(req, res)
})
httpServer.once('error', (error) => {
httpServer.close()
reject(error)
})
httpServer.listen(port, '127.0.0.1', () => resolve(httpServer))
})
export async function startExtensionApiServer(): Promise<number | null> {
if (server && serverPort) {
return serverPort
}
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
try {
server = await startServerOnPort(port)
const address = server.address() as AddressInfo | null
serverPort = address?.port ?? port
log.info(`Extension API listening on 127.0.0.1:${serverPort}`)
return serverPort
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err.code !== 'EADDRINUSE') {
log.warn('Extension API failed to start on port:', port, err)
}
}
}
log.error(`Extension API failed to bind any port in range ${PORT_RANGE_START}-${PORT_RANGE_END}`)
return null
}
export async function stopExtensionApiServer(): Promise<void> {
if (!server) return
await new Promise<void>((resolve) => {
server?.close(() => resolve())
})
server = null
serverPort = null
tokens.clear()
}

View File

@@ -55,7 +55,7 @@ function AppContent() {
const loadSettings = useSetAtom(loadSettingsAtom)
const setUpdateReady = useSetAtom(updateReadyAtom)
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
const { t } = useTranslation()
const { i18n } = useTranslation()
const updateDownloadInProgressRef = useRef(false)
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
const navigate = useNavigate()
@@ -197,14 +197,27 @@ function AppContent() {
available: true,
version: info.version
})
const versionLabel = info?.version ?? ''
const downloadedMessage = versionLabel
? i18n.t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: i18n.t('about.notifications.updateDownloaded')
toast.info(downloadedMessage, {
action: {
label: i18n.t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
const handleUpdateError = (rawMessage: unknown) => {
const message = typeof rawMessage === 'string' ? rawMessage : ''
resetDownloadState()
const errorMessage = message || t('about.notifications.unknownErrorFallback')
toast.error(t('about.notifications.updateError', { error: errorMessage }))
const errorMessage = message || i18n.t('about.notifications.unknownErrorFallback')
toast.error(i18n.t('about.notifications.updateError', { error: errorMessage }))
}
const handleDownloadProgress = (rawProgress: unknown) => {
@@ -214,39 +227,20 @@ function AppContent() {
}
}
const handleUpdateNotification = (rawPayload: unknown) => {
const payload = (rawPayload ?? {}) as { version?: string }
const versionLabel = payload?.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.info(downloadedMessage, {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
// Only listen to update events that should be shown globally
// update:available shows a visual indicator in the sidebar
ipcEvents.on('update:available', handleUpdateAvailable)
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
ipcEvents.on('update:error', handleUpdateError)
ipcEvents.on('update:download-progress', handleDownloadProgress)
ipcEvents.on('update:show-notification', handleUpdateNotification)
return () => {
ipcEvents.removeListener('update:available', handleUpdateAvailable)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
ipcEvents.removeListener('update:error', handleUpdateError)
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
}
}, [setUpdateAvailable, setUpdateReady, t])
}, [i18n, setUpdateAvailable, setUpdateReady])
return (
<div className="flex flex-row h-screen">

View File

@@ -400,6 +400,10 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
const statusIcon = getStatusIcon()
const statusText = getStatusText()
const progressInfo = download.progress
const showInlineProgress = Boolean(
progressInfo && download.status !== 'completed' && download.status !== 'error'
)
const sourceDisplay =
download.uploader && download.channel && download.uploader !== download.channel
? `${download.uploader}${download.channel}`
@@ -725,6 +729,24 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
</TooltipContent>
</Tooltip>
)}
{showInlineProgress && (
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium shrink-0">
{(progressInfo?.percent ?? 0).toFixed(1)}%
</span>
{progressInfo?.downloaded && progressInfo?.total && (
<span className="truncate max-w-[120px]">
{progressInfo.downloaded} / {progressInfo.total}
</span>
)}
{progressInfo?.currentSpeed && (
<span className="truncate max-w-[80px]">{progressInfo.currentSpeed}</span>
)}
{progressInfo?.eta && (
<span className="truncate max-w-[80px]">ETA: {progressInfo.eta}</span>
)}
</div>
)}
{/* Timestamp */}
{timestamp && (
<span className="truncate shrink-0">{formatDateShort(timestamp)}</span>
@@ -898,26 +920,8 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
{/* Progress */}
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
<div className="space-y-1 bg-background/60 w-full overflow-hidden">
<div className="bg-background/60 w-full overflow-hidden">
<Progress value={download.progress.percent} className="h-1 w-full" />
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground w-full">
<span className="font-medium shrink-0">
{download.progress.percent.toFixed(1)}%
</span>
<div className="flex flex-wrap items-center gap-2 min-w-0 flex-1">
{download.progress.downloaded && download.progress.total && (
<span className="truncate max-w-[100px]">
{download.progress.downloaded} / {download.progress.total}
</span>
)}
{download.progress.currentSpeed && (
<span className="truncate max-w-[80px]">{download.progress.currentSpeed}</span>
)}
{download.progress.eta && (
<span className="truncate max-w-[80px]">ETA: {download.progress.eta}</span>
)}
</div>
</div>
</div>
)}

View File

@@ -253,17 +253,17 @@ export function FormatSelector({
onVideoFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
<SelectContent>
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
className="cursor-pointer"
>
<span className="text-sm">{formatVideoLabel(format)}</span>
<span>{formatVideoLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
@@ -281,18 +281,18 @@ export function FormatSelector({
onAudioFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
<SelectItem value="none" className="cursor-pointer py-2.5">
<span className="text-sm">{t('download.noAudio')}</span>
<SelectContent>
<SelectItem value="none" className="cursor-pointer">
<span>{t('download.noAudio')}</span>
</SelectItem>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
className="cursor-pointer"
>
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
@@ -320,17 +320,13 @@ export function FormatSelector({
onAudioFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
<SelectContent>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatAudioLabel(format)}</span>
<SelectItem key={format.format_id} value={format.format_id} className="cursor-pointer">
<span>{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -89,6 +89,7 @@
"currentLocation": "Current download location - ",
"downloadLocation": "Download location",
"downloadSubs": "Download subtitles if available",
"downloadSubsHint": "Save subtitles as separate files when available",
"end": "End",
"endHint": "If kept empty, it will be downloaded to the end",
"endPlaceholder": "10:00",
@@ -372,7 +373,15 @@
"app": "App Settings",
"audio": "Audio Preferences",
"browserForCookies": "Select browser to use cookies from",
"browserForCookiesDescription": "Browser to extract cookies from for authentication",
"browserForCookiesDescription": "Browser to extract cookies from for authentication. We'll try to detect a profile automatically.",
"browserForCookiesProfile": "Profile name or path",
"browserForCookiesProfileDescription": "Profile path for the browser selected above. Auto-filled when possible.",
"browserForCookiesProfilePlaceholder": "Profile name or full path (optional)",
"browserForCookiesProfileInvalid": "Profile path is not valid. Choose the profile folder for the selected browser.",
"browserForCookiesProfileInvalidPath": "That folder does not exist. Pick an existing profile folder.",
"browserForCookiesProfileInvalidProfile": "Profile name not found in the default browser location.",
"browserForCookiesProfileInvalidUnsupported": "No default profile location is known for this browser on this platform.",
"browserForCookiesProfileInvalidEmpty": "Enter a profile path for the selected browser.",
"cookiesFile": "Cookies file",
"cookiesFileDescription": "Netscape formatted cookies file to load for authentication",
"clearCookiesFile": "Clear",
@@ -387,7 +396,10 @@
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
"opera": "Opera",
"safari": "Safari",
"vivaldi": "Vivaldi",
"whale": "Whale"
},
"configFile": "Use configuration file",
"configFileDescription": "Custom configuration file for yt-dlp",
@@ -409,6 +421,14 @@
"launchAtLoginUnsupported": "Auto launch is only available on macOS and Windows.",
"enableAnalytics": "Help improve VidBee",
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
"embedChapters": "Embed chapters",
"embedChaptersDescription": "Add chapter markers to the file when available",
"embedMetadata": "Embed metadata",
"embedMetadataDescription": "Write title, artist, and other metadata when available",
"embedSubs": "Embed subtitles",
"embedSubsDescription": "Embed subtitles into the video file (mp4, webm, mkv)",
"embedThumbnail": "Embed thumbnail",
"embedThumbnailDescription": "Add the thumbnail as cover art",
"maxConcurrentDownloads": "Maximum number of active downloads",
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
"none": "None",

View File

@@ -39,9 +39,10 @@ type LatestVersionState =
| null
export function About() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const [settings, _setSettings] = useAtom(settingsAtom)
const [updateReady] = useAtom(updateReadyAtom)
const [updateAvailableState] = useAtom(updateAvailableAtom)
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
const [appVersion, setAppVersion] = useState<string>('—')
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
@@ -70,6 +71,17 @@ export function About() {
}
}, [])
useEffect(() => {
if (!updateAvailableState.available) {
return
}
setLatestVersionState({
status: 'available',
version: updateAvailableState.version ?? ''
})
}, [updateAvailableState.available, updateAvailableState.version])
// Listen for update events only in About page
useEffect(() => {
if (!window?.api) {
@@ -81,7 +93,7 @@ export function About() {
const versionLabel = info.version ?? ''
// Update will be downloaded automatically because autoDownload is enabled in main process
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }))
toast.success(i18n.t('about.notifications.updateAvailable', { version: versionLabel }))
setLatestVersionState({
status: 'available',
version: versionLabel
@@ -115,7 +127,7 @@ export function About() {
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
}
}, [setUpdateAvailable, t])
}, [i18n, setUpdateAvailable])
const handleSettingChange = async (
key: keyof typeof settings,
@@ -264,6 +276,8 @@ export function About() {
? 'text-destructive'
: 'text-muted-foreground'
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
const shouldShowCheckUpdates =
!updateAvailableState.available && latestVersionState?.status !== 'available'
const handleXFeedback = useCallback(() => {
const versionText = appVersion !== '—' ? ` VidBee v${appVersion}` : ' VidBee'
@@ -379,10 +393,12 @@ export function About() {
{t('about.actions.goToDownload')}
</Button>
) : null}
<Button onClick={handleCheckForUpdates} size="sm" className="gap-2">
<RefreshCw className="h-3.5 w-3.5" />
{t('about.actions.checkUpdates')}
</Button>
{shouldShowCheckUpdates ? (
<Button onClick={handleCheckForUpdates} size="sm" className="gap-2">
<RefreshCw className="h-3.5 w-3.5" />
{t('about.actions.checkUpdates')}
</Button>
) : null}
</div>
</div>
<p className="text-sm text-muted-foreground">{t('about.description')}</p>

View File

@@ -18,17 +18,46 @@ import {
} from '@renderer/components/ui/select'
import { Switch } from '@renderer/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
import type { OneClickQualityPreset } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { AlertTriangle, CheckCircle2 } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcServices } from '../lib/ipc'
import { logger } from '../lib/logger'
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
const normalizeProfileInput = (value: string) => value.trim().replace(/^['"]|['"]$/g, '')
const parseBrowserCookiesSetting = (value: string | undefined) => {
if (!value || value === 'none') {
return { browser: 'none', profile: '' }
}
const separatorIndex = value.indexOf(':')
if (separatorIndex === -1) {
return { browser: value, profile: '' }
}
const browser = value.slice(0, separatorIndex).trim()
const profile = normalizeProfileInput(value.slice(separatorIndex + 1))
return { browser: browser || 'none', profile }
}
const buildBrowserCookiesSetting = (browser: string, profile: string) => {
const trimmedBrowser = browser.trim()
if (!trimmedBrowser || trimmedBrowser === 'none') {
return 'none'
}
const trimmedProfile = normalizeProfileInput(profile)
return trimmedProfile ? `${trimmedBrowser}:${trimmedProfile}` : trimmedBrowser
}
export function Settings() {
const { t, i18n: i18nInstance } = useTranslation()
const { theme, setTheme } = useTheme()
@@ -37,24 +66,20 @@ export function Settings() {
const saveSetting = useSetAtom(saveSettingAtom)
const [platform, setPlatform] = useState<string>('')
const [activeTab, setActiveTab] = useState<string>('general')
const [browserProfileValidation, setBrowserProfileValidation] = useState<{
valid: boolean
reason?: string
}>({ valid: false })
const lastAutoDetectBrowser = useRef<string | null>(null)
useEffect(() => {
logger.info('[Settings] Component mounted, loading settings...')
try {
loadSettings()
// Note: settings will be logged in the next useEffect after it's loaded
} catch (error) {
logger.error('[Settings] Failed to load settings:', error)
}
}, [loadSettings])
useEffect(() => {
logger.info('[Settings] Settings state updated', {
settingsKeys: Object.keys(settings),
settingsValues: settings
})
}, [settings])
useEffect(() => {
const fetchPlatform = async () => {
try {
@@ -70,20 +95,17 @@ export function Settings() {
const autoLaunchSupported = platform === 'darwin' || platform === 'win32'
const handleSettingChange = async (
key: keyof typeof settings,
value: (typeof settings)[keyof typeof settings]
) => {
try {
logger.info('[Settings] Changing setting', { key, value, currentValue: settings[key] })
await saveSetting({ key, value })
toast.success(t('notifications.settingsSaved'))
logger.info('[Settings] Setting changed successfully', { key, value })
} catch (error) {
logger.error('[Settings] Failed to change setting', { key, value, error })
toast.error(t('settings.saveError') || 'Failed to save setting')
}
}
const handleSettingChange = useCallback(
async (key: keyof typeof settings, value: (typeof settings)[keyof typeof settings]) => {
try {
await saveSetting({ key, value })
} catch (error) {
logger.error('[Settings] Failed to change setting', { key, value, error })
toast.error(t('settings.saveError') || 'Failed to save setting')
}
},
[saveSetting, t]
)
const handleSelectPath = async () => {
try {
@@ -146,6 +168,97 @@ export function Settings() {
const activeLanguageCode = normalizeLanguageCode(i18nInstance.language)
const currentLanguage =
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
const parsedBrowserCookies = parseBrowserCookiesSetting(settings.browserForCookies)
const browserForCookiesValue = parsedBrowserCookies.browser
const browserCookiesProfileValue = parsedBrowserCookies.profile
const normalizedBrowserCookiesSetting = buildBrowserCookiesSetting(
browserForCookiesValue,
browserCookiesProfileValue
)
const hasBrowserProfileValue = browserCookiesProfileValue.trim().length > 0
const showBrowserProfileCheck = hasBrowserProfileValue && browserProfileValidation.valid
const showBrowserProfileWarning = hasBrowserProfileValue && !browserProfileValidation.valid
const getBrowserProfileWarningMessage = (reason?: string) => {
switch (reason) {
case 'pathNotFound':
return t('settings.browserForCookiesProfileInvalidPath')
case 'profileNotFound':
return t('settings.browserForCookiesProfileInvalidProfile')
case 'browserUnsupported':
return t('settings.browserForCookiesProfileInvalidUnsupported')
case 'empty':
return t('settings.browserForCookiesProfileInvalidEmpty')
default:
return t('settings.browserForCookiesProfileInvalid')
}
}
useEffect(() => {
if (settings.browserForCookies !== normalizedBrowserCookiesSetting) {
void handleSettingChange('browserForCookies', normalizedBrowserCookiesSetting)
}
}, [handleSettingChange, normalizedBrowserCookiesSetting, settings.browserForCookies])
useEffect(() => {
const browserChanged = lastAutoDetectBrowser.current !== browserForCookiesValue
const shouldAutoDetect =
browserForCookiesValue !== 'none' && (browserChanged || !browserCookiesProfileValue)
if (!shouldAutoDetect) {
lastAutoDetectBrowser.current = browserForCookiesValue
return
}
const detectProfilePath = async () => {
try {
const detectedPath =
await ipcServices.browserCookies.getBrowserProfilePath(browserForCookiesValue)
const nextProfileValue = detectedPath || ''
if (nextProfileValue !== browserCookiesProfileValue) {
const nextValue = buildBrowserCookiesSetting(browserForCookiesValue, nextProfileValue)
await handleSettingChange('browserForCookies', nextValue)
}
} catch (error) {
logger.error('[Settings] Failed to detect browser profile path:', error)
} finally {
lastAutoDetectBrowser.current = browserForCookiesValue
}
}
void detectProfilePath()
}, [browserForCookiesValue, browserCookiesProfileValue, handleSettingChange])
useEffect(() => {
if (browserForCookiesValue === 'none' || !hasBrowserProfileValue) {
setBrowserProfileValidation({ valid: false, reason: 'empty' })
return
}
let isActive = true
const validateProfilePath = async () => {
try {
const result = await ipcServices.browserCookies.validateBrowserProfilePath(
browserForCookiesValue,
browserCookiesProfileValue
)
if (isActive) {
setBrowserProfileValidation(result)
}
} catch (error) {
if (isActive) {
setBrowserProfileValidation({ valid: false, reason: 'pathNotFound' })
}
logger.error('[Settings] Failed to validate browser profile path:', error)
}
}
void validateProfilePath()
return () => {
isActive = false
}
}, [browserForCookiesValue, browserCookiesProfileValue, hasBrowserProfileValue])
const handleLanguageChange = async (value: LanguageCode) => {
if (activeLanguageCode === value) {
@@ -154,7 +267,6 @@ export function Settings() {
await saveSetting({ key: 'language', value })
await i18nInstance.changeLanguage(value)
toast.success(t('notifications.settingsSaved'))
}
return (
@@ -168,24 +280,7 @@ export function Settings() {
<Tabs
value={activeTab}
onValueChange={(value) => {
logger.info('[Settings] Tab changed', { from: activeTab, to: value })
setActiveTab(value)
try {
if (value === 'advanced') {
logger.info('[Settings] Entering advanced tab', {
settings: settings,
settingsKeys: Object.keys(settings),
maxConcurrentDownloads: settings.maxConcurrentDownloads,
browserForCookies: settings.browserForCookies,
cookiesPath: settings.cookiesPath,
proxy: settings.proxy,
configPath: settings.configPath,
enableAnalytics: settings.enableAnalytics
})
}
} catch (error) {
logger.error('[Settings] Error when entering advanced tab:', error)
}
}}
>
<TabsList className="grid w-full grid-cols-2">
@@ -414,7 +509,6 @@ export function Settings() {
checked={settings.showMoreFormats ?? false}
onCheckedChange={(value) => {
try {
logger.info('[Settings] Toggling showMoreFormats', { value })
handleSettingChange('showMoreFormats', value)
} catch (error) {
logger.error('[Settings] Error toggling showMoreFormats:', error)
@@ -425,6 +519,94 @@ export function Settings() {
</Item>
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.embedSubs')}</ItemTitle>
<ItemDescription>{t('settings.embedSubsDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.embedSubs ?? false}
onCheckedChange={(value) => {
try {
handleSettingChange('embedSubs', value)
} catch (error) {
logger.error('[Settings] Error toggling embedSubs:', error)
}
}}
/>
</ItemActions>
</Item>
<ItemSeparator />
{platform !== 'darwin' && (
<>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.embedThumbnail ?? false}
onCheckedChange={(value) => {
try {
handleSettingChange('embedThumbnail', value)
} catch (error) {
logger.error('[Settings] Error toggling embedThumbnail:', error)
}
}}
/>
</ItemActions>
</Item>
<ItemSeparator />
</>
)}
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.embedMetadata')}</ItemTitle>
<ItemDescription>{t('settings.embedMetadataDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.embedMetadata ?? false}
onCheckedChange={(value) => {
try {
handleSettingChange('embedMetadata', value)
} catch (error) {
logger.error('[Settings] Error toggling embedMetadata:', error)
}
}}
/>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.embedChapters')}</ItemTitle>
<ItemDescription>{t('settings.embedChaptersDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.embedChapters ?? true}
onCheckedChange={(value) => {
try {
handleSettingChange('embedChapters', value)
} catch (error) {
logger.error('[Settings] Error toggling embedChapters:', error)
}
}}
/>
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
@@ -438,22 +620,12 @@ export function Settings() {
try {
const maxConcurrent = settings.maxConcurrentDownloads ?? 5
const maxConcurrentStr = maxConcurrent.toString()
logger.info('[Settings] Rendering max concurrent downloads select', {
maxConcurrent,
maxConcurrentStr,
type: typeof maxConcurrent
})
return (
<Select
value={maxConcurrentStr}
onValueChange={(value) => {
try {
const numValue = Number(value)
logger.info('[Settings] Max concurrent downloads changed', {
oldValue: maxConcurrent,
newValue: numValue,
stringValue: value
})
handleSettingChange('maxConcurrentDownloads', numValue)
} catch (error) {
logger.error(
@@ -497,17 +669,12 @@ export function Settings() {
{(() => {
try {
const proxyValue = settings.proxy ?? ''
logger.info('[Settings] Rendering proxy input', { proxyValue })
return (
<Input
placeholder={t('settings.proxyPlaceholder')}
value={proxyValue}
onChange={(e) => {
try {
logger.info('[Settings] Proxy value changed', {
oldValue: proxyValue,
newValue: e.target.value
})
handleSettingChange('proxy', e.target.value)
} catch (error) {
logger.error('[Settings] Error changing proxy:', error)
@@ -523,48 +690,6 @@ export function Settings() {
})()}
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.configFile')}</ItemTitle>
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
{(() => {
try {
const configPathValue = settings.configPath ?? ''
logger.info('[Settings] Rendering config file input', { configPathValue })
return (
<div className="flex gap-2 w-full max-w-md">
<Input value={configPathValue} readOnly className="flex-1" />
<Button onClick={handleSelectConfigFile}>
{t('settings.selectPath')}
</Button>
<Button
variant="secondary"
onClick={() => {
try {
logger.info('[Settings] Clearing config path')
void handleSettingChange('configPath', '')
} catch (error) {
logger.error('[Settings] Error clearing config path:', error)
}
}}
disabled={!configPathValue}
>
{t('settings.clearConfigFile')}
</Button>
</div>
)
} catch (error) {
logger.error('[Settings] Error rendering config file input:', error)
return <div>Error loading config file setting</div>
}
})()}
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
@@ -576,20 +701,13 @@ export function Settings() {
<ItemActions>
{(() => {
try {
const browserValue = settings.browserForCookies ?? 'none'
logger.info('[Settings] Rendering browser for cookies select', {
browserValue
})
return (
<Select
value={browserValue}
value={browserForCookiesValue}
onValueChange={(value) => {
try {
logger.info('[Settings] Browser for cookies changed', {
oldValue: browserValue,
newValue: value
})
handleSettingChange('browserForCookies', value)
const nextValue = buildBrowserCookiesSetting(value, '')
handleSettingChange('browserForCookies', nextValue)
} catch (error) {
logger.error('[Settings] Error changing browser for cookies:', error)
}
@@ -618,6 +736,15 @@ export function Settings() {
<SelectItem value="brave">
{t('settings.browserOptions.brave')}
</SelectItem>
<SelectItem value="opera">
{t('settings.browserOptions.opera')}
</SelectItem>
<SelectItem value="vivaldi">
{t('settings.browserOptions.vivaldi')}
</SelectItem>
<SelectItem value="whale">
{t('settings.browserOptions.whale')}
</SelectItem>
</SelectContent>
</Select>
)
@@ -631,6 +758,72 @@ export function Settings() {
<ItemSeparator />
<Item variant="muted">
<ItemContent className="basis-full">
<ItemTitle>{t('settings.browserForCookiesProfile')}</ItemTitle>
<ItemDescription>
{t('settings.browserForCookiesProfileDescription')}
</ItemDescription>
</ItemContent>
<ItemActions className="basis-full">
{(() => {
try {
return (
<div className="relative w-full">
<Input
placeholder={t('settings.browserForCookiesProfilePlaceholder')}
value={browserCookiesProfileValue}
onChange={(event) => {
try {
const newProfileValue = event.target.value
const nextValue = buildBrowserCookiesSetting(
browserForCookiesValue,
newProfileValue
)
handleSettingChange('browserForCookies', nextValue)
} catch (error) {
logger.error(
'[Settings] Error changing browser cookies profile:',
error
)
}
}}
disabled={browserForCookiesValue === 'none'}
className="w-full pr-10"
/>
{showBrowserProfileCheck ? (
<CheckCircle2
className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-emerald-500"
aria-hidden
/>
) : null}
{showBrowserProfileWarning ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="absolute right-3 top-1/2 inline-flex h-4 w-4 -translate-y-1/2 items-center justify-center text-amber-500">
<AlertTriangle className="h-4 w-4" aria-hidden />
</span>
</TooltipTrigger>
<TooltipContent>
{getBrowserProfileWarningMessage(browserProfileValidation.reason)}
</TooltipContent>
</Tooltip>
) : null}
</div>
)
} catch (error) {
logger.error(
'[Settings] Error rendering browser cookies profile input:',
error
)
return <div>Error loading browser cookies profile setting</div>
}
})()}
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.cookiesFile')}</ItemTitle>
@@ -640,7 +833,6 @@ export function Settings() {
{(() => {
try {
const cookiesPathValue = settings.cookiesPath ?? ''
logger.info('[Settings] Rendering cookies file input', { cookiesPathValue })
return (
<div className="flex gap-2 w-full max-w-md">
<Input value={cookiesPathValue} readOnly className="flex-1" />
@@ -651,7 +843,6 @@ export function Settings() {
variant="secondary"
onClick={() => {
try {
logger.info('[Settings] Clearing cookies path')
void handleSettingChange('cookiesPath', '')
} catch (error) {
logger.error('[Settings] Error clearing cookies path:', error)
@@ -687,6 +878,46 @@ export function Settings() {
</Button>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.configFile')}</ItemTitle>
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
{(() => {
try {
const configPathValue = settings.configPath ?? ''
return (
<div className="flex gap-2 w-full max-w-md">
<Input value={configPathValue} readOnly className="flex-1" />
<Button onClick={handleSelectConfigFile}>
{t('settings.selectPath')}
</Button>
<Button
variant="secondary"
onClick={() => {
try {
void handleSettingChange('configPath', '')
} catch (error) {
logger.error('[Settings] Error clearing config path:', error)
}
}}
disabled={!configPathValue}
>
{t('settings.clearConfigFile')}
</Button>
</div>
)
} catch (error) {
logger.error('[Settings] Error rendering config file input:', error)
return <div>Error loading config file setting</div>
}
})()}
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
@@ -699,18 +930,11 @@ export function Settings() {
{(() => {
try {
const analyticsValue = settings.enableAnalytics ?? true
logger.info('[Settings] Rendering enable analytics switch', {
analyticsValue
})
return (
<Switch
checked={analyticsValue}
onCheckedChange={(value) => {
try {
logger.info('[Settings] Enable analytics changed', {
oldValue: analyticsValue,
newValue: value
})
handleSettingChange('enableAnalytics', value)
} catch (error) {
logger.error('[Settings] Error changing enable analytics:', error)

View File

@@ -271,6 +271,10 @@ export interface AppSettings {
autoUpdate: boolean
subscriptionOnlyLatestDefault: boolean
enableAnalytics: boolean
embedSubs: boolean
embedThumbnail: boolean
embedMetadata: boolean
embedChapters: boolean
}
export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s'
@@ -294,5 +298,9 @@ export const defaultSettings: AppSettings = {
launchAtLogin: false,
autoUpdate: true,
subscriptionOnlyLatestDefault: true,
enableAnalytics: true
enableAnalytics: true,
embedSubs: true,
embedThumbnail: true,
embedMetadata: true,
embedChapters: true
}