diff --git a/macos-gui/.gitignore b/macos-gui/.gitignore new file mode 100644 index 0000000..35dd53d --- /dev/null +++ b/macos-gui/.gitignore @@ -0,0 +1,16 @@ +# Swift build artifacts +.build/ +*.o +*.d + +# Generated app bundle +Untrunc.app/ + +# DMG installer +*.dmg + +# Pre-built CLI binary (compiled locally, not committed) +Sources/UntruncGUI/untrunc + +# macOS +.DS_Store diff --git a/macos-gui/Info.plist b/macos-gui/Info.plist new file mode 100644 index 0000000..58b6cb3 --- /dev/null +++ b/macos-gui/Info.plist @@ -0,0 +1,48 @@ + + + + + CFBundleExecutable + UntruncGUI + CFBundleIdentifier + com.local.UntruncGUI + CFBundleName + Untrunc + CFBundleDisplayName + Untrunc + CFBundleVersion + 1.0 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + APPL + CFBundleSignature + ???? + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + CFBundleIconFile + AppIcon + NSPrincipalClass + NSApplication + CFBundleDocumentTypes + + + CFBundleTypeName + Video File + CFBundleTypeExtensions + + mp4 + m4v + mov + 3gp + + CFBundleTypeRole + Viewer + + + NSAppleEventsUsageDescription + Untrunc needs access to repair video files. + + diff --git a/macos-gui/Package.swift b/macos-gui/Package.swift new file mode 100644 index 0000000..8df4624 --- /dev/null +++ b/macos-gui/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "UntruncGUI", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "UntruncGUI", + path: "Sources/UntruncGUI", + resources: [ + .copy("untrunc"), + .copy("AppIcon.icns") + ] + ) + ] +) diff --git a/macos-gui/README.md b/macos-gui/README.md new file mode 100644 index 0000000..01b89b7 --- /dev/null +++ b/macos-gui/README.md @@ -0,0 +1,57 @@ +# Untrunc — macOS GUI + +A native macOS SwiftUI application for **untrunc** — drag-and-drop video repair without touching the terminal. + +![macOS 13+](https://img.shields.io/badge/macOS-13%2B-blue) ![Swift 5.9](https://img.shields.io/badge/Swift-5.9-orange) ![Architecture](https://img.shields.io/badge/arch-arm64-lightgrey) + +## Features + +- **Drag & drop** reference file and corrupt file +- All key CLI flags exposed as checkboxes (`-s`, `-sv`, `-k`, `-dw`, `-ms`) +- Live log output streamed in real time +- "Show in Finder" button when repair completes +- Fully self-contained DMG — no Homebrew or FFmpeg needed on the target machine + +## Requirements (to build) + +- macOS 13 Ventura or later (Apple Silicon) +- Xcode Command Line Tools: `xcode-select --install` +- FFmpeg via Homebrew: `brew install ffmpeg` + +## Build & Run + +```bash +cd macos-gui +bash build.sh +open Untrunc.app +``` + +## Create a distributable DMG + +```bash +# Also requires: brew install create-dmg +bash package_dmg.sh +# → produces Untrunc-1.0.dmg +``` + +The DMG bundles all FFmpeg dylibs inside the `.app` and rewrites their +install names to `@loader_path`-relative paths, so the app runs on any +macOS 13+ Apple Silicon machine with no dependencies. + +## Project structure + +``` +macos-gui/ +├── Sources/UntruncGUI/ +│ ├── UntruncApp.swift # @main SwiftUI entry point +│ ├── ContentView.swift # Full UI (drop zones, options, log panel) +│ ├── UntruncRunner.swift # Runs untrunc CLI as subprocess, streams output +│ └── AppIcon.icns # App icon (regenerate with make_icon.py) +├── Package.swift # Swift Package manifest +├── Info.plist # macOS app bundle metadata +├── build.sh # Compiles untrunc + Swift app → Untrunc.app +├── bundle_libs.sh # Bundles FFmpeg dylibs and rewrites @rpath +├── package_dmg.sh # Full pipeline → Untrunc-1.0.dmg +├── make_icon.py # Generates AppIcon.icns using AppKit +└── make_dmg_bg.py # Generates DMG background image using AppKit +``` diff --git a/macos-gui/Sources/UntruncGUI/AppIcon.icns b/macos-gui/Sources/UntruncGUI/AppIcon.icns new file mode 100644 index 0000000..909de33 Binary files /dev/null and b/macos-gui/Sources/UntruncGUI/AppIcon.icns differ diff --git a/macos-gui/Sources/UntruncGUI/ContentView.swift b/macos-gui/Sources/UntruncGUI/ContentView.swift new file mode 100644 index 0000000..34dbac7 --- /dev/null +++ b/macos-gui/Sources/UntruncGUI/ContentView.swift @@ -0,0 +1,318 @@ +import SwiftUI +import AppKit + +struct ContentView: View { + @StateObject private var runner = UntruncRunner() + @State private var referenceFile: URL? = nil + @State private var corruptFile: URL? = nil + @State private var options = UntruncOptions() + @State private var logScrollProxy: ScrollViewProxy? = nil + + private let videoTypes = ["mp4", "m4v", "mov", "3gp", "MP4", "M4V", "MOV", "3GP"] + + var canRun: Bool { + referenceFile != nil && corruptFile != nil && !runner.isRunning + } + + var body: some View { + HSplitView { + // Left panel: controls + VStack(alignment: .leading, spacing: 20) { + headerSection + fileDropSection + optionsSection + actionSection + Spacer() + } + .padding(24) + .frame(minWidth: 340, maxWidth: 420) + + // Right panel: log output + logPanel + } + .frame(minWidth: 800, minHeight: 520) + .background(Color(NSColor.windowBackgroundColor)) + } + + // MARK: - Header + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(systemName: "film.stack") + .font(.system(size: 28)) + .foregroundColor(.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text("Untrunc") + .font(.title2) + .fontWeight(.bold) + Text("Restore truncated MP4/MOV video files") + .font(.caption) + .foregroundColor(.secondary) + } + } + Divider() + } + } + + // MARK: - File Drop Areas + + private var fileDropSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Files") + .font(.headline) + + FileDropZone( + label: "Reference File", + systemImage: "checkmark.seal.fill", + tint: .green, + hint: "A working video from the same source/camera", + file: $referenceFile, + allowedTypes: videoTypes + ) + + FileDropZone( + label: "Corrupt File", + systemImage: "exclamationmark.triangle.fill", + tint: .orange, + hint: "The truncated or damaged video to repair", + file: $corruptFile, + allowedTypes: videoTypes + ) + } + } + + // MARK: - Options + + private var optionsSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Options") + .font(.headline) + + Picker("Log level", selection: $options.logLevel) { + ForEach(LogLevel.allCases) { level in + Text(level.rawValue).tag(level) + } + } + .pickerStyle(.menu) + + Group { + Toggle("Step through unknown sequences (-s)", isOn: $options.stepUnknown) + Toggle("Stretch video to match audio (-sv)", isOn: $options.stretchVideo) + Toggle("Keep unknown sequences (-k)", isOn: $options.keepUnknown) + Toggle("Skip output file / analysis only (-dw)", isOn: $options.skipOutput) + Toggle("Make streamable output (-ms)", isOn: $options.makeStreamable) + } + .toggleStyle(.checkbox) + .font(.callout) + } + } + + // MARK: - Action Buttons + + private var actionSection: some View { + VStack(spacing: 10) { + if runner.isRunning { + HStack { + ProgressView() + .scaleEffect(0.8) + Text("Repairing…") + .font(.callout) + .foregroundColor(.secondary) + Spacer() + Button("Cancel") { runner.cancel() } + .foregroundColor(.red) + } + } else { + Button(action: repair) { + Label("Repair Video", systemImage: "wrench.and.screwdriver.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!canRun) + } + + if let outputFile = runner.lastOutputFile { + Button(action: { revealInFinder(outputFile) }) { + Label("Show Output in Finder", systemImage: "folder") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } + } + + // MARK: - Log Panel + + private var logPanel: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Output") + .font(.headline) + .padding(.horizontal, 16) + .padding(.vertical, 10) + Spacer() + if !runner.log.isEmpty { + Button("Clear") { runner.log = "" } + .buttonStyle(.plain) + .foregroundColor(.secondary) + .font(.callout) + .padding(.trailing, 16) + } + } + .background(Color(NSColor.windowBackgroundColor)) + + Divider() + + ScrollViewReader { proxy in + ScrollView { + Text(runner.log.isEmpty ? "Output will appear here…" : runner.log) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(runner.log.isEmpty ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .id("logBottom") + } + .background(Color(NSColor.textBackgroundColor)) + .onChange(of: runner.log) { _ in + withAnimation { + proxy.scrollTo("logBottom", anchor: .bottom) + } + } + } + } + .frame(minWidth: 380) + } + + // MARK: - Actions + + private func repair() { + guard let ref = referenceFile, let corrupt = corruptFile else { return } + runner.run(referenceFile: ref, corruptFile: corrupt, options: options) + } + + private func revealInFinder(_ url: URL) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } +} + +// MARK: - File Drop Zone + +struct FileDropZone: View { + let label: String + let systemImage: String + let tint: Color + let hint: String + @Binding var file: URL? + let allowedTypes: [String] + + @State private var isTargeted = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Label(label, systemImage: systemImage) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(tint) + + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(isTargeted + ? tint.opacity(0.12) + : Color(NSColor.controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder( + isTargeted ? tint : Color(NSColor.separatorColor), + style: StrokeStyle(lineWidth: isTargeted ? 2 : 1, dash: [6]) + ) + ) + + if let file { + HStack(spacing: 10) { + Image(systemName: "doc.fill") + .foregroundColor(tint) + VStack(alignment: .leading, spacing: 2) { + Text(file.lastPathComponent) + .font(.callout) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.middle) + Text(file.deletingLastPathComponent().path) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.head) + } + Spacer() + Button { + self.file = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + } else { + VStack(spacing: 6) { + Image(systemName: "arrow.down.circle") + .font(.title2) + .foregroundColor(isTargeted ? tint : .secondary) + Text("Drop file here or") + .font(.callout) + .foregroundColor(.secondary) + Button("Choose File…") { chooseFile() } + .buttonStyle(.plain) + .foregroundColor(.accentColor) + .font(.callout) + Text(hint) + .font(.caption2) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.vertical, 8) + } + } + .frame(height: 90) + .contentShape(Rectangle()) + .onTapGesture { if file == nil { chooseFile() } } + .onDrop(of: ["public.file-url"], isTargeted: $isTargeted) { providers in + handleDrop(providers) + } + } + } + + private func chooseFile() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.allowedContentTypes = [] + panel.title = "Select \(label)" + panel.message = hint + if panel.runModal() == .OK, let url = panel.url { + self.file = url + } + } + + private func handleDrop(_ providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { item, _ in + DispatchQueue.main.async { + if let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) { + let ext = url.pathExtension.lowercased() + if self.allowedTypes.map({ $0.lowercased() }).contains(ext) { + self.file = url + } + } + } + } + return true + } +} + +#Preview { + ContentView() +} diff --git a/macos-gui/Sources/UntruncGUI/UntruncApp.swift b/macos-gui/Sources/UntruncGUI/UntruncApp.swift new file mode 100644 index 0000000..1133111 --- /dev/null +++ b/macos-gui/Sources/UntruncGUI/UntruncApp.swift @@ -0,0 +1,15 @@ +import SwiftUI + +@main +struct UntruncApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .windowStyle(.titleBar) + .windowToolbarStyle(.unified) + .commands { + CommandGroup(replacing: .newItem) {} + } + } +} diff --git a/macos-gui/Sources/UntruncGUI/UntruncRunner.swift b/macos-gui/Sources/UntruncGUI/UntruncRunner.swift new file mode 100644 index 0000000..8d9d4e1 --- /dev/null +++ b/macos-gui/Sources/UntruncGUI/UntruncRunner.swift @@ -0,0 +1,149 @@ +import Foundation +import Combine + +class UntruncRunner: ObservableObject { + @Published var log: String = "" + @Published var isRunning: Bool = false + @Published var lastOutputFile: URL? = nil + + private var process: Process? + + var binaryPath: String { + // Look for bundled binary first, then fall back to PATH + if let bundled = Bundle.main.path(forResource: "untrunc", ofType: nil) { + return bundled + } + // Fall back to the built binary next to the app during development + let devPath = FileManager.default.currentDirectoryPath + let siblingPath = (devPath as NSString).appendingPathComponent("../untrunc-src/untrunc") + if FileManager.default.fileExists(atPath: siblingPath) { + return siblingPath + } + return "/usr/local/bin/untrunc" + } + + func run(referenceFile: URL, corruptFile: URL, options: UntruncOptions) { + guard !isRunning else { return } + isRunning = true + log = "" + lastOutputFile = nil + + var args: [String] = [] + + // Logging level + switch options.logLevel { + case .quiet: args.append("-q") + case .verbose: args.append("-v") + case .veryVerbose: args.append("-vv") + default: break + } + + if options.stepUnknown { args.append("-s") } + if options.stretchVideo { args.append("-sv") } + if options.keepUnknown { args.append("-k") } + if options.skipOutput { args.append("-dw") } + if options.makeStreamable { args.append("-ms") } + if options.noInteractive { args.append("-n") } + + args.append(referenceFile.path) + args.append(corruptFile.path) + + appendLog("▶ Running untrunc \(args.joined(separator: " "))\n") + + // Predict output filename + let ext = corruptFile.pathExtension + let base = corruptFile.deletingPathExtension().lastPathComponent + let outputName = "\(base)_fixed.\(ext)" + let outputURL = corruptFile.deletingLastPathComponent().appendingPathComponent(outputName) + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: self.binaryPath) + proc.arguments = args + + // Libraries are bundled inside the .app — no external deps needed. + // DYLD_LIBRARY_PATH is intentionally not set; @rpath handles resolution. + proc.environment = ProcessInfo.processInfo.environment + + let outPipe = Pipe() + let errPipe = Pipe() + proc.standardOutput = outPipe + proc.standardError = errPipe + + // Stream stdout + outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + if let str = String(data: data, encoding: .utf8), !str.isEmpty { + self?.appendLog(str) + } + } + // Stream stderr + errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + if let str = String(data: data, encoding: .utf8), !str.isEmpty { + self?.appendLog(str) + } + } + + self.process = proc + + do { + try proc.run() + proc.waitUntilExit() + } catch { + self.appendLog("❌ Failed to launch untrunc: \(error.localizedDescription)\n") + } + + outPipe.fileHandleForReading.readabilityHandler = nil + errPipe.fileHandleForReading.readabilityHandler = nil + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.isRunning = false + let status = proc.terminationStatus + if status == 0 { + self.appendLog("\n✅ Done! Output: \(outputURL.path)\n") + if FileManager.default.fileExists(atPath: outputURL.path) { + self.lastOutputFile = outputURL + } + } else { + self.appendLog("\n❌ Exited with status \(status)\n") + } + } + } + } + + func cancel() { + process?.terminate() + appendLog("\n⚠️ Cancelled.\n") + isRunning = false + } + + private func appendLog(_ text: String) { + DispatchQueue.main.async { + self.log += text + } + } +} + +// MARK: - Options + +enum LogLevel: String, CaseIterable, Identifiable { + case normal = "Normal" + case quiet = "Quiet (errors only)" + case verbose = "Verbose" + case veryVerbose = "Very Verbose" + var id: String { rawValue } +} + +struct UntruncOptions { + var logLevel: LogLevel = .normal + var stepUnknown: Bool = false + var stretchVideo: Bool = false + var keepUnknown: Bool = false + var skipOutput: Bool = false + var makeStreamable: Bool = false + var noInteractive: Bool = true +} diff --git a/macos-gui/build.sh b/macos-gui/build.sh new file mode 100755 index 0000000..4c7c7d7 --- /dev/null +++ b/macos-gui/build.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# build.sh — builds the Untrunc macOS GUI app +# Run from inside the macos-gui/ directory. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BUILD_DIR="$SCRIPT_DIR/.build" +APP_NAME="Untrunc" +APP_DIR="$SCRIPT_DIR/$APP_NAME.app" +CONTENTS="$APP_DIR/Contents" +CLI_DST="$SCRIPT_DIR/Sources/UntruncGUI/untrunc" + +# ── 1. Build the untrunc CLI from source ─────────────────────────────────── +echo "==> Building untrunc CLI..." +FFMPEG_PREFIX="$(brew --prefix ffmpeg 2>/dev/null || echo /opt/homebrew/opt/ffmpeg)" +if [ ! -d "$FFMPEG_PREFIX/include" ]; then + echo "ERROR: FFmpeg not found. Install with: brew install ffmpeg" + exit 1 +fi + +make -C "$REPO_ROOT" \ + CXXFLAGS="-isystem${FFMPEG_PREFIX}/include" \ + LDFLAGS="-L${FFMPEG_PREFIX}/lib -lavformat -lavcodec -lavutil" \ + 2>&1 + +cp "$REPO_ROOT/untrunc" "$CLI_DST" +echo " CLI binary ready." + +# ── 2. Regenerate app icon (optional, skipped if .icns already exists) ────── +ICNS="$SCRIPT_DIR/Sources/UntruncGUI/AppIcon.icns" +if [ ! -f "$ICNS" ]; then + echo "==> Generating app icon..." + python3 "$SCRIPT_DIR/make_icon.py" +fi + +# ── 3. Build the Swift GUI with swift build ───────────────────────────────── +echo "==> Building Swift GUI..." +cd "$SCRIPT_DIR" +swift build -c release 2>&1 + +BINARY="$BUILD_DIR/release/UntruncGUI" +if [ ! -f "$BINARY" ]; then + echo "ERROR: Swift build failed — binary not found." + exit 1 +fi + +# ── 4. Package as .app bundle ──────────────────────────────────────────────── +echo "==> Packaging $APP_NAME.app..." +rm -rf "$APP_DIR" +mkdir -p "$CONTENTS/MacOS" +mkdir -p "$CONTENTS/Resources" + +cp "$BINARY" "$CONTENTS/MacOS/UntruncGUI" +cp "$SCRIPT_DIR/Info.plist" "$CONTENTS/Info.plist" +cp "$ICNS" "$CONTENTS/Resources/AppIcon.icns" + +# Copy untrunc CLI +RESOURCE_UNTRUNC="$BUILD_DIR/release/UntruncGUI_UntruncGUI.resources/untrunc" +if [ -f "$RESOURCE_UNTRUNC" ]; then + cp "$RESOURCE_UNTRUNC" "$CONTENTS/Resources/untrunc" +elif [ -f "$CLI_DST" ]; then + cp "$CLI_DST" "$CONTENTS/Resources/untrunc" +fi +chmod +x "$CONTENTS/Resources/untrunc" +chmod +x "$CONTENTS/MacOS/UntruncGUI" + +echo "" +echo "✅ Built: $APP_DIR" +echo " Run: open \"$APP_DIR\"" +echo " To create a distributable DMG: bash package_dmg.sh" diff --git a/macos-gui/bundle_libs.sh b/macos-gui/bundle_libs.sh new file mode 100755 index 0000000..0b99671 --- /dev/null +++ b/macos-gui/bundle_libs.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# bundle_libs.sh — copies all non-system dylibs into the .app and rewrites paths +# so the app is fully self-contained (no Homebrew needed on target machine). +set -e + +APP="$1" +if [ -z "$APP" ]; then + echo "Usage: $0 " + exit 1 +fi + +FRAMEWORKS="$APP/Contents/Frameworks" +RESOURCES="$APP/Contents/Resources" +MACOS="$APP/Contents/MacOS" + +mkdir -p "$FRAMEWORKS" + +# ── Helpers ──────────────────────────────────────────────────────────────── + +# Returns true if the path is a system library (should NOT be bundled) +is_system() { + local p="$1" + [[ "$p" == /usr/lib/* ]] || \ + [[ "$p" == /System/* ]] || \ + [[ "$p" == @* ]] +} + +# Get the real path of a dylib install name +resolve_lib() { + local name="$1" + # Already an absolute path? + if [[ "$name" == /* ]] && [ -f "$name" ]; then + echo "$name"; return + fi + # Try brew prefix variations + local base; base=$(basename "$name") + local found; found=$(find /opt/homebrew /usr/local -name "$base" -type f 2>/dev/null | head -1) + echo "$found" +} + +PROCESSED=() +QUEUE=() + +already_processed() { + local t="$1" + for p in "${PROCESSED[@]}"; do [ "$p" = "$t" ] && return 0; done + return 1 +} + +# ── Recursively collect and copy libs ───────────────────────────────────── + +collect_deps() { + local binary="$1" + local deps + deps=$(otool -L "$binary" 2>/dev/null | tail -n +2 | awk '{print $1}') + + while IFS= read -r dep; do + [ -z "$dep" ] && continue + is_system "$dep" && continue + + local basename; basename=$(basename "$dep") + local dest="$FRAMEWORKS/$basename" + + already_processed "$basename" && continue + PROCESSED+=("$basename") + + # Resolve actual file path + local src + if [[ "$dep" == /* ]] && [ -f "$dep" ]; then + src="$dep" + else + src=$(resolve_lib "$dep") + fi + + if [ -z "$src" ] || [ ! -f "$src" ]; then + echo " ⚠️ Could not find: $dep (skipping)" + continue + fi + + echo " + $basename" + cp "$src" "$dest" + chmod 755 "$dest" + + # Recurse into the copied dylib + collect_deps "$dest" + done <<< "$deps" +} + +# ── Rewrite install names ───────────────────────────────────────────────── + +rewrite_binary() { + local binary="$1" + local ref_prefix="$2" # e.g. @loader_path/../Frameworks + + local deps + deps=$(otool -L "$binary" 2>/dev/null | tail -n +2 | awk '{print $1}') + + while IFS= read -r dep; do + [ -z "$dep" ] && continue + is_system "$dep" && continue + + local basename; basename=$(basename "$dep") + install_name_tool -change "$dep" "${ref_prefix}/${basename}" "$binary" 2>/dev/null || true + done <<< "$deps" +} + +rewrite_dylib_id() { + local dylib="$1" + local basename; basename=$(basename "$dylib") + install_name_tool -id "@rpath/$basename" "$dylib" 2>/dev/null || true +} + +# ── Main ────────────────────────────────────────────────────────────────── + +echo "==> Collecting dependencies..." + +# Seed from the untrunc CLI binary +UNTRUNC_BIN="$RESOURCES/untrunc" +[ -f "$UNTRUNC_BIN" ] && collect_deps "$UNTRUNC_BIN" + +# Also seed from the macOS GUI binary (in case it has extra deps) +GUI_BIN=$(find "$MACOS" -maxdepth 1 -type f -perm +111 | head -1) +[ -n "$GUI_BIN" ] && collect_deps "$GUI_BIN" + +echo "" +echo "==> Rewriting install names..." + +# 1. Rewrite references in the untrunc CLI binary +# (it lives in Contents/Resources, so Frameworks is one level up) +if [ -f "$UNTRUNC_BIN" ]; then + echo " Patching: $(basename "$UNTRUNC_BIN")" + rewrite_binary "$UNTRUNC_BIN" "@loader_path/../Frameworks" +fi + +# 2. Rewrite references in the GUI binary +if [ -n "$GUI_BIN" ]; then + echo " Patching: $(basename "$GUI_BIN")" + rewrite_binary "$GUI_BIN" "@executable_path/../Frameworks" +fi + +# 3. Fix each bundled dylib: update its own -id and its inter-lib references +for dylib in "$FRAMEWORKS"/*.dylib; do + [ -f "$dylib" ] || continue + echo " Patching: $(basename "$dylib")" + rewrite_dylib_id "$dylib" + rewrite_binary "$dylib" "@loader_path" +done + +# 4. Add @rpath entries so @rpath-based references resolve +if [ -f "$UNTRUNC_BIN" ]; then + install_name_tool -add_rpath "@loader_path/../Frameworks" "$UNTRUNC_BIN" 2>/dev/null || true +fi +if [ -n "$GUI_BIN" ]; then + install_name_tool -add_rpath "@executable_path/../Frameworks" "$GUI_BIN" 2>/dev/null || true +fi + +echo "" +echo "==> Verifying untrunc can find its libraries..." +DYLD_LIBRARY_PATH="$FRAMEWORKS" \ +DYLD_FALLBACK_LIBRARY_PATH="$FRAMEWORKS" \ +"$UNTRUNC_BIN" -V 2>&1 && echo " ✅ untrunc runs standalone" || echo " ⚠️ Verification failed — check output above" + +echo "" +echo "Done. Bundled $(ls "$FRAMEWORKS" | wc -l | tr -d ' ') dylibs into $FRAMEWORKS" diff --git a/macos-gui/make_dmg_bg.py b/macos-gui/make_dmg_bg.py new file mode 100644 index 0000000..fb868c6 --- /dev/null +++ b/macos-gui/make_dmg_bg.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Generate a 660x400 DMG background image using Swift/AppKit.""" +import subprocess, sys + +script = r""" +import AppKit +import CoreGraphics + +let W = CGFloat(660), H = CGFloat(400) +let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: 660, pixelsHigh: 400, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! +NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) +let ctx = NSGraphicsContext.current!.cgContext + +// Background gradient +let bg1 = CGColor(red:0.06, green:0.07, blue:0.11, alpha:1) +let bg2 = CGColor(red:0.10, green:0.13, blue:0.22, alpha:1) +let grad = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: [bg1, bg2] as CFArray, locations: [0, 1] as [CGFloat])! +ctx.drawLinearGradient(grad, start: CGPoint(x:0,y:H), end: CGPoint(x:W,y:0), options:[]) + +// Subtle film strip texture lines across top and bottom +ctx.setStrokeColor(CGColor(red:1,green:1,blue:1,alpha:0.04)) +ctx.setLineWidth(1) +for i in stride(from: 0, to: Int(W), by: 18) { + ctx.move(to: CGPoint(x: CGFloat(i), y: 0)) + ctx.addLine(to: CGPoint(x: CGFloat(i), y: H)) +} +ctx.strokePath() + +// Center divider glow +let divX = W * 0.5 +ctx.setStrokeColor(CGColor(red:0.24, green:0.52, blue:1.0, alpha:0.18)) +ctx.setLineWidth(1) +ctx.move(to: CGPoint(x: divX, y: 60)) +ctx.addLine(to: CGPoint(x: divX, y: H - 60)) +ctx.strokePath() + +// Arrow from left (app) to right (Applications) +let arrowY = H * 0.52 +let ax1 = W * 0.38, ax2 = W * 0.62 +ctx.setStrokeColor(CGColor(red:0.24, green:0.52, blue:1.0, alpha:0.55)) +ctx.setLineWidth(2.5) +ctx.setLineCap(.round) +ctx.move(to: CGPoint(x: ax1, y: arrowY)) +ctx.addLine(to: CGPoint(x: ax2 - 14, y: arrowY)) +ctx.strokePath() +// arrowhead +ctx.setFillColor(CGColor(red:0.24, green:0.52, blue:1.0, alpha:0.55)) +let arrow = CGMutablePath() +arrow.move(to: CGPoint(x: ax2, y: arrowY)) +arrow.addLine(to: CGPoint(x: ax2 - 14, y: arrowY - 7)) +arrow.addLine(to: CGPoint(x: ax2 - 14, y: arrowY + 7)) +arrow.closeSubpath() +ctx.addPath(arrow) +ctx.fillPath() + +// Labels +let titleAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 22, weight: .bold), + .foregroundColor: NSColor(calibratedRed:0.92, green:0.94, blue:1.0, alpha:1) +] +let subAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 12, weight: .regular), + .foregroundColor: NSColor(calibratedRed:0.55, green:0.62, blue:0.80, alpha:1) +] +let instrAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 13, weight: .medium), + .foregroundColor: NSColor(calibratedRed:0.70, green:0.78, blue:1.0, alpha:0.85) +] + +func drawCentered(_ s: String, attrs: [NSAttributedString.Key:Any], cx: CGFloat, cy: CGFloat) { + let ns = NSAttributedString(string: s, attributes: attrs) + let sz = ns.size() + ns.draw(at: CGPoint(x: cx - sz.width/2, y: cy - sz.height/2)) +} + +drawCentered("Untrunc", attrs: titleAttrs, cx: W*0.5, cy: H*0.82) +drawCentered("Drag Untrunc to your Applications folder", attrs: instrAttrs, cx: W*0.5, cy: H*0.70) +drawCentered("Restore truncated MP4, MOV & 3GP video files", attrs: subAttrs, cx: W*0.5, cy: H*0.60) + +let data = rep.representation(using: .png, properties: [:])! +try! data.write(to: URL(fileURLWithPath: "/tmp/dmg_background.png")) +print("done") +""" +r = subprocess.run(["swift", "-"], input=script, capture_output=True, text=True) +if r.returncode != 0: + print("Error:", r.stderr[:400]) + sys.exit(1) +print("✅ DMG background written to /tmp/dmg_background.png") diff --git a/macos-gui/make_icon.py b/macos-gui/make_icon.py new file mode 100644 index 0000000..41e3e3a --- /dev/null +++ b/macos-gui/make_icon.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Generates Untrunc.icns app icon using only built-in macOS tools. +Draws a film-reel + wrench concept with a dark gradient background. +""" +import subprocess, os, shutil, math, struct, zlib + +SIZES = [16, 32, 64, 128, 256, 512, 1024] + +def draw_icon(size): + """Generate icon PNG at given size using a Swift one-liner via NSGraphicsContext.""" + script = f""" +import AppKit +import CoreGraphics + +let sz = CGFloat({size}) +let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: {size}, pixelsHigh: {size}, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0)! + +NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) +let ctx = NSGraphicsContext.current!.cgContext + +// ---- Background: dark rounded rect with gradient ---- +let pad = sz * 0.0 +let rect = CGRect(x: pad, y: pad, width: sz - pad*2, height: sz - pad*2) +let radius = sz * 0.22 + +let path = CGMutablePath() +path.addRoundedRect(in: rect, cornerWidth: radius, cornerHeight: radius) +ctx.addPath(path) +ctx.clip() + +let colors = [CGColor(red:0.08, green:0.09, blue:0.14, alpha:1), + CGColor(red:0.13, green:0.16, blue:0.26, alpha:1)] +let locs: [CGFloat] = [0, 1] +let grad = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: colors as CFArray, locations: locs)! +ctx.drawLinearGradient(grad, + start: CGPoint(x: sz*0.3, y: sz), + end: CGPoint(x: sz*0.7, y: 0), + options: []) + +// ---- Film strip body ---- +let blue = CGColor(red:0.24, green:0.52, blue:1.0, alpha:1) +let filmH = sz * 0.36 +let filmY = sz * 0.32 +let filmX = sz * 0.14 +let filmW = sz * 0.72 +let filmR = sz * 0.04 + +let film = CGMutablePath() +film.addRoundedRect(in: CGRect(x:filmX, y:filmY, width:filmW, height:filmH), + cornerWidth: filmR, cornerHeight: filmR) +ctx.addPath(film) +ctx.setFillColor(blue) +ctx.fillPath() + +// ---- Sprocket holes ---- +let holeR = sz * 0.038 +let holeY1 = filmY + filmH * 0.18 +let holeY2 = filmY + filmH * 0.64 +let holeCount = 5 +let holeStep = filmW / CGFloat(holeCount + 1) +ctx.setFillColor(CGColor(red:0.08, green:0.09, blue:0.14, alpha:0.9)) +for i in 1...holeCount {{ + let hx = filmX + holeStep * CGFloat(i) + ctx.fillEllipse(in: CGRect(x: hx - holeR, y: holeY1 - holeR, width: holeR*2, height: holeR*2)) + ctx.fillEllipse(in: CGRect(x: hx - holeR, y: holeY2 - holeR, width: holeR*2, height: holeR*2)) +}} + +// ---- Wrench (repair symbol) overlapping bottom-right ---- +let wCx = sz * 0.64 +let wCy = sz * 0.38 +let wR = sz * 0.22 + +// wrench handle +ctx.setStrokeColor(CGColor(red:1, green:0.78, blue:0.2, alpha:1)) +ctx.setLineWidth(sz * 0.075) +ctx.setLineCap(.round) +ctx.move(to: CGPoint(x: wCx - wR*0.5, y: wCy - wR*0.5)) +ctx.addLine(to: CGPoint(x: wCx + wR*0.55, y: wCy + wR*0.55)) +ctx.strokePath() + +// wrench head circle +ctx.setFillColor(CGColor(red:1, green:0.78, blue:0.2, alpha:1)) +let headR = wR * 0.38 +ctx.fillEllipse(in: CGRect(x: wCx - wR*0.5 - headR, y: wCy - wR*0.5 - headR, + width: headR*2, height: headR*2)) +// cut out center +ctx.setFillColor(CGColor(red:0.12, green:0.14, blue:0.20, alpha:1)) +let cutR = headR * 0.48 +ctx.fillEllipse(in: CGRect(x: wCx - wR*0.5 - cutR, y: wCy - wR*0.5 - cutR, + width: cutR*2, height: cutR*2)) + +// ---- Lightning bolt (corrupt indicator) top-left ---- +ctx.setFillColor(CGColor(red:1, green:0.35, blue:0.25, alpha:0.92)) +let bx = sz * 0.19 +let by = sz * 0.60 +let bw = sz * 0.11 +let bh = sz * 0.20 +let bolt = CGMutablePath() +bolt.move(to: CGPoint(x: bx + bw*0.55, y: by + bh)) +bolt.addLine(to: CGPoint(x: bx + bw, y: by + bh*0.52)) +bolt.addLine(to: CGPoint(x: bx + bw*0.62, y: by + bh*0.52)) +bolt.addLine(to: CGPoint(x: bx + bw*0.45, y: by)) +bolt.addLine(to: CGPoint(x: bx, y: by + bh*0.48)) +bolt.addLine(to: CGPoint(x: bx + bw*0.38, y: by + bh*0.48)) +bolt.closeSubpath() +ctx.addPath(bolt) +ctx.fillPath() + +// ---- Output PNG ---- +let data = rep.representation(using: .png, properties: [:])! +try! data.write(to: URL(fileURLWithPath: "/tmp/icon_{size}.png")) +""" + result = subprocess.run(["swift", "-"], input=script, capture_output=True, text=True) + if result.returncode != 0: + print(f" Swift error for {size}:", result.stderr[:300]) + return False + return True + + +def build_iconset(out_dir): + os.makedirs(out_dir, exist_ok=True) + mappings = [ + (16, "icon_16x16.png"), + (32, "icon_16x16@2x.png"), + (32, "icon_32x32.png"), + (64, "icon_32x32@2x.png"), + (128, "icon_128x128.png"), + (256, "icon_128x128@2x.png"), + (256, "icon_256x256.png"), + (512, "icon_256x256@2x.png"), + (512, "icon_512x512.png"), + (1024,"icon_512x512@2x.png"), + ] + needed = sorted(set(s for s, _ in mappings)) + for sz in needed: + print(f" Rendering {sz}x{sz}…") + if not draw_icon(sz): + return False + for sz, name in mappings: + src = f"/tmp/icon_{sz}.png" + dst = os.path.join(out_dir, name) + shutil.copy(src, dst) + return True + + +if __name__ == "__main__": + iconset = "/tmp/Untrunc.iconset" + icns = "/Users/macbook/Desktop/AI Vibe Code/untrunc/UntruncGUI/Sources/UntruncGUI/AppIcon.icns" + + print("Drawing icon frames…") + if not build_iconset(iconset): + print("FAILED") + raise SystemExit(1) + + print("Compiling .icns…") + r = subprocess.run(["iconutil", "-c", "icns", iconset, "-o", icns], capture_output=True, text=True) + if r.returncode != 0: + print("iconutil error:", r.stderr) + raise SystemExit(1) + print(f"✅ Icon written to {icns}") diff --git a/macos-gui/package_dmg.sh b/macos-gui/package_dmg.sh new file mode 100755 index 0000000..8fbd05b --- /dev/null +++ b/macos-gui/package_dmg.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_NAME="Untrunc" +VERSION="1.0" +DMG_NAME="${APP_NAME}-${VERSION}.dmg" +APP_PATH="$SCRIPT_DIR/${APP_NAME}.app" +OUT_DMG="$SCRIPT_DIR/$DMG_NAME" +STAGING="$SCRIPT_DIR/.dmg-staging" +BG_IMG="/tmp/dmg_background.png" + +# ── 1. Build the app ──────────────────────────────────────────────────────── +echo "==> Building ${APP_NAME}.app..." +bash "$SCRIPT_DIR/build.sh" + +if [ ! -d "$APP_PATH" ]; then + echo "ERROR: ${APP_NAME}.app not found after build." + exit 1 +fi + +# ── 2. Copy icon into Contents/Resources ─────────────────────────────────── +ICNS_SRC="$SCRIPT_DIR/Sources/UntruncGUI/AppIcon.icns" +if [ -f "$ICNS_SRC" ]; then + cp "$ICNS_SRC" "$APP_PATH/Contents/Resources/AppIcon.icns" + echo " Installed app icon." +fi + +# ── 3. Bundle all non-system dylibs (FFmpeg + deps) ──────────────────────── +echo "==> Bundling dylibs for self-contained distribution..." +bash "$SCRIPT_DIR/bundle_libs.sh" "$APP_PATH" + +# ── 4. Ad-hoc code-sign the whole bundle (must happen AFTER bundling libs) ── +echo "==> Code-signing (ad-hoc)..." +# Sign each dylib individually first, then the outer bundle +for dylib in "$APP_PATH/Contents/Frameworks/"*.dylib; do + codesign --force --sign - "$dylib" 2>/dev/null || true +done +codesign --force --deep --sign - "$APP_PATH" 2>&1 || true + +# ── 5. Generate DMG background ───────────────────────────────────────────── +echo "==> Generating DMG background..." +python3 "$SCRIPT_DIR/make_dmg_bg.py" + +# ── 6. Create the DMG ────────────────────────────────────────────────────── +echo "==> Creating ${DMG_NAME}..." +rm -f "$OUT_DMG" +rm -rf "$STAGING" + +create-dmg \ + --volname "$APP_NAME" \ + --volicon "$ICNS_SRC" \ + --background "$BG_IMG" \ + --window-pos 200 120 \ + --window-size 660 400 \ + --icon-size 120 \ + --icon "${APP_NAME}.app" 165 200 \ + --app-drop-link 495 200 \ + --no-internet-enable \ + --hdiutil-quiet \ + "$OUT_DMG" \ + "$APP_PATH" + +echo "" +echo "✅ Done: $OUT_DMG" +echo " To install: open \"$OUT_DMG\""