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.
+
+  
+
+## 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\""