diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39062ab..bb7a0f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,6 +77,9 @@ jobs: run: ./scripts/test-release-media.ps1 - name: Build Tauri NSIS installer + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: pnpm tauri:build - name: Verify packaged Windows executable metadata @@ -100,12 +103,33 @@ jobs: $checksum = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() $checksumPath = "$installer.sha256" "$checksum $fileName" | Set-Content -LiteralPath $checksumPath -Encoding ascii + $signatures = @(Get-ChildItem -LiteralPath "src-tauri/target/release/bundle/nsis" -File -Filter "*.sig") + if ($signatures.Count -ne 1) { + throw "Expected exactly one Tauri updater signature; found $($signatures.Count)." + } + $signaturePath = "$installer.sig" + Copy-Item -LiteralPath $signatures[0].FullName -Destination $signaturePath + $releaseUrl = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:GITHUB_REF_NAME/$fileName" + $latestPath = Join-Path $releaseDirectory "latest.json" + @{ + version = $version + notes = (Get-Content -LiteralPath "docs/releases/$env:GITHUB_REF_NAME.md" -Raw) + pub_date = (Get-Date).ToUniversalTime().ToString("o") + platforms = @{ + "windows-x86_64" = @{ + signature = (Get-Content -LiteralPath $signaturePath -Raw).Trim() + url = $releaseUrl + } + } + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $latestPath -Encoding utf8 $notes = "docs/releases/$($env:GITHUB_REF_NAME).md" if (-not (Test-Path -LiteralPath $notes -PathType Leaf)) { throw "Release notes are missing: $notes" } "installer=$installer" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "checksum=$checksumPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "signature=$signaturePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "latest=$latestPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "notes=$notes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "prerelease=$($version.Contains('-').ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 @@ -115,18 +139,39 @@ jobs: GH_TOKEN: ${{ github.token }} INSTALLER: ${{ steps.package.outputs.installer }} CHECKSUM: ${{ steps.package.outputs.checksum }} + SIGNATURE: ${{ steps.package.outputs.signature }} + LATEST: ${{ steps.package.outputs.latest }} NOTES: ${{ steps.package.outputs.notes }} IS_PRERELEASE: ${{ steps.package.outputs.prerelease }} run: | $title = "Reelspace $($env:GITHUB_REF_NAME.Substring(1))" if (gh release view $env:GITHUB_REF_NAME --repo $env:GITHUB_REPOSITORY 2>$null) { gh release edit $env:GITHUB_REF_NAME --repo $env:GITHUB_REPOSITORY --title $title --notes-file $env:NOTES - gh release upload $env:GITHUB_REF_NAME $env:INSTALLER $env:CHECKSUM --repo $env:GITHUB_REPOSITORY --clobber + gh release upload $env:GITHUB_REF_NAME $env:INSTALLER $env:CHECKSUM $env:SIGNATURE $env:LATEST --repo $env:GITHUB_REPOSITORY --clobber } else { - $arguments = @("release", "create", $env:GITHUB_REF_NAME, $env:INSTALLER, $env:CHECKSUM, "--repo", $env:GITHUB_REPOSITORY, "--verify-tag", "--title", $title, "--notes-file", $env:NOTES) + $arguments = @("release", "create", $env:GITHUB_REF_NAME, $env:INSTALLER, $env:CHECKSUM, $env:SIGNATURE, $env:LATEST, "--repo", $env:GITHUB_REPOSITORY, "--verify-tag", "--title", $title, "--notes-file", $env:NOTES) if ($env:IS_PRERELEASE -eq "true") { $arguments += "--prerelease" } & gh @arguments if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + + - name: Update signed updater channel + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + LATEST: ${{ steps.package.outputs.latest }} + IS_PRERELEASE: ${{ steps.package.outputs.prerelease }} + run: | + $channel = if ($env:IS_PRERELEASE -eq "true") { "updater-alpha" } else { "updater-stable" } + $title = if ($env:IS_PRERELEASE -eq "true") { "Reelspace Alpha Update Channel" } else { "Reelspace Stable Update Channel" } + if (gh release view $channel --repo $env:GITHUB_REPOSITORY 2>$null) { + gh release edit $channel --repo $env:GITHUB_REPOSITORY --title $title --notes "Machine-readable Reelspace update channel. Installers remain attached to their versioned releases." + gh release upload $channel $env:LATEST --repo $env:GITHUB_REPOSITORY --clobber + } else { + $arguments = @("release", "create", $channel, $env:LATEST, "--repo", $env:GITHUB_REPOSITORY, "--target", "master", "--title", $title, "--notes", "Machine-readable Reelspace update channel. Installers remain attached to their versioned releases.") + if ($env:IS_PRERELEASE -eq "true") { $arguments += "--prerelease" } + & gh @arguments + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } diff --git a/README.md b/README.md index ce86864..8a8c9a4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Your gameplay recordings, organized locally. -Reelspace is a Windows desktop library for browsing large capture folders, playing recordings, adding your own organization, and turning useful moments into clips—without uploading your media to a service. +Reelspace is a Windows desktop library for browsing large capture folders, playing recordings, adding your own organization, and turning useful moments into clips—without requiring an online service. > [!WARNING] > Reelspace is early alpha software. Back up important recordings and exported library data. The installer is not code-signed, so Windows SmartScreen may show an unknown-publisher warning. @@ -12,9 +12,12 @@ Reelspace is a Windows desktop library for browsing large capture folders, playi - Incremental scanning for large recording folders, including persistent probe caching and background thumbnail recovery - A responsive gallery with in-window playback and Windows Explorer integration - Custom tags, notes, collections, and collection membership +- Automatic and manual game classification with aliases, renaming, merging, and collection storage defaults - Trim definitions and FFmpeg-powered clip generation that retain a reference to the source recording +- Optional private YouTube archival with a persistent, resumable queue and explicit local/remote availability states - Storage usage warnings and a confirmed raw-recording cleanup flow -- Metadata-only export and import for tags, notes, collections, memberships, and clip references +- Metadata-only export and import for tags, notes, games, collections, storage preferences, archive references, and clip provenance +- Signed in-app update checks backed by public GitHub Releases (the Windows installer itself remains unsigned) - Local diagnostics, unclean-shutdown detection, bounded job retries, and conservative database recovery ## Install on Windows @@ -22,12 +25,12 @@ Reelspace is a Windows desktop library for browsing large capture folders, playi Reelspace currently supports Windows 10/11 x64. 1. Open the [latest Reelspace release](https://github.com/canikou/Reelspace/releases/latest). -2. Download `Reelspace_0.1.1-alpha.1_windows_x64_setup.exe` and its `.sha256` file. +2. Download the `Reelspace_*_windows_x64_setup.exe` installer and its matching `.sha256` file. 3. Optionally verify the download in PowerShell: ```powershell - (Get-FileHash .\Reelspace_0.1.1-alpha.1_windows_x64_setup.exe -Algorithm SHA256).Hash - Get-Content .\Reelspace_0.1.1-alpha.1_windows_x64_setup.exe.sha256 + (Get-FileHash .\Reelspace_*_windows_x64_setup.exe -Algorithm SHA256).Hash + Get-Content .\Reelspace_*_windows_x64_setup.exe.sha256 ``` The two hashes should match. @@ -38,20 +41,22 @@ Reelspace currently supports Windows 10/11 x64. Open **Settings**, add one or more folders containing recordings, and save. Reelspace indexes supported local media and fills thumbnails in the background. Configure a working folder and clip folder before exporting clips. -Use **Export Library Data** in Settings to create a portable JSON archive of your organization metadata. The archive contains file references, not video files. After moving to another computer or drive, index the local recordings first and then import the archive; Reelspace restores data only for safely matched local files. See [portable library data](docs/library-data.md) for the exact matching and conflict rules. +Use **Export Library Data** in Settings to create a portable JSON archive of your organization metadata. The archive contains file and verified remote references, not video files. After moving to another computer or drive, index any available local recordings and then import the archive. See [portable library data](docs/library-data.md) for the exact matching and conflict rules. ## Privacy and local data -Reelspace is local-first. It has no account, cloud sync, advertising, analytics service, or hosted crash-report collector. Your database, thumbnails, previews, recovery reports, and rotated diagnostics remain in your Windows application-data directory. Routine logs avoid full media paths, but review any diagnostic file before sharing it publicly. +Reelspace is local-first. It has no Reelspace account, cloud sync, advertising, analytics service, or hosted crash-report collector. Your database, thumbnails, previews, recovery reports, and rotated diagnostics remain in your Windows application-data directory. Routine logs avoid full media paths, but review any diagnostic file before sharing it publicly. + +Private YouTube archival is optional and off by default. When enabled, click **Connect YouTube** and complete Google's browser sign-in; no developer credentials are required. OAuth tokens are kept in Windows Credential Manager and selected recordings are uploaded as private videos. Update checks contact public GitHub Releases; automatic downloads remain off by default. Exported library-data archives may contain filenames, folder references, tags, notes, and collection names. Treat them as personal data even though they contain no video or audio. ## Known limits - Windows x64 only; the installer is currently unsigned. -- Recordings must remain available on a local or Windows-accessible filesystem for playback, scanning, and clip creation. -- There is no cloud backup, automatic updater, or online video archival. -- Import carries organization metadata and file references, not media or application settings. +- Local files must remain available for playback and clip creation; YouTube archive copies open in the browser and are not a general cloud-sync filesystem. +- Import carries organization metadata and file/archive references, not media files, OAuth credentials, or general application settings. +- In-app updates are cryptographically signed, but the installer has no paid Authenticode signature and may trigger SmartScreen. - This alpha may still contain rough edges. Keep backups and verify generated clips before deleting any source recording. ## Roadmap @@ -75,6 +80,10 @@ cargo test --manifest-path src-tauri/Cargo.toml --all-targets pnpm tauri:build ``` +Updater-enabled packaging requires `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. The repository's `build-setup.bat` loads the maintainer's DPAPI-protected local key automatically; other source builders should provide their own Tauri updater key when producing an installer. + +Official builds contain Reelspace's public Google Desktop OAuth client ID. Forks can replace it at compile time with `REELSPACE_YOUTUBE_CLIENT_ID`; a Google Desktop client secret is intentionally neither required nor accepted by the app. See [YouTube archive authentication](docs/youtube-archive.md). + The FFmpeg preparation script downloads one pinned BtbN Windows build, verifies its archive and executable hashes, and stages only FFmpeg, FFprobe, and their license for packaging. See [third-party notices](THIRD_PARTY_NOTICES.md). Release candidates also run `scripts/test-release-media.ps1`. With no arguments it creates and removes its own sanitized fixtures; a custom source folder may contain one valid recording and at least one malformed `.mp4`. The harness exercises scanning, failure isolation, thumbnailing, accurate clipping, persistence, and metadata export/import without writing into the source folder. diff --git a/build-setup.bat b/build-setup.bat index 6be43cc..35da59a 100644 --- a/build-setup.bat +++ b/build-setup.bat @@ -26,6 +26,8 @@ echo. if "%RUN_EXIT%"=="0" ( echo Setup generated successfully. echo Setup: %CD%\dist\setup.exe + echo Sig: %CD%\dist\setup.exe.sig + echo SHA: %CD%\dist\setup.exe.sha256 ) else ( echo Setup generation failed with exit code %RUN_EXIT%. ) diff --git a/docs/library-data.md b/docs/library-data.md index a2fd270..e60ce87 100644 --- a/docs/library-data.md +++ b/docs/library-data.md @@ -1,21 +1,23 @@ # Portable Library Data -Reelspace Settings can export and import a versioned `.json` archive for user-created library organization. The archive contains media references, tags, notes, collection names and membership, and clip provenance/trim metadata. It never contains video, audio, thumbnails, previews, FFmpeg binaries, application settings, or diagnostics. +Reelspace Settings can export and import a versioned `.json` archive for user-created library organization. The archive contains media references, tags, notes, game classifications and aliases, collection names and membership, storage preferences, verified private archive references, and clip provenance/trim metadata. It never contains video, audio, thumbnails, previews, FFmpeg binaries, OAuth credentials, application settings, or diagnostics. ## Matching order 1. Exact original path, with kind, size, and duration verification. 2. The exported path relative to any currently configured source, working, clip, or managed clip root. 3. A unique kind, filename, size, and duration match in the current index. -4. Restore a database record only when exactly one original/remapped path exists locally, has the exported size, and remains inside a currently configured source/output root. +4. Restore a database record when exactly one original/remapped path exists locally, has the exported size, and remains inside a currently configured source/output root. +5. If no local copy exists, restore a remote-only record only when the archive contains a validated, verified, private YouTube reference. The placeholder path stays inside Reelspace application data and no video is downloaded. -Ambiguous, changed, and missing files remain unmatched. Import never creates a record pointing at a missing file. +Ambiguous and changed local files remain unmatched. A missing file without a verified remote reference is never restored. ## Merge behavior - Tags are merged case-insensitively. Existing tags take priority if the 100-tag or 4 KB application limit is reached. - An imported note fills an empty local note. A different non-empty local note is preserved and reported as a conflict. - Collections are matched case-insensitively by name, created when absent, and memberships are added without removing local memberships. -- Media and collection changes commit in one SQLite transaction. Any database failure rolls back the entire import. +- Game classifications, aliases, Keep Local choices, collection defaults, and verified private archive references are restored for safely matched records. +- Media, organization, preference, and archive-reference changes commit in one SQLite transaction. Any database failure rolls back the entire import. -The current archive format identifier is `reelspace-library-data`, version `1`. Files are limited to 64 MB, 250,000 media references, 50,000 collections, and 2,000,000 collection memberships. Unknown fields and unsupported versions are rejected so a typo or future incompatible archive cannot be partially interpreted. +The current archive format identifier is `reelspace-library-data`, version `2`. Reelspace also accepts version 1 archives. Files are limited to 64 MB, 250,000 media references, 50,000 collections, and 2,000,000 collection memberships. Unknown fields and unsupported versions are rejected so a typo or future incompatible archive cannot be partially interpreted. diff --git a/docs/releases/v0.2.0-alpha.1.md b/docs/releases/v0.2.0-alpha.1.md new file mode 100644 index 0000000..f25ab97 --- /dev/null +++ b/docs/releases/v0.2.0-alpha.1.md @@ -0,0 +1,20 @@ +# Reelspace v0.2.0-alpha.1 + +This alpha turns Reelspace into a clearer local-first recording workspace and establishes the signed GitHub Releases update path for future versions. + +## Highlights + +- New Frame Stack application icon and a unified, accessible icon/status system across dark and light themes. +- Games workspace with automatic classification, manual assignment, aliases, renaming, and safe merging. +- Collection-level Keep Local and Archive defaults for newly added media. +- Optional private YouTube archival with desktop OAuth, Windows Credential Manager storage, resumable uploads, persisted progress, quota-aware retries, and explicit verification before local cleanup is allowed. +- Dedicated Archive queue with pause, resume, retry, cancel, progress, and local/remote availability states. +- Reactive source-folder watching, periodic reconciliation, recorder discovery/launch shortcuts, repair tools, and sanitized diagnostics export. +- Portable library-data format v2 carries games, collection policies, Keep Local preferences, and verified private archive references without carrying video files or credentials. +- Signed in-app updater artifacts and GitHub release-channel metadata. The installer remains unsigned and may show a SmartScreen warning. + +## Safety notes + +Private archival and automatic downloads are off by default. Reelspace never deletes a local file merely because an upload completed; removing a local copy remains an explicit action and requires a verified remote copy with Keep Local disabled. Generated clips default to Keep Local. + +Back up important recordings and exported library data while the project remains in alpha. diff --git a/docs/youtube-archive.md b/docs/youtube-archive.md new file mode 100644 index 0000000..2ef81cb --- /dev/null +++ b/docs/youtube-archive.md @@ -0,0 +1,24 @@ +# YouTube archive authentication + +Reelspace uses Google's installed-desktop OAuth flow. A user clicks **Connect YouTube**, signs in through their normal browser, approves the requested YouTube permissions, and returns automatically through a temporary loopback callback. Reelspace uses PKCE for the authorization exchange and keeps refresh/access tokens in Windows Credential Manager. + +Users never enter a client ID or client secret. The public desktop client ID identifies Reelspace to Google and is bundled into official builds. Like all installed-app identifiers, it can be extracted from the distributed executable and is not treated as a secret. Reelspace does not bundle or use the Google-generated desktop client secret. + +## Maintainer setup + +The Google Cloud project behind an official build must have: + +1. YouTube Data API v3 enabled. +2. Google Auth Platform branding configured for Reelspace. +3. An External audience. While publishing status is **Testing**, every permitted Google account must be listed under **Audience → Test users**. +4. A Desktop app OAuth client. +5. The `youtube.upload` and `youtube.readonly` scopes declared on the Data Access page. + +The official Reelspace client ID is the default in `src-tauri/src/youtube.rs`. A fork or private build can override it without editing source: + +```powershell +$env:REELSPACE_YOUTUBE_CLIENT_ID = "your-client-id.apps.googleusercontent.com" +pnpm tauri:build +``` + +Do not put OAuth access tokens, refresh tokens, account passwords, or unrelated API credentials in source control. No Reelspace server or web redirect URI is required for this flow. diff --git a/package.json b/package.json index c04fa99..a3ab8ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reelspace", - "version": "0.1.1-alpha.1", + "version": "0.2.0-alpha.1", "private": true, "packageManager": "pnpm@11.9.0", "type": "module", @@ -16,6 +16,8 @@ "dependencies": { "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.4.1", + "@tauri-apps/plugin-process": "2.3.0", + "@tauri-apps/plugin-updater": "2.10.1", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fb098d..cda90bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.4.1 version: 2.7.1 + '@tauri-apps/plugin-process': + specifier: 2.3.0 + version: 2.3.0 + '@tauri-apps/plugin-updater': + specifier: 2.10.1 + version: 2.10.1 lucide-react: specifier: ^0.468.0 version: 0.468.0(react@18.3.1) @@ -526,6 +532,12 @@ packages: '@tauri-apps/plugin-dialog@2.7.1': resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + '@tauri-apps/plugin-process@2.3.0': + resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==} + + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1244,6 +1256,14 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-process@2.3.0': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 diff --git a/scripts/generate-icon.ps1 b/scripts/generate-icon.ps1 index 142e179..056baed 100644 --- a/scripts/generate-icon.ps1 +++ b/scripts/generate-icon.ps1 @@ -1,54 +1,29 @@ $ErrorActionPreference = "Stop" -Add-Type -AssemblyName System.Drawing - $root = Split-Path -Parent $PSScriptRoot -$iconDir = Join-Path $root "src-tauri\icons" -$pngPath = Join-Path $iconDir "icon.png" -$icoPath = Join-Path $iconDir "icon.ico" - -New-Item -ItemType Directory -Force $iconDir | Out-Null - -$bitmap = New-Object System.Drawing.Bitmap 256, 256 -$graphics = [System.Drawing.Graphics]::FromImage($bitmap) -$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias -$graphics.Clear([System.Drawing.Color]::FromArgb(24, 26, 31)) - -$brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush( - (New-Object System.Drawing.Rectangle 0, 0, 256, 256), - [System.Drawing.Color]::FromArgb(39, 110, 241), - [System.Drawing.Color]::FromArgb(113, 215, 183), - 45 +$source = Join-Path $root "src-tauri\icons\icon.svg" +$pnpm = Get-Command pnpm -ErrorAction SilentlyContinue +if (-not $pnpm) { + throw "pnpm is required to generate the Tauri icon family." +} + +Push-Location $root +try { + & $pnpm.Source tauri icon $source + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} finally { + Pop-Location +} + +$required = @( + "src-tauri\icons\32x32.png", + "src-tauri\icons\128x128.png", + "src-tauri\icons\128x128@2x.png", + "src-tauri\icons\icon.png", + "src-tauri\icons\icon.ico" ) -$graphics.FillRectangle($brush, 28, 28, 200, 200) - -$inner = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(24, 26, 31)) -$graphics.FillRectangle($inner, 58, 66, 140, 124) - -$accent = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 255, 255)) -$graphics.FillRectangle($accent, 78, 88, 100, 18) -$graphics.FillRectangle($accent, 78, 124, 64, 18) -$graphics.FillRectangle($accent, 78, 160, 92, 18) - -$graphics.Dispose() -$bitmap.Save($pngPath, [System.Drawing.Imaging.ImageFormat]::Png) - -$pngBytes = [System.IO.File]::ReadAllBytes($pngPath) -$stream = New-Object System.IO.FileStream($icoPath, [System.IO.FileMode]::Create) -$writer = New-Object System.IO.BinaryWriter($stream) -$writer.Write([UInt16]0) -$writer.Write([UInt16]1) -$writer.Write([UInt16]1) -$writer.Write([Byte]0) -$writer.Write([Byte]0) -$writer.Write([Byte]0) -$writer.Write([Byte]0) -$writer.Write([UInt16]1) -$writer.Write([UInt16]32) -$writer.Write([UInt32]$pngBytes.Length) -$writer.Write([UInt32]22) -$writer.Write($pngBytes) -$writer.Close() -$stream.Close() - -$bitmap.Dispose() +foreach ($asset in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $root $asset) -PathType Leaf)) { + throw "Icon generation did not produce $asset." + } +} diff --git a/scripts/runner.ps1 b/scripts/runner.ps1 index 6946006..4a5862d 100644 --- a/scripts/runner.ps1 +++ b/scripts/runner.ps1 @@ -13,6 +13,7 @@ $ErrorActionPreference = "Stop" $startedAt = Get-Date $transcribing = $false $exitCode = 1 +$signingEnvironmentLoadedByRunner = $false $runnerRevision = (Get-FileHash -Algorithm SHA256 -LiteralPath $PSCommandPath).Hash.Substring(0, 12).ToLowerInvariant() function Write-Stage { @@ -38,7 +39,10 @@ function Invoke-RunnerCommand { try { $ErrorActionPreference = "Continue" & $FilePath @Arguments 2>&1 | ForEach-Object { - Write-Host ([string]$_) + $line = [string]$_ + if ($line -ne "System.Management.Automation.RemoteException") { + Write-Host $line + } } $commandExitCode = $LASTEXITCODE } finally { @@ -116,6 +120,53 @@ function Add-ToolPath { } } +function Import-UpdaterSigningEnvironment { + $hasPrivateKey = -not [string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY) + $hasPassword = -not [string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD) + if ($hasPrivateKey -or $hasPassword) { + if (-not ($hasPrivateKey -and $hasPassword)) { + throw "Updater signing requires both TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD." + } + Write-Host "Updater signing: using the caller-provided environment." -ForegroundColor Green + return + } + + $tauriDirectory = Join-Path $env:USERPROFILE ".tauri" + $keyPath = Join-Path $tauriDirectory "reelspace-updater-v3.key" + $passwordPath = Join-Path $tauriDirectory "reelspace-updater-v3.password.dpapi" + if (-not (Test-Path -LiteralPath $keyPath -PathType Leaf)) { + throw "The local updater signing key is missing: $keyPath" + } + if (-not (Test-Path -LiteralPath $passwordPath -PathType Leaf)) { + throw "The DPAPI-protected updater signing password is missing: $passwordPath" + } + + $securePassword = $null + [IntPtr]$passwordPointer = [IntPtr]::Zero + try { + $protectedPassword = (Get-Content -LiteralPath $passwordPath -Raw).Trim() + $securePassword = $protectedPassword | ConvertTo-SecureString + $passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword) + $password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer) + if ([string]::IsNullOrWhiteSpace($password)) { + throw "The local updater signing password could not be decrypted." + } + $env:TAURI_SIGNING_PRIVATE_KEY = $keyPath + $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = $password + $script:signingEnvironmentLoadedByRunner = $true + } finally { + if ($passwordPointer -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer) + } + if ($securePassword) { + $securePassword.Dispose() + } + $protectedPassword = $null + $password = $null + } + Write-Host "Updater signing: loaded the DPAPI-protected local v3 key." -ForegroundColor Green +} + function Get-DependencyFingerprint { $packageHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $Workspace "package.json")).Hash $lockHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $Workspace "pnpm-lock.yaml")).Hash @@ -220,9 +271,20 @@ function Copy-InstallerArtifacts { Copy-Item -LiteralPath $installers[0].FullName -Destination $setupPath -Force Assert-WindowsExecutable -Path $setupPath + $signatureSource = "$($installers[0].FullName).sig" + if (-not (Test-Path -LiteralPath $signatureSource -PathType Leaf)) { + throw "The NSIS installer was built, but its updater signature is missing: $signatureSource" + } + $signaturePath = Join-Path $outputDir "setup.exe.sig" + Copy-Item -LiteralPath $signatureSource -Destination $signaturePath -Force + $setupFile = Get-Item -LiteralPath $setupPath $setupHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $setupPath).Hash + $checksumPath = Join-Path $outputDir "setup.exe.sha256" + "$($setupHash.ToLowerInvariant()) setup.exe" | Set-Content -LiteralPath $checksumPath -Encoding ASCII Write-Host "Setup: $setupPath" -ForegroundColor Green + Write-Host "Sig: $signaturePath" -ForegroundColor Green + Write-Host "SHA: $checksumPath" -ForegroundColor Green Write-Host ("Size: {0:N1} MB" -f ($setupFile.Length / 1MB)) -ForegroundColor Green Write-Host "SHA256: $setupHash" -ForegroundColor Green } @@ -329,6 +391,7 @@ try { Assert-PackagingResources Write-Stage "Building the optimized app and NSIS installer" Write-Host "Release builds are intentionally slower; incremental artifacts are retained for the next build." + Import-UpdaterSigningEnvironment Invoke-RunnerCommand -FilePath $pnpmCommand.Source -Arguments @("run", "tauri:build") Write-Stage "Collecting setup files in dist" Copy-InstallerArtifacts @@ -361,6 +424,10 @@ try { Write-Host "Full log: $LogPath" -ForegroundColor Yellow $exitCode = 1 } finally { + if ($signingEnvironmentLoadedByRunner) { + Remove-Item Env:\TAURI_SIGNING_PRIVATE_KEY -ErrorAction SilentlyContinue + Remove-Item Env:\TAURI_SIGNING_PRIVATE_KEY_PASSWORD -ErrorAction SilentlyContinue + } if ($transcribing) { Stop-Transcript | Out-Null } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 80546e6..cca1b01 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -59,6 +59,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -225,6 +234,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.21.7" @@ -429,6 +461,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -465,6 +499,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -479,6 +530,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -508,6 +568,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -531,7 +601,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -544,7 +614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -557,6 +627,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -684,6 +763,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -895,6 +985,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1007,6 +1106,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1071,6 +1180,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1078,6 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1271,8 +1396,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1294,8 +1421,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1446,6 +1576,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1574,6 +1723,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1584,6 +1734,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1602,9 +1767,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1782,6 +1949,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1833,6 +2020,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1861,6 +2078,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -1905,6 +2132,38 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2007,6 +2266,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2039,6 +2304,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2056,6 +2327,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2111,6 +2383,33 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2287,6 +2586,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2350,6 +2661,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2366,6 +2683,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2570,6 +2901,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2648,26 +2988,138 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.46" +name = "quinn" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ - "proc-macro2", + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "quinn-proto" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2707,11 +3159,16 @@ dependencies = [ [[package]] name = "reelspace" -version = "0.1.1-alpha.1" +version = "0.2.0-alpha.1" dependencies = [ "anyhow", + "base64 0.22.1", "chrono", "directories", + "keyring", + "notify", + "rand 0.9.5", + "reqwest", "rusqlite", "serde", "serde_json", @@ -2719,8 +3176,12 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-process", "tauri-plugin-single-instance", + "tauri-plugin-updater", "thiserror 1.0.69", + "trash", + "url", "walkdir", "which", "windows-sys 0.59.0", @@ -2783,21 +3244,32 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", + "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2833,6 +3305,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -2888,12 +3374,94 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2903,6 +3471,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -2960,6 +3537,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3084,6 +3684,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -3154,7 +3766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3180,6 +3792,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3292,6 +3920,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3344,6 +3978,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3365,7 +4020,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3375,7 +4030,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3391,7 +4046,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3408,6 +4063,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3432,7 +4098,7 @@ dependencies = [ "heck 0.5.0", "http", "http-range", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3463,7 +4129,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3586,6 +4252,16 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.4.3" @@ -3602,6 +4278,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3612,7 +4321,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3624,7 +4333,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3635,7 +4344,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3649,7 +4358,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -3833,6 +4542,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4042,6 +4761,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trash" +version = "5.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.56.0", +] + [[package]] name = "tray-icon" version = "0.24.1" @@ -4146,6 +4883,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4159,6 +4902,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -4339,6 +5088,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.5" @@ -4395,6 +5154,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4403,10 +5171,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -4427,7 +5195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4489,6 +5257,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -4511,14 +5289,26 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -4530,8 +5320,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -4548,6 +5338,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4559,6 +5360,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -4592,6 +5404,26 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4646,6 +5478,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5004,7 +5845,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5024,7 +5865,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5051,6 +5892,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5176,6 +6027,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -5209,6 +6066,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a5d229e..b50ced7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reelspace" -version = "0.1.1-alpha.1" +version = "0.2.0-alpha.1" description = "A Windows capture manager for raw recordings and trimmed clips." authors = ["Reelspace"] edition = "2021" @@ -14,18 +14,27 @@ tauri-build = { version = "2", features = [] } [dependencies] anyhow = "1" +base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } directories = "5" +keyring = { version = "3", features = ["windows-native"] } +notify = "8" +rand = "0.9" +reqwest = { version = "0.13", features = ["blocking", "form", "json", "query", "rustls"] } rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-dialog = "2" +tauri-plugin-process = "2" tauri-plugin-single-instance = "2" +tauri-plugin-updater = "2" thiserror = "1" +trash = "5" +url = "2" walkdir = "2" which = "6" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 261851f..eb5ae3c 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,4 @@ fn main() { + println!("cargo:rerun-if-env-changed=REELSPACE_YOUTUBE_CLIENT_ID"); tauri_build::build(); } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bc43a94..1c05d9e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,8 @@ "permissions": [ "core:default", "dialog:allow-open", - "dialog:allow-save" + "dialog:allow-save", + "updater:default", + "process:allow-restart" ] } diff --git a/src-tauri/examples/library_benchmark.rs b/src-tauri/examples/library_benchmark.rs index c1a6844..ff0fa56 100644 --- a/src-tauri/examples/library_benchmark.rs +++ b/src-tauri/examples/library_benchmark.rs @@ -43,11 +43,20 @@ struct BenchmarkReport { logical_processors: usize, } +struct ScratchCleanup(PathBuf); + +impl Drop for ScratchCleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + fn main() -> Result<(), Box> { let roots = parse_roots()?; let (source_files, source_bytes) = inventory(&roots)?; let scratch = benchmark_scratch()?; fs::create_dir_all(scratch.join("thumbnails"))?; + let _scratch_cleanup = ScratchCleanup(scratch.clone()); for root in &roots { let canonical = root.canonicalize()?; diff --git a/src-tauri/examples/release_media_smoke.rs b/src-tauri/examples/release_media_smoke.rs index 656378e..0c0d6c5 100644 --- a/src-tauri/examples/release_media_smoke.rs +++ b/src-tauri/examples/release_media_smoke.rs @@ -151,6 +151,7 @@ fn run_smoke( clip_folder: Some(output_root.display().to_string()), storage_limit_bytes: None, cleanup_threshold_percent: 90, + ..AppSettings::default() }; let exported = export_library_data(&database, &settings, scratch, archive_path)?; if exported.items != 2 || exported.collections != 1 { diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..108a9ff Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..8590da8 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..33f230e Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..49f895b Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..ab239fc Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..c748c51 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..8b41405 Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..e050bfd Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..ec14433 Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..402de3c Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..94ccdd2 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..5a606f3 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..bda0205 Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..2176999 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..8cb6541 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0b65448 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..228808e Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..eb0f158 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9ee11fd Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..027c508 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..0c1b70c Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2344cce Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..10f3916 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ea924d9 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7796cd3 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1e18977 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..6473612 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..74deafd Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..3dbd0b0 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..1d03c00 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index cad1439..c031920 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 685b669..60465b5 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/icon.svg b/src-tauri/icons/icon.svg new file mode 100644 index 0000000..f5e955a --- /dev/null +++ b/src-tauri/icons/icon.svg @@ -0,0 +1,23 @@ + + Reelspace Frame Stack + Three offset recording frames with an R-shaped front frame. + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..04f1106 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..e917238 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..e917238 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..2bcecff Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..c81541c Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..418dd4e Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..418dd4e Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..62e8cce Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..e917238 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..57aa625 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..57aa625 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..bf0c503 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..fb94d96 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..bf0c503 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..5de4d79 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..47a4538 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..2eed889 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..09a45a3 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2345441..6db0594 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -119,6 +119,9 @@ pub fn get_app_state(state: State<'_, AppState>) -> AppResult { settings, ffmpeg: state.ffmpeg.health(), stats, + active_media_work: state.media_jobs.active_count() > 0 + || state.export_jobs.active_count() > 0 + || state.background_jobs.active_count() > 0, }) } @@ -166,7 +169,7 @@ impl Drop for ScanInFlightGuard { } } -fn scan_sources_inner(state: &AppState) -> AppResult> { +pub(crate) fn scan_sources_inner(state: &AppState) -> AppResult> { let _index_guard = state .media_index_lock .lock() @@ -246,14 +249,32 @@ fn scan_sources_inner(state: &AppState) -> AppResult> { .cloned() .collect::>(); state.db.upsert_media_batch(&changed_items)?; + state.db.classify_media_items(&changed_items)?; + if settings.automatically_archive_recordings { + let media_ids = changed_items + .iter() + .map(|item| item.id.clone()) + .collect::>(); + if !media_ids.is_empty() { + let _ = state.db.queue_archive(&media_ids)?; + } + } for item in existing_items { let path = Path::new(&item.path); if belongs_to_roots(path, &reachable_sources) && !belongs_to_roots(path, &excluded_roots) && matches!(path.try_exists(), Ok(false)) { - state.db.delete_media(&item.id)?; - files_missing += 1; + if state.db.has_verified_remote(&item.id)? { + state.db.record_media_event( + &item.id, + "local_copy_missing", + Some("Preserved because a verified remote copy exists"), + )?; + } else { + state.db.delete_media(&item.id)?; + files_missing += 1; + } } } let queued_thumbnails = queue_missing_thumbnails(state, 0, false)?; @@ -524,6 +545,12 @@ pub fn add_to_collection( state .db .add_to_collection(&collection_id, &media_id, Utc::now().timestamp())?; + if let Some(policy) = state.db.collection_storage_default(&collection_id)? { + state.db.set_keep_local(&media_id, policy == "keepLocal")?; + if policy == "archive" && Path::new(&state.db.get_media(&media_id)?.path).is_file() { + state.db.queue_archive(std::slice::from_ref(&media_id))?; + } + } state.db.list_collections() } @@ -699,6 +726,12 @@ fn create_clip_inner(state: &AppState, mut request: CreateClipRequest) -> AppRes } return Err(error); } + state + .db + .initialize_clip_preferences(&source.id, &item.id, settings.keep_local_clips)?; + if settings.automatically_archive_clips { + state.db.queue_archive(std::slice::from_ref(&item.id))?; + } state.telemetry.event( "info", "clip.export.ready", @@ -814,6 +847,9 @@ fn resolve_clip_dir(settings: &AppSettings, data_dir: &Path) -> AppResult AppResult<()> { + if !matches!(settings.theme.as_str(), "system" | "dark" | "light") { + return Err(user_error("theme must be system, dark, or light")); + } if !(50..=99).contains(&settings.cleanup_threshold_percent) { return Err(user_error( "cleanup threshold must be between 50 and 99 percent", diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 6a195df..8b95de1 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -37,6 +37,45 @@ pub(crate) struct PortableCollectionUpdate { pub id: String, pub name: String, pub media_ids: Vec, + pub storage_default: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct PortableGameUpdate { + pub id: String, + pub name: String, + pub color: String, + pub aliases: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct PortableMediaFeatureUpdate { + pub media_id: String, + pub game_id: Option, + pub keep_local: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct PortableRemoteAssetUpdate { + pub id: String, + pub media_id: String, + pub provider: String, + pub provider_remote_id: String, + pub browser_url: Option, + pub privacy_status: Option, + pub verification_status: String, + pub processing_status: String, + pub verified_at: Option, + pub last_checked_at: i64, +} + +pub(crate) struct PortableImportBatch<'a> { + pub restored_items: &'a [MediaItem], + pub media_updates: &'a [PortableMediaUpdate], + pub collections: &'a [PortableCollectionUpdate], + pub games: &'a [PortableGameUpdate], + pub media_features: &'a [PortableMediaFeatureUpdate], + pub remote_assets: &'a [PortableRemoteAssetUpdate], } #[derive(Debug, Clone, Copy, Default)] @@ -45,7 +84,7 @@ pub(crate) struct PortableImportChanges { pub memberships_added: u64, } -const DATABASE_SCHEMA_VERSION: i64 = 3; +const DATABASE_SCHEMA_VERSION: i64 = 4; pub const PROBE_SCHEMA_VERSION: i64 = 1; impl Database { @@ -92,7 +131,7 @@ impl Database { }) } - fn conn(&self) -> AppResult> { + pub(crate) fn conn(&self) -> AppResult> { self.conn.lock().map_err(|_| { app_error( ErrorCode::DatabaseUnavailable, @@ -196,7 +235,102 @@ impl Database { CREATE INDEX IF NOT EXISTS idx_thumbnail_jobs_ready ON thumbnail_jobs(state, next_attempt_at, priority DESC, updated_at); + CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + color TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS game_aliases ( + alias_key TEXT PRIMARY KEY NOT NULL, + source_label TEXT NOT NULL, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS media_classification ( + media_id TEXT PRIMARY KEY NOT NULL REFERENCES media(id) ON DELETE CASCADE, + game_id TEXT NOT NULL REFERENCES games(id), + source_value TEXT, + assigned_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_media_classification_game ON media_classification(game_id); + + CREATE TABLE IF NOT EXISTS media_preferences ( + media_id TEXT PRIMARY KEY NOT NULL REFERENCES media(id) ON DELETE CASCADE, + keep_local INTEGER NOT NULL DEFAULT 0 CHECK(keep_local IN (0, 1)), + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS collection_preferences ( + collection_id TEXT PRIMARY KEY NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + keep_local_default INTEGER CHECK(keep_local_default IN (0, 1)), + remote_only_default INTEGER CHECK(remote_only_default IN (0, 1)), + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS remote_assets ( + id TEXT PRIMARY KEY NOT NULL, + media_id TEXT NOT NULL REFERENCES media(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_remote_id TEXT NOT NULL, + browser_url TEXT, + privacy_status TEXT, + verification_status TEXT NOT NULL, + processing_status TEXT NOT NULL, + verified_at INTEGER, + last_checked_at INTEGER NOT NULL, + UNIQUE(provider, provider_remote_id) + ); + + CREATE INDEX IF NOT EXISTS idx_remote_assets_media ON remote_assets(media_id, verification_status); + + CREATE TABLE IF NOT EXISTS upload_jobs ( + id TEXT PRIMARY KEY NOT NULL, + media_id TEXT NOT NULL REFERENCES media(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + state TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + bytes_uploaded INTEGER NOT NULL DEFAULT 0, + total_bytes INTEGER NOT NULL DEFAULT 0, + session_url TEXT, + provider_remote_id TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER NOT NULL DEFAULT 0, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_upload_jobs_ready ON upload_jobs(state, next_attempt_at, priority DESC, created_at); + + CREATE TABLE IF NOT EXISTS media_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_id TEXT NOT NULL REFERENCES media(id) ON DELETE CASCADE, + event TEXT NOT NULL, + detail TEXT, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_media_events_media ON media_events(media_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS archive_accounts ( + provider TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL, + display_name TEXT NOT NULL, + connected_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS local_removals ( + media_id TEXT PRIMARY KEY NOT NULL REFERENCES media(id) ON DELETE CASCADE, + original_path TEXT NOT NULL, + removed_at INTEGER NOT NULL + ); + UPDATE thumbnail_jobs SET state = 'queued' WHERE state = 'running'; + UPDATE upload_jobs SET state = 'queued', error = 'Recovered after Reelspace restarted' WHERE state IN ('preparing', 'uploading', 'verifying'); "#, ) .map_err(|error| { @@ -538,10 +672,16 @@ impl Database { let conn = self.conn()?; let mut stmt = conn.prepare( r#" - SELECT c.id, c.name, COUNT(ci.media_id) AS item_count + SELECT c.id, c.name, COUNT(ci.media_id) AS item_count, + CASE + WHEN cp.keep_local_default = 1 THEN 'keepLocal' + WHEN cp.remote_only_default = 1 THEN 'archive' + ELSE NULL + END AS storage_default FROM collections c LEFT JOIN collection_items ci ON ci.collection_id = c.id - GROUP BY c.id, c.name + LEFT JOIN collection_preferences cp ON cp.collection_id = c.id + GROUP BY c.id, c.name, cp.keep_local_default, cp.remote_only_default ORDER BY c.name COLLATE NOCASE "#, )?; @@ -550,6 +690,7 @@ impl Database { id: row.get(0)?, name: row.get(1)?, item_count: row.get::<_, i64>(2)? as u32, + storage_default: row.get(3)?, }) })?; rows.collect::, _>>().map_err(Into::into) @@ -595,17 +736,23 @@ impl Database { pub(crate) fn apply_portable_import( &self, - restored_items: &[MediaItem], - updates: &[PortableMediaUpdate], - collections: &[PortableCollectionUpdate], + batch: PortableImportBatch<'_>, imported_at: i64, ) -> AppResult { + let PortableImportBatch { + restored_items, + media_updates, + collections, + games, + media_features, + remote_assets, + } = batch; let mut conn = self.conn()?; let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; for item in restored_items { upsert_media_on(&transaction, item)?; } - for update in updates { + for update in media_updates { let changed = transaction.execute( "UPDATE media SET tags_json = ?1, notes = ?2 WHERE id = ?3", params![ @@ -618,6 +765,73 @@ impl Database { return Err(user_error("an imported media reference became unavailable")); } } + for game in games { + let existing_name: Option = transaction + .query_row("SELECT name FROM games WHERE id = ?1", [&game.id], |row| { + row.get(0) + }) + .optional()?; + if existing_name + .as_ref() + .is_some_and(|name| !name.eq_ignore_ascii_case(&game.name)) + { + return Err(user_error( + "an imported game identifier conflicts with local data", + )); + } + transaction.execute( + "INSERT OR IGNORE INTO games (id, name, color, created_at) VALUES (?1, ?2, ?3, ?4)", + params![game.id, game.name, game.color, imported_at], + )?; + for alias in &game.aliases { + transaction.execute( + "INSERT OR IGNORE INTO game_aliases (alias_key, source_label, game_id, created_at) VALUES (LOWER(?1), ?1, ?2, ?3)", + params![alias, game.id, imported_at], + )?; + } + } + for feature in media_features { + if let Some(game_id) = &feature.game_id { + transaction.execute( + "INSERT INTO media_classification (media_id, game_id, source_value, assigned_at) VALUES (?1, ?2, NULL, ?3) + ON CONFLICT(media_id) DO UPDATE SET game_id = excluded.game_id, assigned_at = excluded.assigned_at", + params![feature.media_id, game_id, imported_at], + )?; + } + if let Some(keep_local) = feature.keep_local { + transaction.execute( + "INSERT INTO media_preferences (media_id, keep_local, updated_at) VALUES (?1, ?2, ?3) + ON CONFLICT(media_id) DO UPDATE SET keep_local = excluded.keep_local, updated_at = excluded.updated_at", + params![feature.media_id, keep_local, imported_at], + )?; + } + } + for asset in remote_assets { + transaction.execute( + "INSERT INTO remote_assets (id, media_id, provider, provider_remote_id, browser_url, privacy_status, verification_status, processing_status, verified_at, last_checked_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(provider, provider_remote_id) DO UPDATE SET + browser_url = excluded.browser_url, + privacy_status = excluded.privacy_status, + verification_status = excluded.verification_status, + processing_status = excluded.processing_status, + verified_at = excluded.verified_at, + last_checked_at = excluded.last_checked_at + WHERE remote_assets.media_id = excluded.media_id", + params![ + asset.id, + asset.media_id, + asset.provider, + asset.provider_remote_id, + asset.browser_url, + asset.privacy_status, + asset.verification_status, + asset.processing_status, + asset.verified_at, + asset.last_checked_at, + ], + )?; + } let mut changes = PortableImportChanges::default(); for collection in collections { let existing_name: Option = transaction @@ -639,6 +853,18 @@ impl Database { "INSERT OR IGNORE INTO collections (id, name, created_at) VALUES (?1, ?2, ?3)", params![collection.id, collection.name, imported_at], )? as u64; + if let Some(policy) = &collection.storage_default { + let keep_local = policy == "keepLocal"; + transaction.execute( + "INSERT INTO collection_preferences (collection_id, keep_local_default, remote_only_default, updated_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(collection_id) DO UPDATE SET + keep_local_default = excluded.keep_local_default, + remote_only_default = excluded.remote_only_default, + updated_at = excluded.updated_at", + params![collection.id, keep_local, !keep_local, imported_at], + )?; + } for media_id in &collection.media_ids { changes.memberships_added += transaction.execute( "INSERT OR IGNORE INTO collection_items (collection_id, media_id, added_at) VALUES (?1, ?2, ?3)", @@ -929,14 +1155,22 @@ mod tests { db.create_collection(&collection_id, "Local", 1) .expect("local collection"); let restored = item("C:\\captures\\restored.mp4", MediaKind::Raw); + let restored_items = [restored]; + let collections = [PortableCollectionUpdate { + id: collection_id, + name: "Conflicting".into(), + media_ids: Vec::new(), + storage_default: None, + }]; let result = db.apply_portable_import( - &[restored], - &[], - &[PortableCollectionUpdate { - id: collection_id, - name: "Conflicting".into(), - media_ids: Vec::new(), - }], + PortableImportBatch { + restored_items: &restored_items, + media_updates: &[], + collections: &collections, + games: &[], + media_features: &[], + remote_assets: &[], + }, 2, ); assert!(result.is_err()); diff --git a/src-tauri/src/features.rs b/src-tauri/src/features.rs new file mode 100644 index 0000000..73e6eeb --- /dev/null +++ b/src-tauri/src/features.rs @@ -0,0 +1,942 @@ +use crate::db::Database; +use crate::error::{app_error, user_error, AppResult, ErrorCode}; +use crate::models::{ + ArchiveStatus, Game, GameAssignment, MediaEvent, MediaItem, RecorderApp, RepairSummary, + UploadJob, +}; +use crate::state::AppState; +use crate::windows_integration::open_external_url; +use chrono::Utc; +use rusqlite::{params, OptionalExtension, TransactionBehavior}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tauri::State; + +const GAME_COLORS: [&str; 12] = [ + "#7dd3fc", "#b9a7ff", "#6ee7b7", "#f6c56f", "#fb8c8c", "#67e8f9", "#c4b5fd", "#86efac", + "#fdba74", "#f9a8d4", "#93c5fd", "#d8b4fe", +]; + +impl Database { + pub fn initialize_clip_preferences( + &self, + source_id: &str, + clip_id: &str, + keep_local: bool, + ) -> AppResult<()> { + let conn = self.conn()?; + let now = Utc::now().timestamp(); + conn.execute( + "INSERT OR IGNORE INTO media_classification (media_id, game_id, source_value, assigned_at) + SELECT ?1, game_id, source_value, ?3 FROM media_classification WHERE media_id = ?2", + params![clip_id, source_id, now], + )?; + conn.execute( + "INSERT OR REPLACE INTO media_preferences (media_id, keep_local, updated_at) VALUES (?1, ?2, ?3)", + params![clip_id, keep_local, now], + )?; + Ok(()) + } + + pub fn classify_media_items(&self, items: &[MediaItem]) -> AppResult<()> { + if items.is_empty() { + return Ok(()); + } + let mut conn = self.conn()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + for item in items { + let Some(label) = game_label_for_path(Path::new(&item.path)) else { + continue; + }; + let key = normalized_label(&label); + if key.is_empty() { + continue; + } + let game_id: Option = tx + .query_row( + "SELECT game_id FROM game_aliases WHERE alias_key = ?1", + [&key], + |row| row.get(0), + ) + .optional()?; + let game_id = match game_id { + Some(id) => id, + None => { + let existing: Option = tx + .query_row( + "SELECT id FROM games WHERE name = ?1 COLLATE NOCASE", + [&label], + |row| row.get(0), + ) + .optional()?; + existing.unwrap_or_else(|| stable_id(&format!("game:{key}"))) + } + }; + tx.execute( + "INSERT OR IGNORE INTO games (id, name, color, created_at) VALUES (?1, ?2, ?3, ?4)", + params![game_id, label, color_for(&key), Utc::now().timestamp()], + )?; + tx.execute( + "INSERT OR IGNORE INTO game_aliases (alias_key, source_label, game_id, created_at) VALUES (?1, ?2, ?3, ?4)", + params![key, label, game_id, Utc::now().timestamp()], + )?; + tx.execute( + "INSERT OR IGNORE INTO media_classification (media_id, game_id, source_value, assigned_at) VALUES (?1, ?2, ?3, ?4)", + params![item.id, game_id, label, Utc::now().timestamp()], + )?; + } + tx.commit()?; + Ok(()) + } + + pub fn list_games(&self) -> AppResult> { + let conn = self.conn()?; + let mut stmt = conn.prepare( + "SELECT g.id, g.name, g.color, COUNT(DISTINCT mc.media_id) + FROM games g LEFT JOIN media_classification mc ON mc.game_id = g.id + GROUP BY g.id ORDER BY g.name COLLATE NOCASE", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)? as u64, + )) + })?; + let mut games = Vec::new(); + for row in rows { + let (id, name, color, media_count) = row?; + let mut aliases_stmt = conn.prepare( + "SELECT source_label FROM game_aliases WHERE game_id = ?1 ORDER BY source_label COLLATE NOCASE", + )?; + let aliases = aliases_stmt + .query_map([&id], |row| row.get(0))? + .collect::, _>>()?; + games.push(Game { + id, + name, + color, + media_count, + aliases, + }); + } + Ok(games) + } + + pub fn create_game(&self, name: &str) -> AppResult<()> { + let name = validated_game_name(name)?; + let key = normalized_label(name); + let id = stable_id(&format!("game:{key}")); + let now = Utc::now().timestamp(); + let mut conn = self.conn()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute( + "INSERT INTO games (id, name, color, created_at) VALUES (?1, ?2, ?3, ?4)", + params![id, name, color_for(&key), now], + )?; + tx.execute("INSERT INTO game_aliases (alias_key, source_label, game_id, created_at) VALUES (?1, ?2, ?3, ?4)", params![key, name, id, now])?; + tx.commit()?; + Ok(()) + } + + pub fn list_game_assignments(&self) -> AppResult> { + let conn = self.conn()?; + let mut stmt = conn.prepare( + "SELECT mc.media_id, g.id, g.name, g.color FROM media_classification mc INNER JOIN games g ON g.id = mc.game_id", + )?; + let values = stmt + .query_map([], |row| { + Ok(GameAssignment { + media_id: row.get(0)?, + game_id: row.get(1)?, + game_name: row.get(2)?, + color: row.get(3)?, + }) + })? + .collect::, rusqlite::Error>>()?; + Ok(values) + } + + pub fn rename_game(&self, id: &str, name: &str, preserve_alias: bool) -> AppResult<()> { + let name = validated_game_name(name)?; + let mut conn = self.conn()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let old: String = tx + .query_row("SELECT name FROM games WHERE id = ?1", [id], |row| { + row.get(0) + }) + .optional()? + .ok_or_else(|| user_error("game was not found"))?; + tx.execute( + "UPDATE games SET name = ?1 WHERE id = ?2", + params![name, id], + )?; + if preserve_alias { + tx.execute( + "INSERT OR REPLACE INTO game_aliases (alias_key, source_label, game_id, created_at) VALUES (?1, ?2, ?3, ?4)", + params![normalized_label(&old), old, id, Utc::now().timestamp()], + )?; + } + tx.commit()?; + Ok(()) + } + + pub fn merge_games(&self, source_id: &str, target_id: &str) -> AppResult<()> { + if source_id == target_id { + return Err(user_error("a game cannot be merged into itself")); + } + let mut conn = self.conn()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let source_name: String = tx + .query_row("SELECT name FROM games WHERE id = ?1", [source_id], |row| { + row.get(0) + }) + .optional()? + .ok_or_else(|| user_error("source game was not found"))?; + let target_exists: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM games WHERE id = ?1)", + [target_id], + |row| row.get(0), + )?; + if !target_exists { + return Err(user_error("target game was not found")); + } + tx.execute( + "UPDATE media_classification SET game_id = ?1 WHERE game_id = ?2", + params![target_id, source_id], + )?; + tx.execute( + "UPDATE game_aliases SET game_id = ?1 WHERE game_id = ?2", + params![target_id, source_id], + )?; + tx.execute( + "INSERT OR REPLACE INTO game_aliases (alias_key, source_label, game_id, created_at) VALUES (?1, ?2, ?3, ?4)", + params![normalized_label(&source_name), source_name, target_id, Utc::now().timestamp()], + )?; + tx.execute("DELETE FROM games WHERE id = ?1", [source_id])?; + tx.commit()?; + Ok(()) + } + + pub fn assign_game(&self, media_id: &str, game_id: &str) -> AppResult<()> { + let conn = self.conn()?; + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM games WHERE id = ?1)", + [game_id], + |row| row.get(0), + )?; + if !exists { + return Err(user_error("game was not found")); + } + let changed = conn.execute( + "INSERT INTO media_classification (media_id, game_id, source_value, assigned_at) VALUES (?1, ?2, NULL, ?3) + ON CONFLICT(media_id) DO UPDATE SET game_id = excluded.game_id, assigned_at = excluded.assigned_at", + params![media_id, game_id, Utc::now().timestamp()], + )?; + if changed != 1 { + return Err(user_error("media item was not found")); + } + Ok(()) + } + + pub fn list_archive_statuses(&self) -> AppResult> { + let conn = self.conn()?; + let mut stmt = conn.prepare( + "SELECT m.id, m.path, m.kind, COALESCE(mp.keep_local, 0), + COUNT(DISTINCT ra.id), + MAX(CASE WHEN ra.verification_status = 'verified' THEN 1 ELSE 0 END), + MAX(ra.browser_url), + COALESCE((SELECT uj.state FROM upload_jobs uj WHERE uj.media_id = m.id AND uj.state NOT IN ('canceled') ORDER BY uj.updated_at DESC LIMIT 1), ''), + COALESCE((SELECT uj.bytes_uploaded FROM upload_jobs uj WHERE uj.media_id = m.id ORDER BY uj.updated_at DESC LIMIT 1), 0), + COALESCE((SELECT uj.total_bytes FROM upload_jobs uj WHERE uj.media_id = m.id ORDER BY uj.updated_at DESC LIMIT 1), 0), + (SELECT uj.error FROM upload_jobs uj WHERE uj.media_id = m.id ORDER BY uj.updated_at DESC LIMIT 1) + FROM media m + LEFT JOIN media_preferences mp ON mp.media_id = m.id + LEFT JOIN remote_assets ra ON ra.media_id = m.id + GROUP BY m.id", + )?; + let rows = stmt.query_map([], |row| { + let path: String = row.get(1)?; + let kind: String = row.get(2)?; + let mut keep_local = row.get::<_, i64>(3)? != 0; + if kind == "clip" && !keep_local { + keep_local = true; + } + let copies = row.get::<_, i64>(4)? as u64; + let verified = row.get::<_, i64>(5)? != 0; + let job: String = row.get(7)?; + let uploaded = row.get::<_, i64>(8)?.max(0) as u64; + let total = row.get::<_, i64>(9)?.max(0) as u64; + let state = archive_state(&job, copies, verified, Path::new(&path).is_file()); + Ok(ArchiveStatus { + media_id: row.get(0)?, + state, + keep_local, + remote_copy_count: copies, + primary_remote_url: row.get(6)?, + progress_percent: uploaded + .saturating_mul(100) + .checked_div(total) + .unwrap_or(0) + .min(100) as u8, + error: row.get(10)?, + }) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn set_keep_local(&self, media_id: &str, keep_local: bool) -> AppResult<()> { + let conn = self.conn()?; + conn.execute( + "INSERT INTO media_preferences (media_id, keep_local, updated_at) VALUES (?1, ?2, ?3) + ON CONFLICT(media_id) DO UPDATE SET keep_local = excluded.keep_local, updated_at = excluded.updated_at", + params![media_id, keep_local, Utc::now().timestamp()], + )?; + drop(conn); + self.record_media_event( + media_id, + if keep_local { + "keep_local_enabled" + } else { + "keep_local_disabled" + }, + None, + ) + } + + pub fn queue_archive(&self, media_ids: &[String]) -> AppResult { + let mut conn = self.conn()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let now = Utc::now().timestamp(); + let mut queued = 0; + for (index, media_id) in media_ids.iter().enumerate() { + let media: Option<(String, i64)> = tx + .query_row( + "SELECT path, file_size FROM media WHERE id = ?1", + [media_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + let Some((path, size)) = media else { + return Err(user_error("media item was not found")); + }; + if !Path::new(&path).is_file() { + return Err(user_error("remote-only media cannot be queued for upload")); + } + let already_verified: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM remote_assets WHERE media_id = ?1 AND verification_status = 'verified')", [media_id], |row| row.get(0))?; + if already_verified { + continue; + } + let active: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM upload_jobs WHERE media_id = ?1 AND state NOT IN ('failed', 'canceled', 'archived'))", [media_id], |row| row.get(0))?; + if active { + continue; + } + let id = stable_id(&format!("upload:{media_id}:{now}:{index}")); + tx.execute( + "INSERT INTO upload_jobs (id, media_id, provider, state, priority, total_bytes, created_at, updated_at) VALUES (?1, ?2, 'youtube', 'queued', ?3, ?4, ?5, ?5)", + params![id, media_id, if media_ids.len() == 1 { 100 } else { 10 }, size.max(0), now], + )?; + tx.execute("INSERT INTO media_events (media_id, event, created_at) VALUES (?1, 'queued_for_archive', ?2)", params![media_id, now])?; + queued += 1; + } + tx.commit()?; + Ok(queued) + } + + pub fn list_upload_jobs(&self) -> AppResult> { + let conn = self.conn()?; + let mut stmt = conn.prepare( + "SELECT uj.id, uj.media_id, m.filename, uj.state, uj.priority, uj.bytes_uploaded, uj.total_bytes, uj.attempts, uj.error, uj.updated_at + FROM upload_jobs uj INNER JOIN media m ON m.id = uj.media_id + WHERE uj.state != 'canceled' ORDER BY CASE uj.state WHEN 'uploading' THEN 0 WHEN 'preparing' THEN 1 WHEN 'queued' THEN 2 ELSE 3 END, uj.priority DESC, uj.created_at", + )?; + let values = stmt + .query_map([], |row| { + Ok(UploadJob { + id: row.get(0)?, + media_id: row.get(1)?, + filename: row.get(2)?, + state: row.get(3)?, + priority: row.get(4)?, + bytes_uploaded: row.get::<_, i64>(5)?.max(0) as u64, + total_bytes: row.get::<_, i64>(6)?.max(0) as u64, + attempts: row.get::<_, i64>(7)?.max(0) as u32, + error: row.get(8)?, + updated_at: row.get(9)?, + }) + })? + .collect::, rusqlite::Error>>()?; + Ok(values) + } + + pub fn control_upload_job(&self, action: &str, job_id: Option<&str>) -> AppResult<()> { + let conn = self.conn()?; + let now = Utc::now().timestamp(); + let (state, where_clause) = match action { + "pauseAll" => ( + "paused", + "state IN ('queued', 'retry', 'waiting_for_quota')", + ), + "resumeAll" => ("queued", "state = 'paused'"), + "pause" => ( + "paused", + "id = ?1 AND state IN ('queued', 'retry', 'waiting_for_quota')", + ), + "resume" | "retry" => ( + "queued", + "id = ?1 AND state IN ('paused', 'failed', 'waiting_for_quota')", + ), + "cancel" => ( + "canceled", + "id = ?1 AND state NOT IN ('archived', 'uploading')", + ), + _ => return Err(user_error("unknown upload queue action")), + }; + let sql = format!("UPDATE upload_jobs SET state = '{state}', error = NULL, next_attempt_at = 0, updated_at = {now} WHERE {where_clause}"); + if action.ends_with("All") { + conn.execute(&sql, [])?; + } else { + conn.execute( + &sql, + [job_id.ok_or_else(|| user_error("job id is required"))?], + )?; + } + Ok(()) + } + + pub fn has_verified_remote(&self, media_id: &str) -> AppResult { + let conn = self.conn()?; + conn.query_row("SELECT EXISTS(SELECT 1 FROM remote_assets WHERE media_id = ?1 AND verification_status = 'verified')", [media_id], |row| row.get(0)).map_err(Into::into) + } + + pub fn record_media_event( + &self, + media_id: &str, + event: &str, + detail: Option<&str>, + ) -> AppResult<()> { + let conn = self.conn()?; + conn.execute("INSERT INTO media_events (media_id, event, detail, created_at) VALUES (?1, ?2, ?3, ?4)", params![media_id, event, detail, Utc::now().timestamp()])?; + Ok(()) + } + + pub fn list_media_events(&self, media_id: &str) -> AppResult> { + let conn = self.conn()?; + let mut stmt = conn.prepare("SELECT event, detail, created_at FROM media_events WHERE media_id = ?1 ORDER BY created_at DESC LIMIT 100")?; + let values = stmt + .query_map([media_id], |row| { + Ok(MediaEvent { + event: row.get(0)?, + detail: row.get(1)?, + created_at: row.get(2)?, + }) + })? + .collect::, rusqlite::Error>>()?; + Ok(values) + } + + pub fn remove_from_collection(&self, collection_id: &str, media_id: &str) -> AppResult<()> { + self.conn()?.execute( + "DELETE FROM collection_items WHERE collection_id = ?1 AND media_id = ?2", + params![collection_id, media_id], + )?; + Ok(()) + } + + pub fn collection_storage_default(&self, collection_id: &str) -> AppResult> { + self.conn()? + .query_row( + "SELECT CASE + WHEN keep_local_default = 1 THEN 'keepLocal' + WHEN remote_only_default = 1 THEN 'archive' + ELSE NULL + END + FROM collection_preferences WHERE collection_id = ?1", + [collection_id], + |row| row.get(0), + ) + .optional() + .map(|value| value.flatten()) + .map_err(Into::into) + } + + pub fn set_collection_storage_default( + &self, + collection_id: &str, + policy: Option<&str>, + ) -> AppResult<()> { + if !matches!(policy, None | Some("keepLocal") | Some("archive")) { + return Err(user_error("unknown collection storage default")); + } + let mut conn = self.conn()?; + let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let exists: bool = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM collections WHERE id = ?1)", + [collection_id], + |row| row.get(0), + )?; + if !exists { + return Err(user_error("collection was not found")); + } + match policy { + None => { + transaction.execute( + "DELETE FROM collection_preferences WHERE collection_id = ?1", + [collection_id], + )?; + } + Some(policy) => { + let keep_local = policy == "keepLocal"; + transaction.execute( + "INSERT INTO collection_preferences (collection_id, keep_local_default, remote_only_default, updated_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(collection_id) DO UPDATE SET + keep_local_default = excluded.keep_local_default, + remote_only_default = excluded.remote_only_default, + updated_at = excluded.updated_at", + params![collection_id, keep_local, !keep_local, Utc::now().timestamp()], + )?; + transaction.execute( + "INSERT INTO media_preferences (media_id, keep_local, updated_at) + SELECT media_id, ?2, ?3 FROM collection_items WHERE collection_id = ?1 + ON CONFLICT(media_id) DO UPDATE SET keep_local = excluded.keep_local, updated_at = excluded.updated_at", + params![collection_id, keep_local, Utc::now().timestamp()], + )?; + } + } + transaction.commit()?; + if policy == Some("archive") { + let media_ids = self + .list_collection_media(collection_id)? + .into_iter() + .filter(|item| Path::new(&item.path).is_file()) + .map(|item| item.id) + .collect::>(); + if !media_ids.is_empty() { + self.queue_archive(&media_ids)?; + } + } + Ok(()) + } +} + +#[tauri::command] +pub fn list_games(state: State<'_, AppState>) -> AppResult> { + state.db.list_games() +} + +#[tauri::command] +pub fn create_game(state: State<'_, AppState>, name: String) -> AppResult> { + state.db.create_game(&name)?; + state.db.list_games() +} + +#[tauri::command] +pub fn list_game_assignments(state: State<'_, AppState>) -> AppResult> { + state.db.list_game_assignments() +} + +#[tauri::command] +pub fn rename_game( + state: State<'_, AppState>, + id: String, + name: String, + preserve_alias: bool, +) -> AppResult> { + state.db.rename_game(&id, &name, preserve_alias)?; + state.db.list_games() +} + +#[tauri::command] +pub fn merge_games( + state: State<'_, AppState>, + source_id: String, + target_id: String, +) -> AppResult> { + state.db.merge_games(&source_id, &target_id)?; + state.db.list_games() +} + +#[tauri::command] +pub fn assign_game( + state: State<'_, AppState>, + media_id: String, + game_id: String, +) -> AppResult> { + state.db.assign_game(&media_id, &game_id)?; + state.db.list_game_assignments() +} + +#[tauri::command] +pub fn list_archive_statuses(state: State<'_, AppState>) -> AppResult> { + state.db.list_archive_statuses() +} + +#[tauri::command] +pub fn set_keep_local( + state: State<'_, AppState>, + media_id: String, + keep_local: bool, +) -> AppResult> { + state.db.set_keep_local(&media_id, keep_local)?; + state.db.list_archive_statuses() +} + +#[tauri::command] +pub fn queue_archive(state: State<'_, AppState>, media_ids: Vec) -> AppResult { + if media_ids.is_empty() || media_ids.len() > 10_000 { + return Err(user_error("select between 1 and 10,000 media items")); + } + state.db.queue_archive(&media_ids) +} + +#[tauri::command] +pub fn list_upload_jobs(state: State<'_, AppState>) -> AppResult> { + state.db.list_upload_jobs() +} + +#[tauri::command] +pub fn control_upload_queue( + state: State<'_, AppState>, + action: String, + job_id: Option, +) -> AppResult> { + state.db.control_upload_job(&action, job_id.as_deref())?; + state.db.list_upload_jobs() +} + +#[tauri::command] +pub fn list_media_events( + state: State<'_, AppState>, + media_id: String, +) -> AppResult> { + state.db.list_media_events(&media_id) +} + +#[tauri::command] +pub fn remove_from_collection( + state: State<'_, AppState>, + collection_id: String, + media_id: String, +) -> AppResult<()> { + state.db.remove_from_collection(&collection_id, &media_id) +} + +#[tauri::command] +pub fn set_collection_storage_default( + state: State<'_, AppState>, + collection_id: String, + policy: Option, +) -> AppResult> { + state + .db + .set_collection_storage_default(&collection_id, policy.as_deref())?; + state.db.list_collections() +} + +#[tauri::command] +pub fn detect_recording_apps(state: State<'_, AppState>) -> AppResult> { + let settings = state.db.load_settings()?; + let mut candidates = recorder_candidates(); + if let Some(custom) = settings.preferred_recording_app { + candidates.insert( + 0, + ( + "custom".into(), + "Preferred recorder".into(), + PathBuf::from(custom), + ), + ); + } + let mut seen = HashMap::new(); + Ok(candidates + .into_iter() + .filter(|(_, _, path)| path.is_file()) + .filter_map(|(id, name, path)| { + let key = path.to_string_lossy().to_ascii_lowercase(); + if seen.insert(key, ()).is_some() { + return None; + } + Some(RecorderApp { + id, + name, + executable_path: path.to_string_lossy().into_owned(), + }) + }) + .collect()) +} + +#[tauri::command] +pub fn launch_recording_app(state: State<'_, AppState>, id: String) -> AppResult<()> { + let apps = detect_recording_apps(state)?; + let app = apps + .into_iter() + .find(|app| app.id == id) + .ok_or_else(|| user_error("recording application is no longer available"))?; + std::process::Command::new(&app.executable_path) + .spawn() + .map_err(|error| { + app_error( + ErrorCode::WindowsIntegrationFailed, + "The recording application could not be launched", + ) + .with_detail(error.to_string()) + })?; + Ok(()) +} + +#[tauri::command] +pub fn remove_local_copy( + state: State<'_, AppState>, + media_id: String, +) -> AppResult> { + let item = state.db.get_media(&media_id)?; + if !state.db.has_verified_remote(&media_id)? { + return Err(user_error( + "a verified remote copy is required before local cleanup", + )); + } + let statuses = state.db.list_archive_statuses()?; + if statuses + .iter() + .find(|status| status.media_id == media_id) + .is_some_and(|status| status.keep_local) + { + return Err(user_error("disable Keep Local before removing this copy")); + } + let path = Path::new(&item.path); + if !path.is_file() { + return Err(user_error("the local copy is already unavailable")); + } + trash::delete(path).map_err(|error| { + app_error( + ErrorCode::PermissionDenied, + "The local copy could not be moved to the Recycle Bin", + ) + .with_detail(error.to_string()) + })?; + state.db.record_media_event( + &media_id, + "local_copy_removed", + Some("Moved to Windows Recycle Bin"), + )?; + state.db.list_archive_statuses() +} + +#[tauri::command] +pub fn open_remote_copy(state: State<'_, AppState>, media_id: String) -> AppResult<()> { + let status = state + .db + .list_archive_statuses()? + .into_iter() + .find(|status| status.media_id == media_id) + .ok_or_else(|| user_error("media item was not found"))?; + let url = status + .primary_remote_url + .ok_or_else(|| user_error("no remote browser link is available"))?; + open_external_url(&url, "The remote copy could not be opened") +} + +#[tauri::command] +pub fn repair_library(state: State<'_, AppState>) -> AppResult { + let items = state.db.list_media(None)?; + let mut summary = RepairSummary::default(); + for item in items { + if !Path::new(&item.path).is_file() { + summary.missing_local_files += 1; + if state.db.has_verified_remote(&item.id)? { + summary.preserved_remote_only_items += 1; + } + } + } + summary.stale_jobs_recovered = state + .db + .list_upload_jobs()? + .iter() + .filter(|job| job.error.as_deref() == Some("Recovered after Reelspace restarted")) + .count() as u64; + Ok(summary) +} + +#[tauri::command] +pub fn open_diagnostics_folder(state: State<'_, AppState>) -> AppResult<()> { + let path = state.data_dir.join("logs"); + std::fs::create_dir_all(&path)?; + std::process::Command::new("explorer.exe") + .arg(path) + .spawn() + .map_err(|error| { + app_error( + ErrorCode::WindowsIntegrationFailed, + "The diagnostics folder could not be opened", + ) + .with_detail(error.to_string()) + })?; + Ok(()) +} + +#[tauri::command] +pub fn export_diagnostics(state: State<'_, AppState>, path: String) -> AppResult<()> { + let destination = Path::new(&path); + if !destination.is_absolute() + || destination + .extension() + .and_then(|value| value.to_str()) + .is_none_or(|value| !value.eq_ignore_ascii_case("json")) + { + return Err(user_error( + "diagnostics must be exported to an absolute .json path", + )); + } + let settings = state.db.load_settings()?; + let media = state.db.list_media(None)?; + let jobs = state.db.list_upload_jobs()?; + let report = serde_json::json!({ + "schemaVersion": 1, + "generatedAt": Utc::now().to_rfc3339(), + "reelspaceVersion": env!("CARGO_PKG_VERSION"), + "platform": "windows-x64", + "sourceFolderCount": settings.source_folders.len(), + "mediaItems": media.len(), + "queuedArchiveJobs": jobs.iter().filter(|job| job.state != "archived").count(), + "ffmpegReady": state.ffmpeg.health().ready, + "activeMediaWork": state.media_jobs.active_count() > 0 || state.export_jobs.active_count() > 0 || state.background_jobs.active_count() > 0, + "privacy": "No usernames, local paths, OAuth tokens, video titles, or file names are included." + }); + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(destination, serde_json::to_vec_pretty(&report)?)?; + Ok(()) +} + +fn recorder_candidates() -> Vec<(String, String, PathBuf)> { + let program_files = std::env::var_os("ProgramFiles") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Program Files")); + let program_files_x86 = std::env::var_os("ProgramFiles(x86)") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Program Files (x86)")); + let local = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_default(); + vec![ + ( + "obs".into(), + "OBS Studio".into(), + program_files.join(r"obs-studio\bin\64bit\obs64.exe"), + ), + ( + "medal".into(), + "Medal".into(), + local.join(r"Medal\Medal.exe"), + ), + ( + "outplayed".into(), + "Outplayed".into(), + local.join(r"Overwolf\Outplayed\Outplayed.exe"), + ), + ( + "xbox".into(), + "Xbox Game Bar".into(), + PathBuf::from(r"C:\Windows\System32\GameBar.exe"), + ), + ( + "shadowplay".into(), + "NVIDIA App".into(), + program_files.join(r"NVIDIA Corporation\NVIDIA App\CEF\NVIDIA App.exe"), + ), + ( + "shadowplay-legacy".into(), + "NVIDIA Share".into(), + program_files_x86 + .join(r"NVIDIA Corporation\NVIDIA GeForce Experience\NVIDIA Share.exe"), + ), + ] +} + +fn game_label_for_path(path: &Path) -> Option { + let generic = [ + "clips", + "captures", + "recordings", + "videos", + "medal", + "shadowplay", + ]; + let mut parent = path.parent()?; + let mut label = parent.file_name()?.to_string_lossy().trim().to_owned(); + if generic + .iter() + .any(|value| label.eq_ignore_ascii_case(value)) + { + parent = parent.parent()?; + label = parent.file_name()?.to_string_lossy().trim().to_owned(); + } + (!label.is_empty()).then_some(label) +} + +fn validated_game_name(name: &str) -> AppResult<&str> { + let trimmed = name.trim(); + if trimmed.is_empty() || trimmed.len() > 120 || trimmed.chars().any(|ch| ch.is_control()) { + return Err(user_error( + "game name must contain 1 to 120 visible characters", + )); + } + Ok(trimmed) +} + +fn normalized_label(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} +fn stable_id(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) +} +fn color_for(key: &str) -> &'static str { + GAME_COLORS[(Sha256::digest(key.as_bytes())[0] as usize) % GAME_COLORS.len()] +} + +fn archive_state(job: &str, copies: u64, verified: bool, local: bool) -> String { + if copies > 1 && verified { + return "multipleRemoteCopies".into(); + } + match job { + "queued" | "retry" | "paused" => "queued".into(), + "preparing" | "verifying" => "preparing".into(), + "uploading" => "uploading".into(), + "waiting_for_quota" => "waitingForQuota".into(), + "processing_remote" => "processingRemote".into(), + "failed" | "needs_sign_in" => "uploadFailed".into(), + "remote_missing" => "remoteMissing".into(), + _ if verified && local => "archivedVerified".into(), + _ if verified => "remoteOnly".into(), + _ => "localOnly".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn game_labels_ignore_generic_capture_directories() { + assert_eq!( + game_label_for_path(Path::new(r"D:\Medal\Once Human\Clips\capture.mp4")).as_deref(), + Some("Once Human") + ); + } + #[test] + fn archive_states_are_explicit_and_non_color_dependent() { + assert_eq!( + archive_state("waiting_for_quota", 0, false, true), + "waitingForQuota" + ); + assert_eq!(archive_state("", 1, true, false), "remoteOnly"); + assert_eq!(archive_state("", 2, true, true), "multipleRemoteCopies"); + } +} diff --git a/src-tauri/src/ffmpeg.rs b/src-tauri/src/ffmpeg.rs index 8e2537e..c905228 100644 --- a/src-tauri/src/ffmpeg.rs +++ b/src-tauri/src/ffmpeg.rs @@ -2,6 +2,8 @@ use crate::error::{app_error, user_error, AppError, AppResult, ErrorCode}; use crate::models::{AudioTrack, MediaMetadata, ProbeData, ToolHealth}; use serde::Deserialize; use std::io::Read; +#[cfg(windows)] +use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -9,6 +11,9 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{path::BaseDirectory, AppHandle, Manager}; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + #[derive(Debug, Clone)] pub struct Ffmpeg { ffmpeg_path: Option, @@ -164,7 +169,7 @@ impl Ffmpeg { "FFprobe is not available in the Reelspace installation", ) })?; - let mut command = Command::new(ffprobe); + let mut command = media_command(ffprobe); command .args([ "-v", @@ -249,7 +254,7 @@ impl Ffmpeg { } let temp_path = unique_audio_temp_path(output_path); let run = |codec_args: &[&str]| -> AppResult { - let mut command = Command::new(ffmpeg); + let mut command = media_command(ffmpeg); command.args(["-y", "-hide_banner", "-loglevel", "error", "-i"]); command.arg(source); command.arg("-map").arg(format!("0:{stream_index}")); @@ -331,7 +336,7 @@ impl Ffmpeg { ((end_seconds - start_seconds) * 4.0 + 60.0).clamp(120.0, 7_200.0), ); let run = |video_args: &[&str]| -> AppResult { - let mut command = Command::new(ffmpeg); + let mut command = media_command(ffmpeg); command.args(["-y", "-hide_banner", "-loglevel", "error", "-ss", &start]); command.arg("-i").arg(source); command.args(["-t", &duration]); @@ -410,7 +415,7 @@ impl Ffmpeg { } fn thumbnail_command(ffmpeg: &Path, source: &Path, output_path: &Path, timestamp: &str) -> Command { - let mut command = Command::new(ffmpeg); + let mut command = media_command(ffmpeg); command.args([ "-y", "-hide_banner", @@ -435,6 +440,13 @@ fn thumbnail_command(ffmpeg: &Path, source: &Path, output_path: &Path, timestamp command } +fn media_command(executable: &Path) -> Command { + let mut command = Command::new(executable); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + command +} + struct ProcessResult { success: bool, stderr: String, @@ -733,6 +745,34 @@ fn parse_frame_rate(value: &str) -> Option { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn media_processes_are_created_without_a_console_window() { + const CHILD_MARKER: &str = "REELSPACE_NO_CONSOLE_CHILD"; + if std::env::var_os(CHILD_MARKER).is_some() { + let console = unsafe { windows_sys::Win32::System::Console::GetConsoleWindow() }; + assert_eq!(console, std::ptr::null_mut()); + return; + } + + let executable = std::env::current_exe().expect("current test executable"); + let mut command = media_command(&executable); + let output = command + .env(CHILD_MARKER, "1") + .args([ + "--exact", + "ffmpeg::tests::media_processes_are_created_without_a_console_window", + "--nocapture", + ]) + .output() + .expect("spawn hidden child test"); + assert!( + output.status.success(), + "hidden child failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + #[test] fn thumbnail_source_is_an_explicit_ffmpeg_input() { let command = thumbnail_command( diff --git a/src-tauri/src/jobs.rs b/src-tauri/src/jobs.rs index 8d1f512..3cf6e03 100644 --- a/src-tauri/src/jobs.rs +++ b/src-tauri/src/jobs.rs @@ -98,6 +98,13 @@ impl MediaJobQueue { format!("{operation}-{sequence}") } + pub fn active_count(&self) -> usize { + *self + .active + .lock() + .unwrap_or_else(|error| error.into_inner()) + } + pub fn acquire( self: &Arc, cancelled: Option<&AtomicBool>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e0e00f3..7f60c7c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod commands; pub mod db; pub mod error; mod explorer; +mod features; pub mod ffmpeg; mod jobs; pub mod media; @@ -11,10 +12,16 @@ mod recovery; mod state; mod storage; mod telemetry; +mod watcher; +mod windows_integration; +mod youtube; use commands::*; +use features::*; use state::AppState; use tauri::Manager; +use watcher::start_library_watcher; +use youtube::*; pub fn run() -> error::AppResult<()> { let built = tauri::Builder::default() @@ -26,6 +33,8 @@ pub fn run() -> error::AppResult<()> { } })) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { let state = AppState::new(app.handle())?; state.telemetry.event( @@ -39,8 +48,12 @@ pub fn run() -> error::AppResult<()> { }), ); let worker_state = state.clone(); + let archive_worker_state = state.clone(); + let watcher_state = state.clone(); app.manage(state); start_thumbnail_worker(worker_state); + start_youtube_worker(archive_worker_state); + start_library_watcher(watcher_state); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -65,7 +78,32 @@ pub fn run() -> error::AppResult<()> { reveal_in_explorer, get_storage_stats, get_cleanup_candidates, - delete_raw_file + delete_raw_file, + list_games, + create_game, + list_game_assignments, + rename_game, + merge_games, + assign_game, + list_archive_statuses, + set_keep_local, + queue_archive, + list_upload_jobs, + control_upload_queue, + list_media_events, + remove_from_collection, + set_collection_storage_default, + detect_recording_apps, + launch_recording_app, + remove_local_copy, + open_remote_copy, + repair_library, + open_diagnostics_folder, + export_diagnostics, + get_archive_account, + connect_youtube, + disconnect_youtube, + delete_youtube_remote ]) .build(tauri::generate_context!()); let app = match built { diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 6136262..b7a1246 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -9,6 +9,15 @@ pub struct AppSettings { pub clip_folder: Option, pub storage_limit_bytes: Option, pub cleanup_threshold_percent: u8, + pub theme: String, + pub automatically_check_updates: bool, + pub automatically_download_updates: bool, + pub include_prerelease_updates: bool, + pub preferred_recording_app: Option, + pub automatically_archive_recordings: bool, + pub automatically_archive_clips: bool, + pub keep_local_clips: bool, + pub pause_uploads_while_recording: bool, } impl Default for AppSettings { @@ -19,6 +28,15 @@ impl Default for AppSettings { clip_folder: None, storage_limit_bytes: None, cleanup_threshold_percent: 90, + theme: "system".to_owned(), + automatically_check_updates: true, + automatically_download_updates: false, + include_prerelease_updates: true, + preferred_recording_app: None, + automatically_archive_recordings: false, + automatically_archive_clips: false, + keep_local_clips: true, + pause_uploads_while_recording: true, } } } @@ -109,6 +127,7 @@ pub struct Collection { pub id: String, pub name: String, pub item_count: u32, + pub storage_default: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -184,6 +203,7 @@ pub struct AppSnapshot { pub settings: AppSettings, pub ffmpeg: ToolHealth, pub stats: StorageStats, + pub active_media_work: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -194,3 +214,83 @@ pub struct ToolHealth { pub ready: bool, pub message: String, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Game { + pub id: String, + pub name: String, + pub color: String, + pub media_count: u64, + pub aliases: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GameAssignment { + pub media_id: String, + pub game_id: String, + pub game_name: String, + pub color: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveStatus { + pub media_id: String, + pub state: String, + pub keep_local: bool, + pub remote_copy_count: u64, + pub primary_remote_url: Option, + pub progress_percent: u8, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveAccount { + pub provider: String, + pub account_id: String, + pub display_name: String, + pub connected_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UploadJob { + pub id: String, + pub media_id: String, + pub filename: String, + pub state: String, + pub priority: i64, + pub bytes_uploaded: u64, + pub total_bytes: u64, + pub attempts: u32, + pub error: Option, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MediaEvent { + pub event: String, + pub detail: Option, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecorderApp { + pub id: String, + pub name: String, + pub executable_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct RepairSummary { + pub missing_local_files: u64, + pub preserved_remote_only_items: u64, + pub thumbnails_queued: u64, + pub stale_jobs_recovered: u64, +} diff --git a/src-tauri/src/portability.rs b/src-tauri/src/portability.rs index 3002e2f..f2b7620 100644 --- a/src-tauri/src/portability.rs +++ b/src-tauri/src/portability.rs @@ -1,4 +1,7 @@ -use crate::db::{Database, PortableCollectionUpdate, PortableMediaUpdate}; +use crate::db::{ + Database, PortableCollectionUpdate, PortableGameUpdate, PortableImportBatch, + PortableMediaFeatureUpdate, PortableMediaUpdate, PortableRemoteAssetUpdate, +}; use crate::error::{user_error, AppError, AppResult}; use crate::media::normalized_path_key; use crate::models::{ @@ -13,7 +16,8 @@ use std::io::{Read, Write}; use std::path::{Component, Path, PathBuf}; const ARCHIVE_FORMAT: &str = "reelspace-library-data"; -const ARCHIVE_VERSION: u32 = 1; +const ARCHIVE_VERSION: u32 = 2; +const MIN_ARCHIVE_VERSION: u32 = 1; const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024; const MAX_ITEMS: usize = 250_000; const MAX_COLLECTIONS: usize = 50_000; @@ -28,6 +32,8 @@ struct LibraryArchive { exported_at: i64, items: Vec, collections: Vec, + #[serde(default)] + games: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -51,6 +57,34 @@ struct ArchiveItem { modified_at: i64, trim_start_seconds: Option, trim_end_seconds: Option, + #[serde(default)] + game_id: Option, + #[serde(default)] + keep_local: Option, + #[serde(default)] + remote_assets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ArchiveGame { + archive_id: String, + name: String, + color: String, + aliases: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ArchiveRemoteAsset { + provider: String, + provider_remote_id: String, + browser_url: Option, + privacy_status: Option, + verification_status: String, + processing_status: String, + verified_at: Option, + last_checked_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -59,6 +93,40 @@ struct ArchiveCollection { archive_id: String, name: String, member_ids: Vec, + #[serde(default)] + storage_default: Option, +} + +fn portable_remote_assets( + database: &Database, +) -> AppResult>> { + let conn = database.conn()?; + let mut statement = conn.prepare( + "SELECT media_id, provider, provider_remote_id, browser_url, privacy_status, + verification_status, processing_status, verified_at, last_checked_at + FROM remote_assets ORDER BY media_id, provider, provider_remote_id", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + ArchiveRemoteAsset { + provider: row.get(1)?, + provider_remote_id: row.get(2)?, + browser_url: row.get(3)?, + privacy_status: row.get(4)?, + verification_status: row.get(5)?, + processing_status: row.get(6)?, + verified_at: row.get(7)?, + last_checked_at: row.get(8)?, + }, + )) + })?; + let mut assets = HashMap::>::new(); + for row in rows { + let (media_id, asset) = row?; + assets.entry(media_id).or_default().push(asset); + } + Ok(assets) } pub fn export_library_data( @@ -79,6 +147,18 @@ pub fn export_library_data( if collections.len() > MAX_COLLECTIONS { return Err(user_error("there are too many collections to export")); } + let games = database.list_games()?; + let game_assignments = database + .list_game_assignments()? + .into_iter() + .map(|assignment| (assignment.media_id, assignment.game_id)) + .collect::>(); + let keep_local = database + .list_archive_statuses()? + .into_iter() + .map(|status| (status.media_id, status.keep_local)) + .collect::>(); + let remote_assets = portable_remote_assets(database)?; let items = media .iter() .map(|item| ArchiveItem { @@ -103,6 +183,9 @@ pub fn export_library_data( modified_at: item.modified_at, trim_start_seconds: item.trim_start_seconds, trim_end_seconds: item.trim_end_seconds, + game_id: game_assignments.get(&item.id).cloned(), + keep_local: keep_local.get(&item.id).copied(), + remote_assets: remote_assets.get(&item.id).cloned().unwrap_or_default(), }) .collect::>(); let mut membership_count = 0usize; @@ -123,6 +206,7 @@ pub fn export_library_data( archive_id: collection.id, name: collection.name, member_ids, + storage_default: collection.storage_default, }); } let archive = LibraryArchive { @@ -131,6 +215,15 @@ pub fn export_library_data( exported_at: Utc::now().timestamp(), items, collections: archive_collections, + games: games + .into_iter() + .map(|game| ArchiveGame { + archive_id: game.id, + name: game.name, + color: game.color, + aliases: game.aliases, + }) + .collect(), }; let mut writer = BoundedWriter::new(MAX_ARCHIVE_BYTES as usize); if let Err(error) = serde_json::to_writer_pretty(&mut writer, &archive) { @@ -176,6 +269,26 @@ pub fn import_library_data( validate_archive(&archive)?; let roots = reference_roots(settings, data_dir); + let existing_games = database.list_games()?; + let mut game_ids = HashMap::::new(); + let games = archive + .games + .iter() + .map(|game| { + let local_id = existing_games + .iter() + .find(|existing| existing.name.eq_ignore_ascii_case(&game.name)) + .map(|existing| existing.id.clone()) + .unwrap_or_else(|| stable_id(&format!("game:{}", game.name.trim().to_lowercase()))); + game_ids.insert(game.archive_id.clone(), local_id.clone()); + PortableGameUpdate { + id: local_id, + name: game.name.clone(), + color: game.color.clone(), + aliases: game.aliases.clone(), + } + }) + .collect::>(); let current = database.list_media(None)?; let by_path = current .iter() @@ -217,7 +330,15 @@ pub fn import_library_data( } continue; } - if let Some(path) = resolve_existing_media_path(archived, &roots) { + let resolved_local_path = resolve_existing_media_path(archived, &roots); + let path = resolved_local_path.clone().or_else(|| { + archived + .remote_assets + .iter() + .any(|asset| asset.verification_status == "verified") + .then(|| remote_only_placeholder(data_dir, archived)) + }); + if let Some(path) = path { let path_string = path.to_string_lossy().to_string(); let id = stable_id(&path_string); if by_path.contains_key(&normalized_path_key(&path)) @@ -230,7 +351,9 @@ pub fn import_library_data( id, kind: archived.kind, path: path_string, - source_path: resolve_source_path(archived, &roots), + source_path: resolved_local_path + .as_ref() + .and_then(|_| resolve_source_path(archived, &roots)), filename: path .file_name() .and_then(|value| value.to_str()) @@ -275,12 +398,61 @@ pub fn import_library_data( .collect::>() .into_iter() .collect(), + storage_default: collection.storage_default.clone(), + }) + .collect::>(); + let media_features = archive + .items + .iter() + .filter_map(|item| { + imported_ids + .get(&item.archive_id) + .map(|media_id| PortableMediaFeatureUpdate { + media_id: media_id.clone(), + game_id: item + .game_id + .as_ref() + .and_then(|archive_game_id| game_ids.get(archive_game_id)) + .cloned(), + keep_local: item.keep_local, + }) + }) + .collect::>(); + let remote_assets = archive + .items + .iter() + .filter_map(|item| { + imported_ids + .get(&item.archive_id) + .map(|media_id| (media_id, &item.remote_assets)) + }) + .flat_map(|(media_id, assets)| { + assets.iter().map(move |asset| PortableRemoteAssetUpdate { + id: stable_id(&format!( + "remote:{}:{}", + asset.provider, asset.provider_remote_id + )), + media_id: media_id.clone(), + provider: asset.provider.clone(), + provider_remote_id: asset.provider_remote_id.clone(), + browser_url: asset.browser_url.clone(), + privacy_status: asset.privacy_status.clone(), + verification_status: asset.verification_status.clone(), + processing_status: asset.processing_status.clone(), + verified_at: asset.verified_at, + last_checked_at: asset.last_checked_at, + }) }) .collect::>(); let changes = database.apply_portable_import( - &restored_items, - &updates, - &collections, + PortableImportBatch { + restored_items: &restored_items, + media_updates: &updates, + collections: &collections, + games: &games, + media_features: &media_features, + remote_assets: &remote_assets, + }, Utc::now().timestamp(), )?; Ok(LibraryDataImportSummary { @@ -301,7 +473,7 @@ fn validate_archive(archive: &LibraryArchive) -> AppResult<()> { "the selected file is not a Reelspace data archive", )); } - if archive.version != ARCHIVE_VERSION { + if !(MIN_ARCHIVE_VERSION..=ARCHIVE_VERSION).contains(&archive.version) { return Err(user_error(format!( "Reelspace data archive version {} is not supported", archive.version @@ -315,8 +487,60 @@ fn validate_archive(archive: &LibraryArchive) -> AppResult<()> { if archive.collections.len() > MAX_COLLECTIONS { return Err(user_error("the data archive contains too many collections")); } + if archive.games.len() > MAX_COLLECTIONS { + return Err(user_error("the data archive contains too many games")); + } + let mut game_ids = HashSet::with_capacity(archive.games.len()); + let mut game_names = HashSet::with_capacity(archive.games.len()); + for game in &archive.games { + validate_id(&game.archive_id, "game")?; + if !game_ids.insert(game.archive_id.as_str()) { + return Err(user_error( + "the data archive contains duplicate game identifiers", + )); + } + let name = game.name.trim(); + let normalized_name = name.split_whitespace().collect::>().join(" "); + if name.is_empty() + || name != game.name + || normalized_name != name + || name.chars().count() > 120 + || contains_forbidden_control(name, false) + || !game_names.insert(name.to_lowercase()) + { + return Err(user_error("an imported game name is invalid or duplicated")); + } + if game.color.len() != 7 + || !game.color.starts_with('#') + || !game.color[1..] + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err(user_error("an imported game color is invalid")); + } + if game.aliases.len() > 1_000 { + return Err(user_error("an imported game has too many aliases")); + } + let mut aliases = HashSet::with_capacity(game.aliases.len()); + for original_alias in &game.aliases { + let alias = original_alias.trim(); + let normalized_alias = alias.split_whitespace().collect::>().join(" "); + if alias.is_empty() + || alias != original_alias + || normalized_alias != alias + || alias.chars().count() > 120 + || contains_forbidden_control(alias, false) + || !aliases.insert(alias.to_lowercase()) + { + return Err(user_error( + "an imported game alias is invalid or duplicated", + )); + } + } + } let mut ids = HashSet::with_capacity(archive.items.len()); let mut original_paths = HashSet::with_capacity(archive.items.len()); + let mut remote_ids = HashSet::new(); for item in &archive.items { validate_id(&item.archive_id, "media")?; if !ids.insert(item.archive_id.as_str()) { @@ -330,6 +554,22 @@ fn validate_archive(archive: &LibraryArchive) -> AppResult<()> { )); } validate_item(item)?; + for asset in &item.remote_assets { + if !remote_ids.insert((asset.provider.as_str(), asset.provider_remote_id.as_str())) { + return Err(user_error( + "the data archive contains duplicate remote references", + )); + } + } + if item + .game_id + .as_ref() + .is_some_and(|game_id| !game_ids.contains(game_id.as_str())) + { + return Err(user_error( + "a media item references a game missing from the data archive", + )); + } } let mut collection_ids = HashSet::with_capacity(archive.collections.len()); let mut collection_names = HashSet::with_capacity(archive.collections.len()); @@ -350,6 +590,14 @@ fn validate_archive(archive: &LibraryArchive) -> AppResult<()> { "the data archive contains duplicate collection names", )); } + if !matches!( + collection.storage_default.as_deref(), + None | Some("keepLocal") | Some("archive") + ) { + return Err(user_error( + "an imported collection storage default is invalid", + )); + } memberships = memberships.saturating_add(collection.member_ids.len()); if memberships > MAX_MEMBERSHIPS { return Err(user_error( @@ -428,6 +676,34 @@ fn validate_item(item: &ArchiveItem) -> AppResult<()> { return Err(user_error("imported media notes are not normalized")); } validate_trim(item)?; + let mut remote_ids = HashSet::with_capacity(item.remote_assets.len()); + for asset in &item.remote_assets { + if asset.provider != "youtube" + || asset.provider_remote_id.is_empty() + || asset.provider_remote_id.len() > 256 + || contains_forbidden_control(&asset.provider_remote_id, false) + || !remote_ids.insert((&asset.provider, &asset.provider_remote_id)) + || asset.privacy_status.as_deref() != Some("private") + || asset.verification_status != "verified" + || asset.processing_status != "processed" + { + return Err(user_error( + "an imported remote archive reference is invalid", + )); + } + if let Some(browser_url) = &asset.browser_url { + let parsed = url::Url::parse(browser_url) + .map_err(|_| user_error("an imported remote archive URL is invalid"))?; + let allowed_host = parsed.host_str().is_some_and(|host| { + host.eq_ignore_ascii_case("youtu.be") + || host.eq_ignore_ascii_case("youtube.com") + || host.eq_ignore_ascii_case("www.youtube.com") + }); + if parsed.scheme() != "https" || !allowed_host { + return Err(user_error("an imported remote archive URL is invalid")); + } + } + } Ok(()) } @@ -510,6 +786,13 @@ fn resolve_existing_media_path(archived: &ArchiveItem, roots: &[PathBuf]) -> Opt } } +fn remote_only_placeholder(data_dir: &Path, archived: &ArchiveItem) -> PathBuf { + data_dir + .join("remote-only") + .join(&archived.archive_id) + .join(&archived.filename) +} + fn resolve_source_path(archived: &ArchiveItem, roots: &[PathBuf]) -> Option { let mut paths = archived .original_source_path @@ -885,6 +1168,9 @@ mod tests { modified_at: 2, trim_start_seconds: None, trim_end_seconds: None, + game_id: None, + keep_local: None, + remote_assets: Vec::new(), } } @@ -898,7 +1184,9 @@ mod tests { archive_id: "b".repeat(64), name: "Favorites".into(), member_ids: vec!["a".repeat(64)], + storage_default: None, }], + games: Vec::new(), } } @@ -924,6 +1212,20 @@ mod tests { source_db .add_to_collection(&"b".repeat(64), &source_item.id, 2) .expect("membership"); + source_db + .set_collection_storage_default(&"b".repeat(64), Some("keepLocal")) + .expect("collection default"); + source_db.create_game("Hades").expect("game"); + let game_id = source_db.list_games().expect("games")[0].id.clone(); + source_db + .assign_game(&source_item.id, &game_id) + .expect("classification"); + source_db + .rename_game(&game_id, "Hades II", true) + .expect("rename game"); + source_db + .set_keep_local(&source_item.id, true) + .expect("keep local"); let archive_path = directory.join("library-data.json"); let export = export_library_data( &source_db, @@ -969,9 +1271,24 @@ mod tests { let updated = target_db.get_media(&target_item.id).expect("updated"); assert_eq!(updated.tags, vec!["local", "highlight"]); assert_eq!(updated.notes.as_deref(), Some("portable note")); + let imported_collection = &target_db.list_collections().expect("collections")[0]; + assert_eq!(imported_collection.item_count, 1); assert_eq!( - target_db.list_collections().expect("collections")[0].item_count, - 1 + imported_collection.storage_default.as_deref(), + Some("keepLocal") + ); + let imported_game = &target_db.list_games().expect("imported games")[0]; + assert_eq!(imported_game.name, "Hades II"); + assert!(imported_game.aliases.iter().any(|alias| alias == "Hades")); + let assignment = &target_db + .list_game_assignments() + .expect("imported assignments")[0]; + assert_eq!(assignment.media_id, target_item.id); + assert!( + target_db + .list_archive_statuses() + .expect("imported preferences")[0] + .keep_local ); target_db @@ -1060,6 +1377,47 @@ mod tests { let _ = fs::remove_dir_all(directory); } + #[test] + fn verified_remote_only_records_survive_without_copying_video_files() { + let directory = fixture("remote-only"); + let mut archive = archive(); + archive.collections.clear(); + archive.items[0].relative_path = None; + archive.items[0].remote_assets = vec![ArchiveRemoteAsset { + provider: "youtube".into(), + provider_remote_id: "private-video-id".into(), + browser_url: Some("https://www.youtube.com/watch?v=private-video-id".into()), + privacy_status: Some("private".into()), + verification_status: "verified".into(), + processing_status: "processed".into(), + verified_at: Some(10), + last_checked_at: 10, + }]; + let archive_path = directory.join("remote-only.json"); + fs::write( + &archive_path, + serde_json::to_vec_pretty(&archive).expect("serialize archive"), + ) + .expect("write archive"); + + let database = Database::open(directory.join("target.sqlite")).expect("database"); + let summary = import_library_data( + &database, + &AppSettings::default(), + &directory, + &archive_path, + ) + .expect("remote-only import"); + assert_eq!(summary.restored_items, 1); + let media = database.list_media(None).expect("media"); + assert_eq!(media.len(), 1); + assert!(!Path::new(&media[0].path).exists()); + let status = &database.list_archive_statuses().expect("archive status")[0]; + assert_eq!(status.state, "remoteOnly"); + assert_eq!(status.remote_copy_count, 1); + let _ = fs::remove_dir_all(directory); + } + #[test] fn validation_rejects_ambiguous_or_malformed_archive_data() { let mut wrong_format = archive(); diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs new file mode 100644 index 0000000..03b14fb --- /dev/null +++ b/src-tauri/src/watcher.rs @@ -0,0 +1,57 @@ +use crate::commands::scan_sources_inner; +use crate::state::AppState; +use notify::{RecursiveMode, Watcher}; +use serde_json::json; +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +pub(crate) fn start_library_watcher(state: AppState) { + std::thread::Builder::new().name("reelspace-library-watcher".into()).spawn(move || { + let (sender, receiver) = mpsc::channel(); + let mut watcher = match notify::recommended_watcher(move |event| { let _ = sender.send(event); }) { + Ok(watcher) => watcher, + Err(error) => { + state.telemetry.event("error", "library.watcher.unavailable", json!({ "error": error.to_string() })); + return; + } + }; + let mut watched = BTreeSet::::new(); + let mut last_reconcile = Instant::now(); + loop { + if let Ok(settings) = state.db.load_settings() { + let wanted = settings.source_folders.into_iter().map(PathBuf::from).filter(|path| path.is_dir()).collect::>(); + for path in watched.difference(&wanted) { let _ = watcher.unwatch(path); } + for path in wanted.difference(&watched) { + if let Err(error) = watcher.watch(path, RecursiveMode::Recursive) { + state.telemetry.event("warn", "library.watcher.path_failed", json!({ "pathId": stable_path_id(path), "error": error.to_string() })); + } + } + watched = wanted; + } + let mut should_scan = last_reconcile.elapsed() >= Duration::from_secs(15 * 60); + match receiver.recv_timeout(Duration::from_secs(5)) { + Ok(Ok(_)) => { + std::thread::sleep(Duration::from_millis(1200)); + while receiver.try_recv().is_ok() {} + should_scan = true; + } + Ok(Err(error)) => state.telemetry.event("warn", "library.watcher.event_failed", json!({ "error": error.to_string() })), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => return, + } + if should_scan && !watched.is_empty() { + match scan_sources_inner(&state) { + Ok(_) => last_reconcile = Instant::now(), + Err(error) => state.telemetry.event("warn", "library.watcher.scan_failed", json!({ "code": error.code })), + } + } + } + }).ok(); +} + +fn stable_path_id(path: &std::path::Path) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(path.to_string_lossy().as_bytes()))[..16].to_owned() +} diff --git a/src-tauri/src/windows_integration.rs b/src-tauri/src/windows_integration.rs new file mode 100644 index 0000000..e397e5b --- /dev/null +++ b/src-tauri/src/windows_integration.rs @@ -0,0 +1,89 @@ +use crate::error::{app_error, user_error, AppResult, ErrorCode}; +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use std::ptr; +use url::Url; +use windows_sys::Win32::UI::Shell::ShellExecuteW; +use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + +pub(crate) fn open_external_url(url: &str, failure_message: &str) -> AppResult<()> { + validate_external_url(url)?; + let operation = wide("open"); + let target = wide(url); + let result = unsafe { + ShellExecuteW( + ptr::null_mut(), + operation.as_ptr(), + target.as_ptr(), + ptr::null(), + ptr::null(), + SW_SHOWNORMAL, + ) + }; + if result as isize > 32 { + Ok(()) + } else { + Err( + app_error(ErrorCode::WindowsIntegrationFailed, failure_message) + .with_detail(format!("ShellExecuteW returned {}", result as isize)), + ) + } +} + +fn validate_external_url(value: &str) -> AppResult<()> { + if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) { + return Err(user_error("the external browser link is malformed")); + } + let parsed = + Url::parse(value).map_err(|_| user_error("the external browser link is malformed"))?; + if parsed.scheme() != "https" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return Err(user_error( + "the external browser link is not a safe HTTPS URL", + )); + } + Ok(()) +} + +fn wide(value: &str) -> Vec { + OsStr::new(value).encode_wide().chain(Some(0)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn external_browser_links_require_safe_https_urls() { + assert!( + validate_external_url("https://accounts.google.com/o/oauth2/v2/auth?state=abc").is_ok() + ); + assert!(validate_external_url("https://www.youtube.com/watch?v=abc").is_ok()); + for invalid in [ + "", + "http://accounts.google.com/", + "file:///C:/Windows/System32/calc.exe", + "https://user:password@example.com/", + "https://example.com/\nnext", + "not a url", + ] { + assert!( + validate_external_url(invalid).is_err(), + "accepted {invalid:?}" + ); + } + } + + #[test] + fn wide_strings_are_null_terminated_without_changing_text() { + let encoded = wide("open"); + assert_eq!(encoded.last(), Some(&0)); + assert_eq!( + String::from_utf16(&encoded[..encoded.len() - 1]).unwrap(), + "open" + ); + } +} diff --git a/src-tauri/src/youtube.rs b/src-tauri/src/youtube.rs new file mode 100644 index 0000000..0e6d0a4 --- /dev/null +++ b/src-tauri/src/youtube.rs @@ -0,0 +1,933 @@ +use crate::db::Database; +use crate::error::{app_error, user_error, AppResult, ErrorCode}; +use crate::models::ArchiveAccount; +use crate::state::AppState; +use crate::windows_integration::open_external_url; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use chrono::Utc; +use keyring::Entry; +use rand::{distr::Alphanumeric, Rng}; +use reqwest::blocking::{Client, Response}; +use reqwest::header::{CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, LOCATION, RANGE}; +use rusqlite::{params, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tauri::State; +use url::Url; + +const CREDENTIAL_SERVICE: &str = "Reelspace Archive"; +const CREDENTIAL_ACCOUNT: &str = "youtube-oauth"; +const YOUTUBE_SCOPE: &str = "https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly"; +const DEFAULT_CLIENT_ID: &str = + "213662861772-slkhvmapt8hpv7r6f1ll3tv4gvcnfcd5.apps.googleusercontent.com"; +const BUNDLED_CLIENT_ID: Option<&str> = option_env!("REELSPACE_YOUTUBE_CLIENT_ID"); +const UPLOAD_CHUNK_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredCredentials { + client_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + client_secret: Option, + refresh_token: String, + access_token: String, + expires_at: i64, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + expires_in: i64, + refresh_token: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct ArchiveWork { + id: String, + media_id: String, + path: PathBuf, + filename: String, + total_bytes: u64, + bytes_uploaded: u64, + session_url: Option, + provider_remote_id: Option, + attempts: u32, +} + +impl Database { + pub fn get_youtube_remote_id(&self, media_id: &str) -> AppResult { + self.conn()?.query_row( + "SELECT provider_remote_id FROM remote_assets WHERE media_id = ?1 AND provider = 'youtube' ORDER BY verified_at DESC LIMIT 1", + [media_id], |row| row.get(0), + ).optional()?.ok_or_else(|| user_error("no YouTube archive exists for this media item")) + } + + pub fn remove_youtube_remote(&self, media_id: &str, remote_id: &str) -> AppResult<()> { + let now = Utc::now().timestamp(); + let mut conn = self.conn()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("DELETE FROM remote_assets WHERE media_id = ?1 AND provider = 'youtube' AND provider_remote_id = ?2", params![media_id, remote_id])?; + tx.execute("UPDATE upload_jobs SET state = 'canceled', error = 'Remote copy deleted', updated_at = ?1 WHERE media_id = ?2 AND provider_remote_id = ?3", params![now, media_id, remote_id])?; + tx.execute("INSERT INTO media_events (media_id, event, detail, created_at) VALUES (?1, 'remote_copy_deleted', ?2, ?3)", params![media_id, format!("YouTube video {remote_id}"), now])?; + tx.commit()?; + Ok(()) + } + + pub fn save_archive_account(&self, account: &ArchiveAccount) -> AppResult<()> { + self.conn()?.execute( + "INSERT OR REPLACE INTO archive_accounts (provider, account_id, display_name, connected_at) VALUES (?1, ?2, ?3, ?4)", + params![account.provider, account.account_id, account.display_name, account.connected_at], + )?; + Ok(()) + } + + pub fn get_archive_account(&self) -> AppResult> { + self.conn()?.query_row( + "SELECT provider, account_id, display_name, connected_at FROM archive_accounts WHERE provider = 'youtube'", + [], + |row| Ok(ArchiveAccount { provider: row.get(0)?, account_id: row.get(1)?, display_name: row.get(2)?, connected_at: row.get(3)? }), + ).optional().map_err(Into::into) + } + + pub fn delete_archive_account(&self) -> AppResult<()> { + let conn = self.conn()?; + conn.execute( + "DELETE FROM archive_accounts WHERE provider = 'youtube'", + [], + )?; + conn.execute("UPDATE upload_jobs SET state = 'needs_sign_in', error = 'Connect YouTube to continue', updated_at = ?1 WHERE state NOT IN ('archived', 'canceled')", [Utc::now().timestamp()])?; + Ok(()) + } + + pub fn resume_authenticated_jobs(&self) -> AppResult<()> { + self.conn()?.execute( + "UPDATE upload_jobs SET state = 'queued', error = NULL, next_attempt_at = 0, updated_at = ?1 WHERE state = 'needs_sign_in'", + [Utc::now().timestamp()], + )?; + Ok(()) + } + + pub(crate) fn claim_archive_work(&self) -> AppResult> { + let mut conn = self.conn()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let now = Utc::now().timestamp(); + let work = tx.query_row( + "SELECT uj.id, uj.media_id, m.path, m.filename, uj.total_bytes, uj.bytes_uploaded, uj.session_url, uj.provider_remote_id, uj.attempts + FROM upload_jobs uj INNER JOIN media m ON m.id = uj.media_id + WHERE uj.state IN ('queued', 'retry', 'processing_remote') AND uj.next_attempt_at <= ?1 + ORDER BY uj.priority DESC, uj.created_at LIMIT 1", + [now], + |row| Ok(ArchiveWork { + id: row.get(0)?, media_id: row.get(1)?, path: PathBuf::from(row.get::<_, String>(2)?), filename: row.get(3)?, + total_bytes: row.get::<_, i64>(4)?.max(0) as u64, bytes_uploaded: row.get::<_, i64>(5)?.max(0) as u64, + session_url: row.get(6)?, provider_remote_id: row.get(7)?, attempts: row.get::<_, i64>(8)?.max(0) as u32, + }), + ).optional()?; + if let Some(work) = &work { + let state = if work.provider_remote_id.is_some() { + "verifying" + } else { + "preparing" + }; + tx.execute("UPDATE upload_jobs SET state = ?1, attempts = attempts + 1, updated_at = ?2 WHERE id = ?3", params![state, now, work.id])?; + } + tx.commit()?; + Ok(work) + } + + pub(crate) fn set_upload_session(&self, job_id: &str, session_url: &str) -> AppResult<()> { + self.conn()?.execute("UPDATE upload_jobs SET session_url = ?1, state = 'uploading', updated_at = ?2 WHERE id = ?3", params![session_url, Utc::now().timestamp(), job_id])?; + Ok(()) + } + + pub(crate) fn set_upload_progress(&self, job_id: &str, bytes: u64) -> AppResult<()> { + self.conn()?.execute("UPDATE upload_jobs SET bytes_uploaded = ?1, state = 'uploading', updated_at = ?2 WHERE id = ?3", params![bytes as i64, Utc::now().timestamp(), job_id])?; + Ok(()) + } + + pub(crate) fn set_remote_processing(&self, job_id: &str, remote_id: &str) -> AppResult<()> { + let now = Utc::now().timestamp(); + self.conn()?.execute( + "UPDATE upload_jobs SET provider_remote_id = ?1, bytes_uploaded = total_bytes, state = 'processing_remote', next_attempt_at = ?2, error = NULL, updated_at = ?3 WHERE id = ?4", + params![remote_id, now + 30, now, job_id], + )?; + Ok(()) + } + + pub(crate) fn complete_archive(&self, work: &ArchiveWork) -> AppResult<()> { + let remote_id = work + .provider_remote_id + .as_deref() + .ok_or_else(|| user_error("the upload has no remote identifier"))?; + let now = Utc::now().timestamp(); + let mut conn = self.conn()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute( + "INSERT OR REPLACE INTO remote_assets (id, media_id, provider, provider_remote_id, browser_url, privacy_status, verification_status, processing_status, verified_at, last_checked_at) + VALUES (?1, ?2, 'youtube', ?3, ?4, 'private', 'verified', 'processed', ?5, ?5)", + params![format!("youtube:{remote_id}"), work.media_id, remote_id, format!("https://www.youtube.com/watch?v={remote_id}"), now], + )?; + tx.execute("UPDATE upload_jobs SET state = 'archived', error = NULL, next_attempt_at = 0, updated_at = ?1 WHERE id = ?2", params![now, work.id])?; + tx.execute("INSERT INTO media_events (media_id, event, detail, created_at) VALUES (?1, 'archive_verified', ?2, ?3)", params![work.media_id, format!("YouTube video {remote_id}"), now])?; + tx.commit()?; + Ok(()) + } + + pub(crate) fn reschedule_archive( + &self, + work: &ArchiveWork, + state: &str, + message: &str, + delay_seconds: i64, + ) -> AppResult<()> { + let now = Utc::now().timestamp(); + self.conn()?.execute( + "UPDATE upload_jobs SET state = ?1, error = ?2, next_attempt_at = ?3, updated_at = ?4 WHERE id = ?5", + params![state, message, now + delay_seconds, now, work.id], + )?; + Ok(()) + } +} + +#[tauri::command] +pub fn get_archive_account(state: State<'_, AppState>) -> AppResult> { + state.db.get_archive_account() +} + +#[tauri::command] +pub async fn connect_youtube(state: State<'_, AppState>) -> AppResult { + let client_id = bundled_oauth_client_id()?; + let runtime = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || connect_youtube_inner(&runtime, client_id)) + .await + .map_err(|error| { + app_error( + ErrorCode::Internal, + "The YouTube connection task stopped unexpectedly", + ) + .with_detail(error.to_string()) + })? +} + +#[tauri::command] +pub async fn disconnect_youtube(state: State<'_, AppState>) -> AppResult<()> { + let runtime = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || disconnect_youtube_inner(&runtime)) + .await + .map_err(|error| { + app_error( + ErrorCode::Internal, + "The YouTube disconnect task stopped unexpectedly", + ) + .with_detail(error.to_string()) + })? +} + +#[tauri::command] +pub async fn delete_youtube_remote(state: State<'_, AppState>, media_id: String) -> AppResult<()> { + let runtime = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || delete_youtube_remote_inner(&runtime, &media_id)) + .await + .map_err(|error| { + app_error( + ErrorCode::Internal, + "The remote deletion task stopped unexpectedly", + ) + .with_detail(error.to_string()) + })? +} + +pub(crate) fn start_youtube_worker(state: AppState) { + std::thread::Builder::new() + .name("reelspace-youtube-archive".into()) + .spawn(move || loop { + let pause_for_media = state + .db + .load_settings() + .map(|settings| settings.pause_uploads_while_recording) + .unwrap_or(true) + && (state.media_jobs.active_count() > 0 + || state.export_jobs.active_count() > 0 + || state.background_jobs.active_count() > 0); + if pause_for_media { + std::thread::sleep(Duration::from_secs(3)); + continue; + } + match state.db.claim_archive_work() { + Ok(Some(work)) => { + if let Err(error) = process_archive_work(&state, &work) { + state.telemetry.event( + "error", + "archive.job.failed", + json!({ "mediaId": work.media_id, "code": error.code }), + ); + let delay = retry_delay(work.attempts); + let next_state = if error.code == ErrorCode::PermissionDenied { + "needs_sign_in" + } else if error.message.contains("allowance") { + "waiting_for_quota" + } else { + "retry" + }; + let next_delay = if next_state == "waiting_for_quota" { + 24 * 60 * 60 + } else { + delay + }; + let _ = state.db.reschedule_archive( + &work, + next_state, + &error.message, + next_delay, + ); + } + } + Ok(None) => std::thread::sleep(Duration::from_secs(5)), + Err(_) => std::thread::sleep(Duration::from_secs(10)), + } + }) + .ok(); +} + +fn connect_youtube_inner(state: &AppState, client_id: String) -> AppResult { + let listener = TcpListener::bind("127.0.0.1:0").map_err(|error| { + app_error( + ErrorCode::WindowsIntegrationFailed, + "Reelspace could not start the local OAuth callback", + ) + .with_detail(error.to_string()) + })?; + let port = listener.local_addr()?.port(); + let redirect_uri = format!("http://127.0.0.1:{port}"); + let verifier = random_token(72); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + let expected_state = random_token(40); + let mut authorize = + Url::parse("https://accounts.google.com/o/oauth2/v2/auth").expect("static OAuth URL"); + authorize + .query_pairs_mut() + .append_pair("client_id", &client_id) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", YOUTUBE_SCOPE) + .append_pair("access_type", "offline") + .append_pair("prompt", "consent") + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", &expected_state); + open_external_url( + authorize.as_str(), + "The Google authorization page could not be opened", + )?; + let (code, returned_state) = receive_oauth_callback(listener)?; + if returned_state != expected_state { + return Err(user_error( + "the OAuth response did not match this Reelspace session", + )); + } + let client = http_client()?; + let response = client + .post("https://oauth2.googleapis.com/token") + .form(&[ + ("client_id", client_id.as_str()), + ("code", code.as_str()), + ("code_verifier", verifier.as_str()), + ("grant_type", "authorization_code"), + ("redirect_uri", redirect_uri.as_str()), + ]) + .send() + .map_err(network_error)?; + let response = require_success(response, "Google rejected the OAuth token exchange")?; + let token: TokenResponse = response.json().map_err(network_error)?; + let refresh_token = token.refresh_token.ok_or_else(|| { + user_error( + "Google did not return an offline refresh token; revoke Reelspace access and try again", + ) + })?; + let credentials = StoredCredentials { + client_id, + client_secret: None, + refresh_token, + access_token: token.access_token, + expires_at: Utc::now().timestamp() + token.expires_in, + }; + let account = fetch_channel(&client, &credentials.access_token)?; + save_credentials(&credentials)?; + state.db.save_archive_account(&account)?; + state.db.resume_authenticated_jobs()?; + Ok(account) +} + +fn disconnect_youtube_inner(state: &AppState) -> AppResult<()> { + if let Ok(credentials) = load_credentials() { + let _ = http_client()? + .post("https://oauth2.googleapis.com/revoke") + .form(&[("token", credentials.refresh_token)]) + .send(); + } + let entry = credential_entry()?; + if let Err(error) = entry.delete_credential() { + if !matches!(error, keyring::Error::NoEntry) { + return Err(credential_error(error)); + } + } + state.db.delete_archive_account() +} + +fn delete_youtube_remote_inner(state: &AppState, media_id: &str) -> AppResult<()> { + let remote_id = state.db.get_youtube_remote_id(media_id)?; + let client = http_client()?; + let mut credentials = load_credentials()?; + refresh_access_token(&client, &mut credentials)?; + let response = client + .delete("https://www.googleapis.com/youtube/v3/videos") + .bearer_auth(&credentials.access_token) + .query(&[("id", remote_id.as_str())]) + .send() + .map_err(network_error)?; + if !response.status().is_success() && response.status().as_u16() != 404 { + return Err(youtube_response_error(response)); + } + state.db.remove_youtube_remote(media_id, &remote_id) +} + +fn process_archive_work(state: &AppState, work: &ArchiveWork) -> AppResult<()> { + let client = http_client()?; + let mut credentials = load_credentials()?; + refresh_access_token(&client, &mut credentials)?; + if let Some(remote_id) = &work.provider_remote_id { + return verify_remote(state, work, &client, &credentials.access_token, remote_id); + } + let metadata = std::fs::metadata(&work.path).map_err(|error| { + app_error( + ErrorCode::SourceUnavailable, + "The queued local file is unavailable", + ) + .with_detail(error.to_string()) + })?; + if metadata.len() != work.total_bytes { + return Err(app_error( + ErrorCode::FileChanged, + "The queued file changed after upload was scheduled", + )); + } + let mut session = work.session_url.clone(); + let mut offset = work.bytes_uploaded; + if let Some(url) = &session { + match query_upload_offset(&client, url, &credentials.access_token, work.total_bytes)? { + UploadOffset::Complete(remote_id) => { + state.db.set_remote_processing(&work.id, &remote_id)?; + return Ok(()); + } + UploadOffset::Resume(server_offset) => offset = server_offset, + UploadOffset::Expired => { + session = None; + offset = 0; + } + } + } + if session.is_none() { + let url = begin_upload(&client, &credentials.access_token, work)?; + state.db.set_upload_session(&work.id, &url)?; + session = Some(url); + } + let url = session.expect("upload session initialized"); + let mut file = std::fs::File::open(&work.path)?; + while offset < work.total_bytes { + file.seek(SeekFrom::Start(offset))?; + let chunk_len = (work.total_bytes - offset).min(UPLOAD_CHUNK_BYTES as u64) as usize; + let mut chunk = vec![0_u8; chunk_len]; + file.read_exact(&mut chunk)?; + let last = offset + chunk_len as u64 - 1; + let response = client + .put(&url) + .bearer_auth(&credentials.access_token) + .header(CONTENT_TYPE, "application/octet-stream") + .header(CONTENT_LENGTH, chunk_len) + .header( + CONTENT_RANGE, + format!("bytes {offset}-{last}/{}", work.total_bytes), + ) + .body(chunk) + .send() + .map_err(network_error)?; + if response.status().as_u16() == 308 { + offset = response + .headers() + .get(RANGE) + .and_then(|value| value.to_str().ok()) + .and_then(parse_uploaded_range) + .unwrap_or(last + 1); + state.db.set_upload_progress(&work.id, offset)?; + continue; + } + if response.status().is_success() { + let payload: Value = response.json().map_err(network_error)?; + let remote_id = payload.get("id").and_then(Value::as_str).ok_or_else(|| { + app_error( + ErrorCode::Internal, + "YouTube completed the upload without returning a video id", + ) + })?; + state.db.set_remote_processing(&work.id, remote_id)?; + return Ok(()); + } + return Err(youtube_response_error(response)); + } + Ok(()) +} + +fn begin_upload(client: &Client, access_token: &str, work: &ArchiveWork) -> AppResult { + let title = Path::new(&work.filename) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("Reelspace recording"); + let response = client.post("https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status") + .bearer_auth(access_token).header(CONTENT_TYPE, "application/json; charset=UTF-8") + .header("X-Upload-Content-Length", work.total_bytes).header("X-Upload-Content-Type", "application/octet-stream") + .json(&json!({ "snippet": { "title": title, "description": "Private archive created by Reelspace", "categoryId": "20" }, "status": { "privacyStatus": "private", "embeddable": false } })) + .send().map_err(network_error)?; + let response = require_success(response, "YouTube rejected the resumable upload request")?; + response + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + .ok_or_else(|| { + app_error( + ErrorCode::Internal, + "YouTube did not return a resumable upload location", + ) + }) +} + +enum UploadOffset { + Resume(u64), + Complete(String), + Expired, +} +fn query_upload_offset( + client: &Client, + url: &str, + token: &str, + total: u64, +) -> AppResult { + let response = client + .put(url) + .bearer_auth(token) + .header(CONTENT_LENGTH, 0) + .header(CONTENT_RANGE, format!("bytes */{total}")) + .send() + .map_err(network_error)?; + if response.status().as_u16() == 404 { + return Ok(UploadOffset::Expired); + } + if response.status().as_u16() == 308 { + let offset = response + .headers() + .get(RANGE) + .and_then(|value| value.to_str().ok()) + .and_then(parse_uploaded_range) + .unwrap_or(0); + return Ok(UploadOffset::Resume(offset)); + } + if response.status().is_success() { + let payload: Value = response.json().map_err(network_error)?; + if let Some(id) = payload.get("id").and_then(Value::as_str) { + return Ok(UploadOffset::Complete(id.to_owned())); + } + } + Err(app_error( + ErrorCode::Internal, + "YouTube reported a completed upload without a video id", + )) +} + +fn verify_remote( + state: &AppState, + work: &ArchiveWork, + client: &Client, + token: &str, + remote_id: &str, +) -> AppResult<()> { + let response = client + .get("https://www.googleapis.com/youtube/v3/videos") + .bearer_auth(token) + .query(&[("part", "status"), ("id", remote_id)]) + .send() + .map_err(network_error)?; + let response = require_success(response, "YouTube archive verification failed")?; + let payload: Value = response.json().map_err(network_error)?; + let status = payload + .pointer("/items/0/status/uploadStatus") + .and_then(Value::as_str); + let privacy = payload + .pointer("/items/0/status/privacyStatus") + .and_then(Value::as_str); + match (status, privacy) { + (Some("processed"), Some("private")) => state.db.complete_archive(work), + (Some("failed" | "rejected"), _) => state.db.reschedule_archive( + work, + "failed", + "YouTube rejected or failed remote processing", + 0, + ), + (None, _) => state.db.reschedule_archive( + work, + "remote_missing", + "The uploaded YouTube video is no longer accessible", + 0, + ), + _ => state.db.reschedule_archive( + work, + "processing_remote", + "YouTube is still processing the private upload", + 45, + ), + } +} + +fn fetch_channel(client: &Client, token: &str) -> AppResult { + let response = client + .get("https://www.googleapis.com/youtube/v3/channels") + .bearer_auth(token) + .query(&[("part", "snippet"), ("mine", "true")]) + .send() + .map_err(network_error)?; + let response = require_success( + response, + "The connected Google account has no accessible YouTube channel", + )?; + let payload: Value = response.json().map_err(network_error)?; + let item = payload.pointer("/items/0").ok_or_else(|| { + user_error("the connected Google account does not have a YouTube channel") + })?; + Ok(ArchiveAccount { + provider: "youtube".into(), + account_id: item + .get("id") + .and_then(Value::as_str) + .unwrap_or("unknown") + .into(), + display_name: item + .pointer("/snippet/title") + .and_then(Value::as_str) + .unwrap_or("YouTube") + .into(), + connected_at: Utc::now().timestamp(), + }) +} + +fn refresh_access_token(client: &Client, credentials: &mut StoredCredentials) -> AppResult<()> { + if credentials.expires_at > Utc::now().timestamp() + 90 { + return Ok(()); + } + let mut form = vec![ + ("client_id", credentials.client_id.as_str()), + ("refresh_token", credentials.refresh_token.as_str()), + ("grant_type", "refresh_token"), + ]; + if let Some(secret) = credentials.client_secret.as_deref() { + form.push(("client_secret", secret)); + } + let response = client + .post("https://oauth2.googleapis.com/token") + .form(&form) + .send() + .map_err(network_error)?; + let response = require_success(response, "Google sign-in expired; reconnect YouTube")?; + let token: TokenResponse = response.json().map_err(network_error)?; + credentials.access_token = token.access_token; + credentials.expires_at = Utc::now().timestamp() + token.expires_in; + save_credentials(credentials) +} + +fn receive_oauth_callback(listener: TcpListener) -> AppResult<(String, String)> { + listener.set_nonblocking(true)?; + let deadline = Instant::now() + Duration::from_secs(180); + loop { + match listener.accept() { + Ok((mut stream, _)) => { + let mut buffer = [0_u8; 8192]; + let read = stream.read(&mut buffer)?; + let request = String::from_utf8_lossy(&buffer[..read]); + let target = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .ok_or_else(|| user_error("the OAuth callback was malformed"))?; + let url = Url::parse(&format!("http://127.0.0.1{target}")) + .map_err(|_| user_error("the OAuth callback URL was malformed"))?; + let values = url + .query_pairs() + .collect::>(); + let html = "Reelspace connected

Return to Reelspace

You can close this tab.

"; + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}", html.len()); + let _ = stream.write_all(response.as_bytes()); + if let Some(error) = values.get("error") { + return Err(user_error(format!( + "Google authorization was not completed: {error}" + ))); + } + let code = values + .get("code") + .map(|value| value.to_string()) + .ok_or_else(|| user_error("Google did not return an authorization code"))?; + let state = values + .get("state") + .map(|value| value.to_string()) + .ok_or_else(|| user_error("Google did not return the OAuth session state"))?; + return Ok((code, state)); + } + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(100)) + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + return Err(user_error("Google authorization timed out")) + } + Err(error) => return Err(error.into()), + } + } +} + +fn bundled_oauth_client_id() -> AppResult { + let client_id = BUNDLED_CLIENT_ID.unwrap_or(DEFAULT_CLIENT_ID).trim(); + validate_oauth_client_id(client_id).map_err(|error| { + app_error( + ErrorCode::InvalidRequest, + "YouTube sign-in is not configured in this Reelspace build", + ) + .with_detail(error.message) + })?; + Ok(client_id.to_owned()) +} + +fn validate_oauth_client_id(client_id: &str) -> AppResult<()> { + let prefix = client_id + .strip_suffix(".apps.googleusercontent.com") + .unwrap_or_default(); + if client_id.is_empty() + || client_id.len() > 300 + || client_id.trim() != client_id + || prefix.is_empty() + || !prefix + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err(user_error("invalid Google Desktop OAuth client id")); + } + Ok(()) +} + +fn http_client() -> AppResult { + Client::builder() + .timeout(Duration::from_secs(90)) + .user_agent(concat!("Reelspace/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(network_error) +} +fn random_token(length: usize) -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(length) + .map(char::from) + .collect() +} +fn credential_entry() -> AppResult { + Entry::new(CREDENTIAL_SERVICE, CREDENTIAL_ACCOUNT).map_err(credential_error) +} +fn save_credentials(value: &StoredCredentials) -> AppResult<()> { + credential_entry()? + .set_password(&serde_json::to_string(value)?) + .map_err(credential_error) +} +fn load_credentials() -> AppResult { + let raw = credential_entry()? + .get_password() + .map_err(credential_error)?; + serde_json::from_str(&raw).map_err(|error| { + app_error( + ErrorCode::PermissionDenied, + "Stored YouTube credentials are unreadable; reconnect the account", + ) + .with_detail(error.to_string()) + }) +} +fn credential_error(error: keyring::Error) -> crate::error::AppError { + app_error( + ErrorCode::PermissionDenied, + "YouTube credentials could not be accessed in Windows Credential Manager", + ) + .with_detail(error.to_string()) +} +fn network_error(error: reqwest::Error) -> crate::error::AppError { + app_error( + ErrorCode::SourceUnavailable, + "The YouTube service could not be reached", + ) + .with_detail(error.to_string()) +} +fn require_success(response: Response, message: &str) -> AppResult { + if response.status().is_success() { + Ok(response) + } else { + Err(youtube_response_error_with(response, message)) + } +} +fn youtube_response_error(response: Response) -> crate::error::AppError { + youtube_response_error_with(response, "YouTube rejected the archive request") +} +fn youtube_response_error_with(response: Response, message: &str) -> crate::error::AppError { + let status = response.status(); + let body = response.text().unwrap_or_default(); + let quota = body.contains("quotaExceeded") || body.contains("uploadLimitExceeded"); + let auth = status.as_u16() == 401 || body.contains("invalid_grant"); + let message = youtube_provider_message(&body).unwrap_or(message); + let mut error = app_error( + if auth { + ErrorCode::PermissionDenied + } else { + ErrorCode::SourceUnavailable + }, + if quota { + "YouTube's upload allowance is exhausted; Reelspace will retry after the quota resets" + } else { + message + }, + ) + .with_detail(format!( + "HTTP {}: {}", + status.as_u16(), + redact_provider_error(&body) + )); + error.retryable = status.is_server_error() || quota; + error +} +fn youtube_provider_message(body: &str) -> Option<&'static str> { + let value: Value = serde_json::from_str(body).ok()?; + let error = value.get("error")?.as_str().unwrap_or_default(); + let description = value + .get("error_description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + match error { + "invalid_grant" => Some( + "Google rejected the OAuth code exchange. Restart YouTube sign-in and make sure this build uses the same Desktop OAuth client ID that opened the browser.", + ), + "redirect_uri_mismatch" => Some( + "Google rejected Reelspace's loopback redirect URI. The bundled OAuth client must be a Desktop app client.", + ), + "unauthorized_client" => Some( + "Google rejected this OAuth client. Use a Desktop app client and ensure the YouTube scopes are enabled for the Google Cloud project.", + ), + "access_denied" => Some( + "Google did not grant Reelspace YouTube access. If the app is in testing, add this Google account as a test user and try again.", + ), + "invalid_client" if description.contains("secret") => Some( + "Google rejected this OAuth client because it expects a client secret. Replace the bundled client ID with a Desktop app client that supports installed-app PKCE.", + ), + "invalid_client" => Some( + "Google rejected the bundled OAuth client ID. Replace it with an active Desktop app client ID.", + ), + "invalid_request" if description.contains("code_verifier") => Some( + "Google rejected Reelspace's PKCE verifier during YouTube sign-in. Restart the sign-in flow and try again.", + ), + "invalid_request" => Some( + "Google rejected the YouTube sign-in request. Check that the bundled OAuth client is a Desktop app and the YouTube API scopes are configured.", + ), + _ => None, + } +} +fn redact_provider_error(body: &str) -> String { + body.chars() + .filter(|ch| !ch.is_control() || *ch == ' ') + .take(800) + .collect() +} +fn parse_uploaded_range(value: &str) -> Option { + value + .rsplit_once('-')? + .1 + .parse::() + .ok()? + .checked_add(1) +} +fn retry_delay(attempts: u32) -> i64 { + (30_i64.saturating_mul(2_i64.saturating_pow(attempts.min(10)))).min(6 * 60 * 60) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn resumable_ranges_continue_after_the_last_confirmed_byte() { + assert_eq!(parse_uploaded_range("bytes=0-8388607"), Some(8_388_608)); + } + #[test] + fn retry_backoff_is_bounded() { + assert_eq!(retry_delay(0), 30); + assert_eq!(retry_delay(99), 6 * 60 * 60); + } + #[test] + fn oauth_validation_accepts_only_well_formed_desktop_client_ids() { + assert!(validate_oauth_client_id("123-abc.apps.googleusercontent.com").is_ok()); + for invalid in [ + "", + "web.example", + ".apps.googleusercontent.com", + " 123.apps.googleusercontent.com", + "123.apps.googleusercontent.com ", + "123\n.apps.googleusercontent.com", + "123/abc.apps.googleusercontent.com", + ] { + assert!( + validate_oauth_client_id(invalid).is_err(), + "accepted {invalid:?}" + ); + } + } + + #[test] + fn legacy_stored_client_secrets_remain_readable() { + let credentials: StoredCredentials = serde_json::from_value(json!({ + "client_id": "123.apps.googleusercontent.com", + "client_secret": "legacy-secret", + "refresh_token": "refresh", + "access_token": "access", + "expires_at": 1 + })) + .unwrap(); + assert_eq!(credentials.client_secret.as_deref(), Some("legacy-secret")); + } + + #[test] + fn google_oauth_errors_get_actionable_messages() { + assert_eq!( + youtube_provider_message( + r#"{"error":"redirect_uri_mismatch","error_description":"Bad redirect"}"# + ), + Some( + "Google rejected Reelspace's loopback redirect URI. The bundled OAuth client must be a Desktop app client." + ) + ); + assert_eq!( + youtube_provider_message( + r#"{"error":"invalid_client","error_description":"client_secret is missing"}"# + ), + Some( + "Google rejected this OAuth client because it expects a client secret. Replace the bundled client ID with a Desktop app client that supports installed-app PKCE." + ) + ); + assert_eq!( + youtube_provider_message(r#"{"error":"quotaExceeded"}"#), + None + ); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4580c63..106cbd9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Reelspace", - "version": "0.1.1-alpha.1", + "version": "0.2.0-alpha.1", "identifier": "com.reelspace.app", "build": { "beforeDevCommand": "pnpm dev", @@ -31,6 +31,7 @@ }, "bundle": { "active": true, + "createUpdaterArtifacts": true, "targets": ["nsis"], "icon": ["icons/icon.ico"], "resources": { @@ -45,5 +46,11 @@ } } }, - "plugins": {} + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM1NDFFRjM3QzZFRTcwRDAKUldUUWNPN0dOKzlCeFg3M1ZsTUIxakJuVjA3S2NjS2xOMXVyQ2NHZll2bGx5V1ltS1dlRnFmeEMK", + "endpoints": ["https://github.com/canikou/Reelspace/releases/download/updater-alpha/latest.json"], + "windows": { "installMode": "passive" } + } + } } diff --git a/src/App.tsx b/src/App.tsx index 6bea826..9258248 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,21 +1,26 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { CheckCircle2, FolderPlus, RefreshCw, X } from "lucide-react"; +import { AppIcon } from "./components/icons"; import { CollectionPickerDialog } from "./components/CollectionPickerDialog"; import { ConfirmDialog } from "./components/ConfirmDialog"; +import { GamePickerDialog } from "./components/GamePickerDialog"; import { PlayerModal } from "./components/PlayerModal"; import { SetupPrompt } from "./components/SetupPrompt"; import { Sidebar, type PageKey } from "./components/Sidebar"; import { TextEditorDialog } from "./components/TextEditorDialog"; import { TrimModal } from "./components/TrimModal"; +import { UpdatePrompt } from "./components/UpdatePrompt"; import { api, normalizeAppError } from "./lib/api"; import { pickFolder } from "./lib/dialog"; import { formatBytes } from "./lib/format"; -import type { AppSnapshot, Collection, MediaItem } from "./lib/types"; +import type { AppSnapshot, ArchiveAccount, Collection, Game, MediaItem, RecorderApp, UploadJob } from "./lib/types"; +import { checkForUpdate, dismissUpdate, downloadUpdate, installUpdate, type UpdateViewState } from "./lib/updater"; import { ClipsPage } from "./pages/ClipsPage"; import { CollectionsPage } from "./pages/CollectionsPage"; import { GalleryPage } from "./pages/GalleryPage"; import { HomePage } from "./pages/HomePage"; import { SettingsPage } from "./pages/SettingsPage"; +import { GamesPage } from "./pages/GamesPage"; +import { ArchivePage } from "./pages/ArchivePage"; type EditorState = { kind: "tags" | "notes"; item: MediaItem } | null; @@ -25,19 +30,27 @@ export function App() { const [rawItems, setRawItems] = useState([]); const [clipItems, setClipItems] = useState([]); const [collections, setCollections] = useState([]); + const [games, setGames] = useState([]); + const [uploadJobs, setUploadJobs] = useState([]); + const [recorders, setRecorders] = useState([]); + const [archiveAccount, setArchiveAccount] = useState(null); const [playing, setPlaying] = useState(null); const [trimming, setTrimming] = useState(null); const [editing, setEditing] = useState(null); const [collectionTarget, setCollectionTarget] = useState(null); + const [gameTarget, setGameTarget] = useState(null); const [creatingCollection, setCreatingCollection] = useState(false); const [selectedCollection, setSelectedCollection] = useState(null); const [collectionItems, setCollectionItems] = useState([]); const [highlightedClipId, setHighlightedClipId] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); + const [pendingLocalRemoval, setPendingLocalRemoval] = useState(null); + const [pendingRemoteRemoval, setPendingRemoteRemoval] = useState(null); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [setupDismissed, setSetupDismissed] = useState(false); + const [updateState, setUpdateState] = useState({ phase: "idle" }); const scanInFlight = useRef(false); const lastScanAt = useRef(0); const refreshGeneration = useRef(0); @@ -56,14 +69,40 @@ export function App() { api.getAppState(), api.listMedia("raw"), api.listMedia("clip"), - api.listCollections() + api.listCollections(), + api.listArchiveStatuses(), + api.listGameAssignments(), + api.listGames(), + api.listUploadJobs(), + api.detectRecordingApps(), + api.getArchiveAccount() ] as const); if (generation !== refreshGeneration.current) return; const failures: unknown[] = []; if (results[0].status === "fulfilled") setSnapshot(results[0].value); else failures.push(results[0].reason); - if (results[1].status === "fulfilled") setRawItems(results[1].value); else failures.push(results[1].reason); - if (results[2].status === "fulfilled") setClipItems(results[2].value); else failures.push(results[2].reason); + const statuses = results[4].status === "fulfilled" ? new Map(results[4].value.map((status) => [status.mediaId, status])) : new Map(); + const assignments = results[5].status === "fulfilled" ? new Map(results[5].value.map((assignment) => [assignment.mediaId, assignment])) : new Map(); + const enrich = (items: MediaItem[]) => items.map((item) => { + const status = statuses.get(item.id); + const game = assignments.get(item.id); + return { ...item, + availability: status?.state ?? "localOnly", + keepLocal: status?.keepLocal ?? item.kind === "clip", + remoteCopyCount: status?.remoteCopyCount ?? 0, + primaryRemoteUrl: status?.primaryRemoteUrl ?? null, + gameId: game?.gameId ?? null, + gameName: game?.gameName ?? null + }; + }); + if (results[1].status === "fulfilled") setRawItems(enrich(results[1].value)); else failures.push(results[1].reason); + if (results[2].status === "fulfilled") setClipItems(enrich(results[2].value)); else failures.push(results[2].reason); if (results[3].status === "fulfilled") setCollections(results[3].value); else failures.push(results[3].reason); + if (results[4].status === "rejected") failures.push(results[4].reason); + if (results[5].status === "rejected") failures.push(results[5].reason); + if (results[6].status === "fulfilled") setGames(results[6].value); else failures.push(results[6].reason); + if (results[7].status === "fulfilled") setUploadJobs(results[7].value); else failures.push(results[7].reason); + if (results[8].status === "fulfilled") setRecorders(results[8].value); else failures.push(results[8].reason); + if (results[9].status === "fulfilled") setArchiveAccount(results[9].value); else failures.push(results[9].reason); if (failures.length) throw normalizeAppError(failures[0]); }, []); @@ -71,6 +110,32 @@ export function App() { refresh().catch(showError); }, [refresh, showError]); + useEffect(() => { + const theme = snapshot?.settings.theme ?? "system"; + if (theme === "system") delete document.documentElement.dataset.theme; + else document.documentElement.dataset.theme = theme; + }, [snapshot?.settings.theme]); + + const runUpdateCheck = useCallback(async (manual = false) => { + setUpdateState({ phase: "checking" }); + try { + const next = await checkForUpdate({ manual, includePrerelease: snapshot?.settings.includePrereleaseUpdates ?? true }); + setUpdateState(next); + if (next.phase === "available" && snapshot?.settings.automaticallyDownloadUpdates) { + setUpdateState(await downloadUpdate(setUpdateState)); + } + } catch (reason) { + const message = reason instanceof Error ? reason.message : "The update channel could not be reached"; + setUpdateState(manual ? { phase: "error", version: snapshot?.version, error: message } : { phase: "idle" }); + } + }, [snapshot?.settings.automaticallyDownloadUpdates, snapshot?.settings.includePrereleaseUpdates, snapshot?.version]); + + useEffect(() => { + if (!snapshot?.settings.automaticallyCheckUpdates) return; + const timeout = window.setTimeout(() => runUpdateCheck(false), 4_000); + return () => window.clearTimeout(timeout); + }, [runUpdateCheck, snapshot?.settings.automaticallyCheckUpdates]); + const actions = useMemo( () => ({ view: setPlaying, @@ -78,9 +143,15 @@ export function App() { reveal: (item: MediaItem) => api.revealInExplorer(item.id).catch(showError), notes: (item: MediaItem) => setEditing({ kind: "notes", item }), tags: (item: MediaItem) => setEditing({ kind: "tags", item }), - addToCollection: setCollectionTarget + addToCollection: setCollectionTarget, + classify: setGameTarget, + archive: (item: MediaItem) => api.queueArchive([item.id]).then(async () => { await refresh(); setNotice("Queued for private archive"); }).catch(showError), + setKeepLocal: (item: MediaItem, value: boolean) => api.setKeepLocal(item.id, value).then(async () => { await refresh(); setNotice(value ? "Keep Local enabled" : "Keep Local disabled"); }).catch(showError), + openRemote: (item: MediaItem) => api.openRemoteCopy(item.id).catch(showError), + removeLocal: setPendingLocalRemoval, + removeRemote: setPendingRemoteRemoval }), - [showError] + [refresh, showError] ); const rootsKey = snapshot?.settings.sourceFolders.join("\0") ?? ""; @@ -128,6 +199,12 @@ export function App() { return () => window.clearTimeout(timeout); }, [notice]); + useEffect(() => { + if (page !== "archive") return; + const interval = window.setInterval(() => refresh().catch(showError), 5_000); + return () => window.clearInterval(interval); + }, [page, refresh, showError]); + useEffect(() => { if (!highlightedClipId) return; const timeout = window.setTimeout(() => setHighlightedClipId(null), 5000); @@ -175,21 +252,37 @@ export function App() {

{titleFor(page)}

- {busy ? Scanning : null} + {busy ? Scanning : null} + {recorders[0] ? : null} +
- {error ?
{error}
: null} - {notice ?
{notice}
: null} + {error ?
{error}
: null} + {notice ?
{notice}
: null} + { + try { setUpdateState(await downloadUpdate(setUpdateState)); } + catch (reason) { setUpdateState((current) => ({ ...current, phase: "error", error: reason instanceof Error ? reason.message : "Update download failed" })); } + }} + onInstall={() => installUpdate().catch(showError)} + onLater={() => { dismissUpdate(updateState.version).catch(() => undefined); setUpdateState({ phase: "idle" }); }} + onSkip={() => { dismissUpdate(updateState.version, true).catch(() => undefined); setUpdateState({ phase: "idle" }); }} + /> {!snapshot && !error ?
: null} {snapshot && !snapshot.settings.sourceFolders.length && setupDismissed ? ( - + ) : null} {page === "home" && snapshot ? : null} {page === "gallery" ? : null} {page === "clips" ? : null} + {page === "games" ? { setGames(await api.createGame(name)); setNotice("Game created"); }} onRename={async (game, name) => { setGames(await api.renameGame(game.id, name)); await refresh(); setNotice("Game renamed and alias preserved"); }} onMerge={async (source, target) => { setGames(await api.mergeGames(source.id, target.id)); await refresh(); setNotice("Game classifications merged"); }} /> : null} {page === "collections" ? ( setCreatingCollection(true)} onSelect={openCollection} onBack={() => { collectionRequestGeneration.current += 1; setSelectedCollection(null); setCollectionItems([]); }} + onRemove={async (collection, item) => { await api.removeFromCollection(collection.id, item.id); await openCollection(collection); await refresh(); setNotice("Removed from collection"); }} + onStorageDefault={async (collection, policy) => { + const nextCollections = await api.setCollectionStorageDefault(collection.id, policy); + setCollections(nextCollections); + setSelectedCollection(nextCollections.find((item) => item.id === collection.id) ?? null); + await refresh(); + setNotice(policy === "keepLocal" ? "Collection keeps new items local" : policy === "archive" ? "Collection archives new items" : "Collection uses app defaults"); + }} /> ) : null} + {page === "archive" ? { setUploadJobs(await api.controlUploadQueue(action, jobId)); await refresh(); }} /> : null} {page === "settings" && snapshot ? ( { setArchiveAccount(await api.connectYouTube()); await refresh(); setNotice("YouTube archive connected"); }} + onDisconnectYouTube={async () => { await api.disconnectYouTube(); setArchiveAccount(null); await refresh(); setNotice("YouTube archive disconnected"); }} onSave={async (settings) => { const next = await api.saveSettings(settings); setSnapshot(next); @@ -263,6 +369,28 @@ export function App() { }} /> ) : null} + {gameTarget ? ( + setGameTarget(null)} + onCreate={async (name) => { + const nextGames = await api.createGame(name); + setGames(nextGames); + const created = nextGames.find( + (game) => game.name.toLocaleLowerCase() === name.trim().toLocaleLowerCase(), + ); + if (!created) throw new Error("The new game could not be found after creation."); + return created; + }} + onPick={async (game) => { + await api.assignGame(gameTarget.id, game.id); + setGameTarget(null); + await refresh(); + setNotice(`Classified as ${game.name}`); + }} + /> + ) : null} {creatingCollection ? ( ) : null} + {pendingLocalRemoval ? ( + setPendingLocalRemoval(null)} + onConfirm={async () => { await api.removeLocalCopy(pendingLocalRemoval.id); setPendingLocalRemoval(null); await refresh(); setNotice("Local copy moved to the Recycle Bin"); }} + /> + ) : null} + {pendingRemoteRemoval ? ( + setPendingRemoteRemoval(null)} + onConfirm={async () => { await api.deleteYouTubeRemote(pendingRemoteRemoval.id); setPendingRemoteRemoval(null); await refresh(); setNotice("Remote copy deleted"); }} + /> + ) : null} {snapshot && !snapshot.settings.sourceFolders.length && !setupDismissed ? ( setSetupDismissed(true)} /> ) : null} @@ -298,5 +444,5 @@ export function App() { } function titleFor(page: PageKey): string { - return { home: "Home", gallery: "Gallery", clips: "Clips", collections: "Collections", settings: "Settings" }[page]; + return { home: "Home", gallery: "Recordings", clips: "Clips", games: "Games", collections: "Collections", archive: "Archive", settings: "Settings" }[page]; } diff --git a/src/components/CollectionPickerDialog.tsx b/src/components/CollectionPickerDialog.tsx index d6f45a8..3587102 100644 --- a/src/components/CollectionPickerDialog.tsx +++ b/src/components/CollectionPickerDialog.tsx @@ -1,7 +1,7 @@ -import { Library, Plus } from "lucide-react"; import { useRef, useState } from "react"; import type { Collection, MediaItem } from "../lib/types"; import { Dialog } from "./Dialog"; +import { AppIcon } from "./icons"; export function CollectionPickerDialog({ item, @@ -39,13 +39,13 @@ export function CollectionPickerDialog({
{collections.map((collection) => ( ))} {error ?
{error}
: null}
diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 81dc265..9182ebe 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,6 +1,6 @@ -import { AlertTriangle, Trash2 } from "lucide-react"; import { useRef, useState } from "react"; import { Dialog } from "./Dialog"; +import { AppIcon } from "./icons"; export function ConfirmDialog({ title, @@ -36,13 +36,13 @@ export function ConfirmDialog({ return ( undefined : onClose}>
- + {detail ? {detail} : null} {error ?
{error}
: null}
- +
); diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index fa2b940..8952df3 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -1,5 +1,5 @@ -import { X } from "lucide-react"; import { type ReactNode, useEffect, useId, useRef } from "react"; +import { AppIcon } from "./icons"; export function Dialog({ title, @@ -47,7 +47,7 @@ export function Dialog({ {description ?

{description}

: null} {children} diff --git a/src/components/GamePickerDialog.tsx b/src/components/GamePickerDialog.tsx new file mode 100644 index 0000000..3dcf499 --- /dev/null +++ b/src/components/GamePickerDialog.tsx @@ -0,0 +1,70 @@ +import { useMemo, useState } from "react"; +import type { Game, MediaItem } from "../lib/types"; +import { Dialog } from "./Dialog"; +import { AppIcon } from "./icons"; + +export function GamePickerDialog({ item, games, onClose, onPick, onCreate }: { + item: MediaItem; + games: Game[]; + onClose: () => void; + onPick: (game: Game) => Promise; + onCreate: (name: string) => Promise; +}) { + const [query, setQuery] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const trimmedQuery = query.trim(); + const visible = useMemo( + () => games + .filter((game) => game.name.toLocaleLowerCase().includes(trimmedQuery.toLocaleLowerCase())) + .slice(0, 30), + [games, trimmedQuery], + ); + const exactMatch = games.some( + (game) => game.name.toLocaleLowerCase() === trimmedQuery.toLocaleLowerCase(), + ); + + async function choose(action: () => Promise) { + setBusy(true); + setError(null); + try { + await onPick(await action()); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "The game could not be assigned."); + } finally { + setBusy(false); + } + } + + return ( + undefined : onClose}> +
+ + setQuery(event.target.value)} + placeholder="Search or create a game" + /> +
+
+ {visible.map((game) => ( + + ))} + {trimmedQuery && !exactMatch ? ( + + ) : null} + {!visible.length && !trimmedQuery ?

No games yet

: null} +
+ {error ?

{error}

: null} +
+ ); +} diff --git a/src/components/MediaCard.tsx b/src/components/MediaCard.tsx index 18c94c3..71f4f58 100644 --- a/src/components/MediaCard.tsx +++ b/src/components/MediaCard.tsx @@ -1,8 +1,8 @@ -import { Eye, FolderSearch, MoreVertical, NotebookPen, Scissors, Tags, Library } from "lucide-react"; import { useEffect, useRef } from "react"; import { mediaSrc } from "../lib/api"; import { formatBytes, formatDuration, formatShortDate } from "../lib/format"; import type { MediaItem } from "../lib/types"; +import { AppIcon, AvailabilityIcon, type AvailabilityState } from "./icons"; export interface MediaActions { view: (item: MediaItem) => void; @@ -11,6 +11,13 @@ export interface MediaActions { notes: (item: MediaItem) => void; tags: (item: MediaItem) => void; addToCollection: (item: MediaItem) => void; + classify?: (item: MediaItem) => void; + archive?: (item: MediaItem) => void; + setKeepLocal?: (item: MediaItem, value: boolean) => void; + openRemote?: (item: MediaItem) => void; + removeLocal?: (item: MediaItem) => void; + removeRemote?: (item: MediaItem) => void; + removeFromCollection?: (item: MediaItem) => void; } export function MediaCard({ @@ -26,6 +33,7 @@ export function MediaCard({ }) { const cardRef = useRef(null); const thumbnailSrc = mediaSrc(item.thumbnailPath); + const availability = (item.availability ?? "localOnly") as AvailabilityState; const detail = [ item.width && item.height ? `${item.width}×${item.height}` : null, item.codec?.toUpperCase(), @@ -43,13 +51,16 @@ export function MediaCard({ {thumbnailSrc ? ( ) : ( -
No preview
+
No preview
)} {item.kind} + + {item.keepLocal ? : null} {formatDuration(item.durationSeconds)}
+ {item.gameName ? {item.gameName} : null} {item.tags.length ? item.tags.slice(0, 2).map((tag) => {tag}) : Untagged} {item.tags.length > 2 ? +{item.tags.length - 2} : null}
@@ -57,15 +68,22 @@ export function MediaCard({

{detail}

- - {item.kind === "raw" ? : null} - + + {item.kind === "raw" ? : null} +
- +
- - - + + + {actions.classify ? : null} + + {actions.removeFromCollection ? : null} + {actions.archive && (availability === "localOnly" || availability === "uploadFailed") ? : null} + {actions.setKeepLocal ? : null} + {actions.openRemote && item.primaryRemoteUrl ? : null} + {actions.removeLocal && availability === "archivedVerified" && !item.keepLocal ? : null} + {actions.removeRemote && item.primaryRemoteUrl ? : null}
diff --git a/src/components/MediaLibrary.tsx b/src/components/MediaLibrary.tsx index c04dfab..03c8f56 100644 --- a/src/components/MediaLibrary.tsx +++ b/src/components/MediaLibrary.tsx @@ -1,8 +1,8 @@ -import { Grid2X2, List, Search, SlidersHorizontal, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import type { MediaItem } from "../lib/types"; import { MediaGrid } from "./MediaGrid"; import type { MediaActions } from "./MediaCard"; +import { AppIcon } from "./icons"; type SortKey = "latest" | "oldest" | "largest" | "longest"; type ViewMode = "grid" | "list"; @@ -49,7 +49,7 @@ export function MediaLibrary({
{visible.length}/ {items.length}
- - + +
diff --git a/src/components/PlayerModal.tsx b/src/components/PlayerModal.tsx index 154f53e..cdc9631 100644 --- a/src/components/PlayerModal.tsx +++ b/src/components/PlayerModal.tsx @@ -1,8 +1,8 @@ -import { FolderSearch, Library, Maximize2, NotebookPen, Scissors, Tags, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { mediaSrc } from "../lib/api"; import type { MediaItem } from "../lib/types"; import type { MediaActions } from "./MediaCard"; +import { AppIcon } from "./icons"; export function PlayerModal({ item, actions, onClose }: { item: MediaItem; actions: MediaActions; onClose: () => void }) { const videoRef = useRef(null); @@ -33,13 +33,13 @@ export function PlayerModal({ item, actions, onClose }: { item: MediaItem; actio

{item.tags.join(", ") || "Untagged"}

- {item.kind === "raw" ? : null} - - - - - - + {item.kind === "raw" ? : null} + + + + + +
diff --git a/src/components/SetupPrompt.tsx b/src/components/SetupPrompt.tsx index 64318e8..3a1aead 100644 --- a/src/components/SetupPrompt.tsx +++ b/src/components/SetupPrompt.tsx @@ -1,4 +1,4 @@ -import { FolderPlus, X } from "lucide-react"; +import { AppIcon, FrameStackMark } from "./icons"; export function SetupPrompt({ busy, @@ -17,11 +17,12 @@ export function SetupPrompt({

Choose a capture folder

Reelspace needs one source folder before it can scan for raw videos.

- +
+
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 5636378..845516c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,24 +1,25 @@ -import { Clapperboard, FolderOpen, Home, Library, Settings, Tags, type LucideIcon } from "lucide-react"; +import { AppIcon, FrameStackMark, type IconName } from "./icons"; -export type PageKey = "home" | "gallery" | "clips" | "collections" | "settings"; +export type PageKey = "home" | "gallery" | "clips" | "games" | "collections" | "archive" | "settings"; -const items: Array<{ key: PageKey; label: string; icon: LucideIcon }> = [ - { key: "home", label: "Home", icon: Home }, - { key: "gallery", label: "Gallery", icon: FolderOpen }, - { key: "clips", label: "Clips", icon: Clapperboard }, - { key: "collections", label: "Collections", icon: Library }, - { key: "settings", label: "Settings", icon: Settings } +const items: Array<{ key: PageKey; label: string; icon: IconName }> = [ + { key: "home", label: "Home", icon: "home" }, + { key: "gallery", label: "Recordings", icon: "recording" }, + { key: "clips", label: "Clips", icon: "clip" }, + { key: "games", label: "Games", icon: "game" }, + { key: "collections", label: "Collections", icon: "collection" }, + { key: "archive", label: "Archive", icon: "archive" }, + { key: "settings", label: "Settings", icon: "settings" } ]; export function Sidebar({ page, onPageChange }: { page: PageKey; onPageChange: (page: PageKey) => void }) { return (