Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1525,6 +1525,23 @@ The macOS app is signed with a **Developer ID Application** certificate and nota

`package-macos.sh` also works locally: with a Developer ID cert in your keychain it signs, and `--notarize` uses a stored `notarytool` keychain profile (default name `VapourBox`). In CI there is no keychain profile, so it falls back to `NOTARY_APPLE_ID` / `NOTARY_PASSWORD` / `NOTARY_TEAM_ID` env vars. Use `--no-sign` for ad-hoc local test builds.

> **Every helper executable needs the entitlements too, not just the app.**
> Entitlements are per-Mach-O and the app bundle's set does **not** reach a
> child process, so `vapourbox-worker` must be signed with
> `--entitlements packaging/macos/distribution.entitlements` in its own right.
> Signed hardened without them it gets **library validation**, and the worker
> `dlopen`s the downloaded, **ad-hoc-signed** `libdvdread.dylib` from
> `deps/<arch>/lib/` (`worker/src/dvd_reader.rs`) — which then fails with
> *"mapping process and mapped file (non-platform) have different Team IDs"* and
> DVD import is dead in the shipped app. It shipped that way through 0.9.12.
>
> Nothing else catches this: the debug build is **ad-hoc signed**, so it has no
> Team ID to mismatch and DVD extraction works fine locally; `codesign --verify
> --deep --strict` passes, and notarization passes. `package-macos.sh` now
> asserts after signing that both `vapourbox` and `vapourbox-worker` carry
> `disable-library-validation`, and fails the package if either doesn't. Add any
> future bundled helper to that loop.

> **A notarization `403` is not necessarily your account.** The notary service
> returns *"HTTP status code: 403. Invalid or inaccessible developer team ID for
> the provided Apple ID"* during Apple-side outages, which reads like a
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,12 @@ Each filter leads with a plain-language summary and a **More** expander describi

**From a disc:** insert it and click the disc icon in the toolbar, or **Open DVD** on the drop zone. VapourBox reads the disc structure and shows a title picker with duration, resolution, chapters and audio tracks. Select a title and **Add to Queue** — it's extracted to a temporary file, then analyzed and queued like any other video.

**From a folder:** drag in a folder containing `VIDEO_TS` (or the `VIDEO_TS` folder itself) and the title picker appears automatically.
**From a ripped folder:** drag in a folder containing `VIDEO_TS`, the `VIDEO_TS` folder itself, or a flat rip — the `VIDEO_TS` contents (`VIDEO_TS.IFO`, `VTS_01_1.VOB`, …) sitting directly in a folder named after the disc. The title picker appears automatically in all three cases.

**A folder of loose files:** drag it in, or use **Open Folder** — the folder is scanned recursively and everything found is queued.

VapourBox decides between the two by looking for a DVD IFO: a folder is only treated as a disc if one is present, and a flat rip additionally needs at least one `.VOB`, so a stray `VIDEO_TS.IFO` beside ordinary videos won't divert the whole folder into the title picker.

**Encrypted discs.** Most commercial DVDs use CSS encryption. VapourBox reads discs via libdvdread, which can load libdvdcss at runtime to decrypt them, but **libdvdcss is not bundled** — install it separately if you need it. Unencrypted discs (home recordings, some independent releases) work without it.

- **macOS:** `brew install libdvdcss`
Expand Down
25 changes: 24 additions & 1 deletion Scripts/package-macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,19 @@ else
done

# 4. Sign helper executables
#
# The worker needs the SAME entitlements as the app, not just the hardened
# runtime. Entitlements are per-executable, and the worker is its own
# process — the app bundle's set does not reach it. Signed hardened with no
# entitlements it gets library validation, and it dlopens the downloaded,
# ad-hoc-signed libdvdread from deps/ (dvd_reader.rs), which then fails with
# "mapping process and mapped file (non-platform) have different Team IDs"
# and no DVD can be extracted. Debug builds never show this: they are
# ad-hoc signed, so there is no Team ID to mismatch.
echo " Signing helper executables..."
if [ -f "$CONTENTS/MacOS/vapourbox-worker" ]; then
codesign --force --sign "$IDENTITY" --options runtime --timestamp "$CONTENTS/MacOS/vapourbox-worker"
codesign --force --sign "$IDENTITY" --options runtime --timestamp \
--entitlements "$ENTITLEMENTS" "$CONTENTS/MacOS/vapourbox-worker"
fi

# 5. Sign the main app bundle with entitlements
Expand All @@ -293,6 +303,19 @@ else
# 6. Verify
echo " Verifying signature..."
codesign --verify --deep --strict "$APP_BUNDLE"

# Every Mach-O we launch as its own process must carry
# disable-library-validation, or it cannot dlopen the ad-hoc-signed
# libraries in the downloaded deps bundle (see step 4).
for exe in "$CONTENTS/MacOS/vapourbox" "$CONTENTS/MacOS/vapourbox-worker"; do
[ -f "$exe" ] || continue
if ! codesign -d --entitlements - --xml "$exe" 2>/dev/null \
| grep -q "com.apple.security.cs.disable-library-validation"; then
echo "ERROR: $(basename "$exe") is signed without disable-library-validation"
echo " It will fail to dlopen the ad-hoc-signed deps libraries."
exit 1
fi
done
echo " Signature verified OK"
fi

Expand Down
96 changes: 78 additions & 18 deletions app/lib/services/disc_detector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -109,29 +109,89 @@ class DiscDetector {
return discs;
}

/// Checks if a given path contains a VIDEO_TS directory (or IS a VIDEO_TS directory).
/// Returns the parent path (mount point) if found, or null.
static Future<String?> findVideoTsParent(String path) async {
// Check if path itself is named VIDEO_TS
/// Decides whether [path] is a ripped DVD, and if so returns the path to
/// hand the worker's `--dvd-info` / `--dvd-extract`. Returns null for an
/// ordinary folder, which the caller should scan for video files instead.
///
/// Three shapes count as a DVD, in this order:
///
/// 1. [path] **is** a `VIDEO_TS` directory holding an IFO — the disc root is
/// its parent.
/// 2. [path] **contains** a `VIDEO_TS` directory holding an IFO — the usual
/// mounted disc or a rip that kept the wrapping directory.
/// 3. [path] holds the VIDEO_TS *contents* directly (a "flat" rip, named
/// after the disc): an IFO **and** at least one `.VOB`.
///
/// Case 3 demands a VOB where the first two do not, deliberately. A folder
/// with a `VIDEO_TS` subdirectory is unambiguous, but a flat folder is
/// ordinary until proven otherwise, and a stray IFO next to unrelated videos
/// must not hijack the whole folder into the DVD flow. An IFO with no VOB is
/// not extractable anyway.
static Future<String?> findDvdRoot(String path) async {
final dir = Directory(path);
if (!await dir.exists()) return null;

// 1. The path itself is a VIDEO_TS directory.
final dirName = path.replaceAll('\\', '/').split('/').last.toUpperCase();
if (dirName == 'VIDEO_TS') {
final parent = Directory(path).parent.path;
if (await File('$path/VIDEO_TS.IFO').exists() ||
await File('$path/video_ts.ifo').exists()) {
return parent;
}
if (dirName == 'VIDEO_TS' && await _hasDvdIfo(dir)) {
return dir.parent.path;
}

// Check if path contains VIDEO_TS
final videoTsDir = Directory('$path/VIDEO_TS');
if (await videoTsDir.exists()) {
// Verify it has IFO files
if (await File('$path/VIDEO_TS/VIDEO_TS.IFO').exists() ||
await File('$path/VIDEO_TS/video_ts.ifo').exists()) {
return path;
}
// 2. A VIDEO_TS subdirectory (matched case-insensitively, since only
// Windows and macOS resolve the case for us).
final videoTs = await _findChildDirectory(dir, 'video_ts');
if (videoTs != null && await _hasDvdIfo(videoTs)) {
return path;
}

// 3. A flat rip: VIDEO_TS contents sitting directly in the folder.
if (await _hasDvdIfo(dir) && await _hasVob(dir)) {
return path;
}

return null;
}

/// Case-insensitive lookup of a child directory by [name] (already lowercase).
static Future<Directory?> _findChildDirectory(
Directory parent, String name) async {
try {
await for (final entity in parent.list(followLinks: false)) {
if (entity is Directory &&
entity.path.replaceAll('\\', '/').split('/').last.toLowerCase() ==
name) {
return entity;
}
}
} catch (_) {
// Unreadable directory — treat as not a DVD.
}
return null;
}

/// Whether [dir] directly contains `VIDEO_TS.IFO` or a `VTS_nn_0.IFO`.
static Future<bool> _hasDvdIfo(Directory dir) =>
_anyFile(dir, (name) => name == 'video_ts.ifo' || _vtsIfo.hasMatch(name));

/// Whether [dir] directly contains any `.VOB`.
static Future<bool> _hasVob(Directory dir) =>
_anyFile(dir, (name) => name.endsWith('.vob'));

static final RegExp _vtsIfo = RegExp(r'^vts_\d{2}_0\.ifo$');

/// Whether any file directly in [dir] has a lowercased name matching [test].
static Future<bool> _anyFile(
Directory dir, bool Function(String name) test) async {
try {
await for (final entity in dir.list(followLinks: false)) {
if (entity is! File) continue;
final name =
entity.path.replaceAll('\\', '/').split('/').last.toLowerCase();
if (test(name)) return true;
}
} catch (_) {
// Unreadable directory — treat as not a DVD.
}
return false;
}
}
7 changes: 4 additions & 3 deletions app/lib/viewmodels/main_viewmodel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1523,10 +1523,11 @@ class MainViewModel extends ChangeNotifier {
}
}

/// Add a folder: if it's a VIDEO_TS folder, treat as DVD; otherwise scan for videos.
/// Add a folder: if it's a ripped DVD, treat as DVD; otherwise scan for videos.
Future<void> addFolder(String folderPath) async {
// Check if this is a DVD folder (contains VIDEO_TS)
final dvdMountPoint = await DiscDetector.findVideoTsParent(folderPath);
// Check if this is a ripped DVD (a VIDEO_TS directory, a folder containing
// one, or a flat rip holding the VIDEO_TS contents directly).
final dvdMountPoint = await DiscDetector.findDvdRoot(folderPath);
if (dvdMountPoint != null) {
// Route to DVD enumeration flow — caller should show title picker
// We throw a special exception that the UI can catch to show the DVD picker
Expand Down
61 changes: 54 additions & 7 deletions app/lib/views/about_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,24 @@ class _AboutDialogState extends State<AboutDialog> {
copyright: 'HolyWu',
url: 'https://github.com/HomeOfVapourSynthEvolution/havsfunc',
),
_ComponentTile(
name: 'VIVTC',
license: 'LGPL 2.1',
copyright: 'Fredrik Mellbin',
url: 'https://github.com/vapoursynth/vivtc',
),
_ComponentTile(
name: 'mvtools',
license: 'GPL 2.0',
copyright: 'Manao, Fizick, Pinterf, dubhater',
url: 'https://github.com/dubhater/vapoursynth-mvtools',
),
_ComponentTile(
name: 'nnedi3',
license: 'GPL 2.0',
copyright: 'Kevin Stone (tritical); port by dubhater',
url: 'https://github.com/dubhater/vapoursynth-nnedi3',
),
_ComponentTile(
name: 'znedi3',
license: 'GPL 2.0',
Expand All @@ -184,18 +196,24 @@ class _AboutDialogState extends State<AboutDialog> {
copyright: 'tritical, HolyWu',
url: 'https://github.com/HomeOfVapourSynthEvolution/VapourSynth-EEDI3',
),
_ComponentTile(
name: 'akarin',
license: 'LGPL 3.0',
copyright: 'The akarin plugin authors',
url: 'https://github.com/Jaded-Encoding-Thaumaturgy/akarin-vapoursynth-plugin',
),
_ComponentTile(
name: 'zsmooth',
license: 'MIT',
copyright: 'Adrian Woracz',
url: 'https://github.com/adworacz/zsmooth',
),
_ComponentTile(
name: 'FFmpeg',
license: 'LGPL 2.1+',
copyright: 'FFmpeg contributors',
url: 'https://github.com/FFmpeg/FFmpeg',
),
_ComponentTile(
name: 'ffms2',
license: 'MIT',
copyright: 'FFMS contributors',
url: 'https://github.com/FFMS/ffms2',
),
_ComponentTile(
name: 'DFTTest',
license: 'GPL 3.0',
Expand All @@ -218,7 +236,19 @@ class _AboutDialogState extends State<AboutDialog> {
name: 'fmtconv',
license: 'WTFPL',
copyright: 'Firesledge (Laurent de Soras)',
url: 'https://github.com/EleonoreMizo/fmtconv',
url: 'https://gitlab.com/EleonoreMizo/fmtconv',
),
_ComponentTile(
name: 'whisper.cpp',
license: 'MIT',
copyright: 'Georgi Gerganov',
url: 'https://github.com/ggerganov/whisper.cpp',
),
_ComponentTile(
name: 'libdvdread',
license: 'GPL 2.0+',
copyright: 'VideoLAN and contributors',
url: 'https://code.videolan.org/videolan/libdvdread',
),
_ComponentTile(
name: 'Flutter',
Expand All @@ -237,6 +267,23 @@ class _AboutDialogState extends State<AboutDialog> {
),
),

const SizedBox(height: 12),

// Credits that are not bundled components: QTGMC is an algorithm
// (shipped as part of havsfunc above), Hybrid is inspiration only.
// Both are acknowledged in README.md, so neither gets a licence
// badge here.
Text(
'QTGMC deinterlacing algorithm by Vit. '
'Inspired by Hybrid by Selur.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),

const SizedBox(height: 16),

// Buttons
Expand Down
Loading
Loading