Skip to content
Open
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
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,14 @@ or `UiSection` directly (both silently drop it if written one level up).
`visibleWhen` outside `ui`, and every condition must name a parameter/method the
filter actually has.

**`sinceAppVersion`** (filter-level or per-parameter, e.g. `"1.2.0"`): tags
when a filter or parameter shipped, purely so `WhatsNewService` can draw a
"NEW" badge next to it — persisting across every launch of the current app
version, and only advancing on the next real update, not clearing after one
run. Stamp it when you ship the feature, not retroactively on anything already
out — see "NEW badges" in
[docs/FILTER_SCHEMA.md](docs/FILTER_SCHEMA.md).

Several ongoing rules for schemas and the panel that renders them, each guarded by
`filter_schema_curation_test.dart` unless noted — see
docs/ENGINEERING_NOTES.md for the audits that found violations of each:
Expand Down
59 changes: 35 additions & 24 deletions Scripts/run-debug-macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Build and run VapourBox debug app on macOS.
# Usage: ./Scripts/run-debug-macos.sh [--skip-worker] [--skip-app] [--run-only]

set -e
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
Expand Down Expand Up @@ -46,42 +46,53 @@ pkill -f "vapourbox.app" 2>/dev/null || true
# Build Rust worker (debug)
if ! $SKIP_WORKER; then
echo "==> Building worker (debug)..."
cd "$WORKER_DIR"
cargo build
(cd "$WORKER_DIR" && cargo build)
echo " Worker built."
fi

# Build Flutter app (debug) via xcodebuild
# Build Flutter app (debug).
#
# This calls `flutter build macos --debug` directly, not a raw `xcodebuild`
# invocation against Runner.xcworkspace — recent Flutter versions resolve
# several plugins (file_picker, package_info_plus, screen_retriever_macos,
# shared_preferences_foundation, url_launcher_macos, as of Flutter 3.47) via
# Swift Package Manager rather than CocoaPods, and only `flutter build`
# drives that resolution. A raw `xcodebuild -scheme Runner` call fails on
# those with "Unable to resolve module dependency", even with Pods-Runner
# built first (the fix for the *different*, older module-resolution issue
# this script used to work around).
if ! $SKIP_APP; then
echo "==> Building Flutter app (debug)..."
cd "$APP_DIR"
flutter pub get --no-example > /dev/null 2>&1

cd "$APP_DIR/macos"
xcodebuild -workspace Runner.xcworkspace \
-scheme Runner \
-configuration Debug \
build \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
2>&1 | grep -E '(error:|warning:|BUILD|Compiling)' || true

flutter pub get --no-example > /dev/null

# Remove what a previous run of *this script* injected below (worker
# binary, templates) before rebuilding. Left in place, they confuse
# Xcode's code-signing pass — "code object is not signed at all" on a
# leftover .vpy file it never created — which fails the whole build.
# Removing only these two paths (not the whole bundle) keeps Xcode's own
# incremental build cache intact.
rm -f "$DEBUG_APP/Contents/MacOS/vapourbox-worker"
rm -rf "$DEBUG_APP/Contents/MacOS/templates"

# No `|| true` and no piping through `grep` here: either of those would
# swallow a real build failure (this is exactly how a previous version of
# this script silently fell back to launching a stale, months-old app
# bundle after `xcodebuild` failed). `set -e` above means a nonzero exit
# here stops the script immediately, with Flutter's own error output
# printed in full.
flutter build macos --debug
echo " Flutter app built."
fi

# Copy app bundle from DerivedData to Flutter build location
echo "==> Assembling debug bundle..."
DERIVED_APP=$(find ~/Library/Developer/Xcode/DerivedData -path "*/Runner-*/Build/Products/Debug/vapourbox.app" -maxdepth 5 2>/dev/null | head -1)
if [ -z "$DERIVED_APP" ]; then
echo "ERROR: Could not find built app in DerivedData. Build the app first."
if [ ! -d "$DEBUG_APP" ]; then
echo "ERROR: $DEBUG_APP does not exist." >&2
echo "Run without --skip-app / --run-only first to build it." >&2
exit 1
fi

mkdir -p "$APP_DIR/build/macos/Build/Products/Debug"
rm -rf "$DEBUG_APP"
cp -R "$DERIVED_APP" "$DEBUG_APP"

# Copy worker binary
echo "==> Assembling debug bundle..."
cp "$WORKER_DIR/target/debug/vapourbox-worker" "$DEBUG_APP/Contents/MacOS/"

# Copy templates (includes pipe_source.py used by VapourSynth scripts)
Expand Down
6 changes: 6 additions & 0 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'services/preset_service.dart';
import 'services/temp_directory_service.dart';
import 'services/tool_locator.dart';
import 'services/update_checker.dart';
import 'services/whats_new_service.dart';
import 'viewmodels/main_viewmodel.dart';
import 'views/dependency_download_dialog.dart';
import 'views/main_window.dart';
Expand All @@ -31,6 +32,11 @@ void main() async {
// settings don't flash from simple to advanced on startup.
await AdvancedModeService.instance.initialize();

// Records the previously-installed version (before overwriting it with the
// current one) so "NEW" badges on filters/parameters can compare against
// it. Must happen before the first pass list or settings panel builds.
await WhatsNewService.instance.initialize();

// Load the saved default for existing output files (issue #85) before the
// first job can be started.
await OverwriteBehaviorService.instance.initialize();
Expand Down
18 changes: 17 additions & 1 deletion app/lib/models/filter_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ class ParameterDefinition {
/// When disabled (null value), the parameter is not passed to VapourSynth.
final bool? optional;

/// The app version (e.g. `"1.2.0"`, matching `pubspec.yaml`) this parameter
/// was added or last meaningfully changed in. Purely cosmetic — it only
/// feeds [WhatsNewService.isNew] to decide whether a "NEW" badge shows next
/// to this control; nothing else reads it. Leave unset for anything that
/// isn't worth flagging (most edits aren't).
final String? sinceAppVersion;

const ParameterDefinition({
required this.type,
required this.defaultValue,
Expand All @@ -136,6 +143,7 @@ class ParameterDefinition {
this.vapoursynth,
this.ui,
this.optional,
this.sinceAppVersion,
});

/// Get the VapourSynth parameter name (falls back to schema name if not specified).
Expand Down Expand Up @@ -396,9 +404,16 @@ class FilterSchema {
/// Unique identifier for this filter.
final String id;

/// Schema version.
/// Schema version — this JSON file's own revision, unrelated to the app.
final String version;

/// The app version (e.g. `"1.2.0"`, matching `pubspec.yaml`) this filter was
/// introduced in. Not the same thing as [version] above: that tracks this
/// schema file, this tracks the shipping app. Feeds a "NEW" badge in the
/// pass list via [WhatsNewService.isNew] — leave unset for anything already
/// shipped before this mechanism existed.
final String? sinceAppVersion;

/// Display name.
final String name;

Expand Down Expand Up @@ -459,6 +474,7 @@ class FilterSchema {
this.schema,
required this.id,
required this.version,
this.sinceAppVersion,
required this.name,
this.description,
this.longDescription,
Expand Down
177 changes: 135 additions & 42 deletions app/lib/services/update_checker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,48 @@ class UpdateInfo {
bool get isUpdateAvailable => _compareVersions(latestVersion, currentVersion) > 0;
}

/// Service for checking GitHub releases for updates.
/// One entry in the app's release history, for the Settings "Changes" tab.
///
/// Deliberately lightweight — no `body` here. The releases list endpoint does
/// return each release's full body, but fetching every past release's notes
/// just to populate a sidebar of version numbers is wasted bandwidth against
/// GitHub's rate limit; [UpdateChecker.fetchReleaseNotes] pulls a given
/// version's notes only once the user actually selects it.
class AppRelease {
/// The raw GitHub tag, e.g. `"v1.2.0"`.
final String tagName;

/// [tagName] with the leading `v` stripped, e.g. `"1.2.0"` — comparable
/// against [WhatsNewService.isNew] and `pubspec.yaml`'s version.
final String version;

/// The release's title, if it has one distinct from the tag.
final String? name;

final String htmlUrl;
final DateTime? publishedAt;

const AppRelease({
required this.tagName,
required this.version,
this.name,
required this.htmlUrl,
this.publishedAt,
});
}

/// Service for checking GitHub releases for updates, and for browsing the
/// app's full release history (Settings → Changes).
class UpdateChecker {
static const _prefsKeyCheckForUpdates = 'check_for_updates';
static const _githubRepo = 'StuartCameronCode/VapourBox';
static const _apiUrl = 'https://api.github.com/repos/$_githubRepo/releases/latest';
static const _apiBase = 'https://api.github.com/repos/$_githubRepo';
static const _apiUrl = '$_apiBase/releases/latest';

/// App release tags only — `vX.Y.Z` exactly. The repo also carries
/// `deps-vX.Y.Z` and `whisper-vX.Y.Z` tags for its two other release
/// trains, which must not show up as app versions.
static final RegExp _appVersionTag = RegExp(r'^v\d+\.\d+\.\d+$');

static UpdateChecker? _instance;
static UpdateChecker get instance => _instance ??= UpdateChecker._();
Expand All @@ -51,65 +88,121 @@ class UpdateChecker {
/// Returns null if no update is available or if check fails.
Future<UpdateInfo?> checkForUpdates() async {
try {
// Get current version
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version;

// Fetch latest release from GitHub (User-Agent required by GitHub API)
final client = await RhttpClient.create(
settings: const ClientSettings(
throwOnStatusCode: false,
),
final json = await _getJson(_apiUrl);
if (json is! Map<String, dynamic>) return null;

final tagName = json['tag_name'] as String?;
final htmlUrl = json['html_url'] as String?;
final body = json['body'] as String?;
final publishedAtStr = json['published_at'] as String?;

if (tagName == null || htmlUrl == null) {
debugPrint('UpdateChecker: Invalid response from GitHub API');
return null;
}

// Parse version from tag (remove 'v' prefix if present)
final latestVersion = tagName.startsWith('v') ? tagName.substring(1) : tagName;

final updateInfo = UpdateInfo(
currentVersion: currentVersion,
latestVersion: latestVersion,
releaseUrl: htmlUrl,
releaseNotes: body,
publishedAt: publishedAtStr != null ? DateTime.tryParse(publishedAtStr) : null,
);

if (updateInfo.isUpdateAvailable) {
debugPrint('UpdateChecker: Update available: $currentVersion -> $latestVersion');
return updateInfo;
} else {
debugPrint('UpdateChecker: No update available (current: $currentVersion, latest: $latestVersion)');
return null;
}
} catch (e) {
debugPrint('UpdateChecker: Error checking for updates: $e');
return null;
}
}

/// The app's full release history, newest first, for Settings → Changes.
/// Returns null on failure (network, rate limit, malformed response) so the
/// caller can distinguish "couldn't load" from "no releases exist".
Future<List<AppRelease>?> listReleases() async {
try {
final json = await _getJson('$_apiBase/releases?per_page=100');
if (json is! List) return null;

final releases = <AppRelease>[];
for (final entry in json) {
if (entry is! Map) continue;
final tagName = entry['tag_name'] as String?;
final htmlUrl = entry['html_url'] as String?;
if (tagName == null || htmlUrl == null) continue;
if (!_appVersionTag.hasMatch(tagName)) continue;
if (entry['draft'] == true || entry['prerelease'] == true) continue;

final publishedAtStr = entry['published_at'] as String?;
releases.add(AppRelease(
tagName: tagName,
version: tagName.substring(1),
name: entry['name'] as String?,
htmlUrl: htmlUrl,
publishedAt: publishedAtStr != null ? DateTime.tryParse(publishedAtStr) : null,
));
}
return releases;
} catch (e) {
debugPrint('UpdateChecker: Error listing releases: $e');
return null;
}
}

/// A single release's notes, fetched lazily — only called once the user
/// actually selects that version in the Changes tab. Returns null on
/// failure or when the release has no body.
Future<String?> fetchReleaseNotes(String tagName) async {
try {
final json = await _getJson('$_apiBase/releases/tags/$tagName');
if (json is! Map<String, dynamic>) return null;
final body = json['body'] as String?;
return (body == null || body.trim().isEmpty) ? null : body;
} catch (e) {
debugPrint('UpdateChecker: Error fetching release notes for $tagName: $e');
return null;
}
}

/// GET a GitHub API URL and decode the JSON body. Returns null on any
/// non-200 response or transport failure — every caller here treats that
/// the same way (fail soft, nothing loads), so the error handling lives
/// once rather than once per endpoint.
Future<dynamic> _getJson(String url) async {
try {
final client = await RhttpClient.create(
settings: const ClientSettings(throwOnStatusCode: false),
);
try {
final response = await client.get(
_apiUrl,
url,
headers: HttpHeaders.rawMap({
'User-Agent': 'VapourBox-UpdateChecker/1.0',
'Accept': 'application/vnd.github.v3+json',
}),
);

if (response.statusCode != 200) {
debugPrint('UpdateChecker: GitHub API returned ${response.statusCode}');
return null;
}

final json = jsonDecode(response.body) as Map<String, dynamic>;
final tagName = json['tag_name'] as String?;
final htmlUrl = json['html_url'] as String?;
final body = json['body'] as String?;
final publishedAtStr = json['published_at'] as String?;

if (tagName == null || htmlUrl == null) {
debugPrint('UpdateChecker: Invalid response from GitHub API');
return null;
}

// Parse version from tag (remove 'v' prefix if present)
final latestVersion = tagName.startsWith('v') ? tagName.substring(1) : tagName;

final updateInfo = UpdateInfo(
currentVersion: currentVersion,
latestVersion: latestVersion,
releaseUrl: htmlUrl,
releaseNotes: body,
publishedAt: publishedAtStr != null ? DateTime.tryParse(publishedAtStr) : null,
);

if (updateInfo.isUpdateAvailable) {
debugPrint('UpdateChecker: Update available: $currentVersion -> $latestVersion');
return updateInfo;
} else {
debugPrint('UpdateChecker: No update available (current: $currentVersion, latest: $latestVersion)');
debugPrint('UpdateChecker: GitHub API returned ${response.statusCode} for $url');
return null;
}
return jsonDecode(response.body);
} finally {
client.dispose();
}
} catch (e) {
debugPrint('UpdateChecker: Error checking for updates: $e');
debugPrint('UpdateChecker: request to $url failed: $e');
return null;
}
}
Expand Down
Loading