Compare commits

..

10 Commits

Author SHA1 Message Date
Nexmoe
8acd761189 chore: release v1.1.10 2026-01-12 22:29:52 +08:00
Nexmoe
20c6ae6543 fix(ci): notarize dmg in workflow (#87)
* chore: release v1.1.9

* fix(ci): notarize dmg in workflow
2026-01-12 22:29:14 +08:00
Nexmoe
66f2211d68 fix(build): move mac tool signing to beforeSign (#86)
* fix(build): move tool signing to beforeSign

* ci(macos): verify signing and notarization

* fix(build): move signing hook to afterPack
2026-01-12 21:53:39 +08:00
Nexmoe
ac183b4e3c build(mac): sign bundled tools (#85) 2026-01-12 21:19:18 +08:00
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
10 changed files with 217 additions and 58 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:
@@ -127,9 +138,86 @@ 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
echo "SIGNING_AVAILABLE=false" >> "$GITHUB_ENV"
# 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"
echo "SIGNING_AVAILABLE=true" >> "$GITHUB_ENV"
- name: Build application
run: ${{ matrix.build_script }}
- name: Verify macOS codesign and notarization
if: matrix.platform == 'macos' && env.SIGNING_AVAILABLE == 'true'
shell: bash
run: |
set -euo pipefail
apps_found=0
while IFS= read -r app; do
apps_found=1
echo "Verifying codesign for $app"
codesign --verify --deep --strict --verbose=2 "$app"
spctl -a -t exec -vv "$app"
echo "Validating notarization ticket for $app"
xcrun stapler validate "$app"
done < <(find dist -type d -name "*.app" -prune -print)
if [[ "$apps_found" -eq 0 ]]; then
echo "::error::No .app bundles found in dist"
exit 1
fi
dmgs_found=0
while IFS= read -r dmg; do
dmgs_found=1
echo "Submitting DMG for notarization: $dmg"
xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" --wait
echo "Stapling notarization ticket for $dmg"
xcrun stapler staple "$dmg"
echo "Validating notarization ticket for $dmg"
xcrun stapler validate "$dmg"
done < <(find dist -type f -name "*.dmg" -print)
if [[ "$dmgs_found" -eq 0 ]]; then
echo "::notice::No DMG artifacts found to validate"
fi
- name: Upload build artifacts
if: inputs.upload_artifacts == true
uses: actions/upload-artifact@v4

View File

@@ -10,6 +10,7 @@ jobs:
uses: ./.github/workflows/build.yml
with:
upload_artifacts: true
secrets: inherit
release:
needs: [build]

58
build/after-pack.cjs Normal file
View File

@@ -0,0 +1,58 @@
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const BINARIES = ['yt-dlp_macos', 'ffmpeg_macos', 'deno']
const findAppBundle = (appOutDir) => {
const entries = fs.readdirSync(appOutDir)
const app = entries.find((entry) => entry.endsWith('.app'))
return app ? path.join(appOutDir, app) : null
}
const resolveSigningIdentity = () =>
process.env.CSC_NAME || process.env.APPLE_SIGNING_IDENTITY || '-'
const signBinary = (targetPath, entitlementsPath) => {
const identity = resolveSigningIdentity()
const args = ['--force', '--sign', identity, '--entitlements', entitlementsPath]
if (identity !== '-') {
args.push('--options', 'runtime', '--timestamp')
}
args.push(targetPath)
execFileSync('codesign', args, { stdio: 'inherit' })
}
exports.default = async function afterPack(context) {
if (context.electronPlatformName !== 'darwin') {
return
}
const appBundle = findAppBundle(context.appOutDir)
if (!appBundle) {
console.warn('afterPack: No .app bundle found, skipping tool signing.')
return
}
const resourcesPath = path.join(
appBundle,
'Contents',
'Resources',
'app.asar.unpacked',
'resources'
)
const entitlementsPath = path.resolve(__dirname, 'entitlements.mac.plist')
for (const binary of BINARIES) {
const targetPath = path.join(resourcesPath, binary)
if (!fs.existsSync(targetPath)) {
console.warn(`afterPack: Missing ${binary}, skipping.`)
continue
}
console.log(`afterPack: Signing ${binary} with entitlements.`)
signBinary(targetPath, entitlementsPath)
}
}

View File

@@ -8,5 +8,7 @@
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View File

@@ -2,6 +2,7 @@ appId: com.vidbee
productName: VidBee
directories:
buildResources: build
afterPack: build/after-pack.cjs
protocols:
- name: VidBee
schemes:
@@ -28,9 +29,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

View File

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

View File

@@ -364,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')

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

@@ -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>