commit fd180ccd2e95c60af57f15da620c70d7c471b164 Author: Victor Fedorov Date: Tue Jan 20 19:35:38 2026 +0700 first commit diff --git a/Assets.xcassets/AccentColor.colorset/Contents.json b/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/AppIcon.appiconset/ChatGPT Image 10 янв. 2026 г., 20_55_42.png b/Assets.xcassets/AppIcon.appiconset/ChatGPT Image 10 янв. 2026 г., 20_55_42.png new file mode 100644 index 0000000..6bf55e0 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ChatGPT Image 10 янв. 2026 г., 20_55_42.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/Contents.json b/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..7988c4e --- /dev/null +++ b/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,59 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "ChatGPT Image 10 янв. 2026 г., 20_55_42.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/Contents.json b/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ContentView.swift b/ContentView.swift new file mode 100644 index 0000000..8a5ee4b --- /dev/null +++ b/ContentView.swift @@ -0,0 +1,128 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var vm = DownloadViewModel() + + var body: some View { + VStack(spacing: 16) { + header + + GroupBox("Download") { + VStack(alignment: .leading, spacing: 10) { + HStack { + TextField("YouTube video or playlist URL", text: $vm.urlString) + .textFieldStyle(.roundedBorder) + + Button("Paste") { + if let s = NSPasteboard.general.string(forType: .string) { + vm.urlString = s.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + } + + HStack(spacing: 16) { + Toggle("Audio only (MP3)", isOn: $vm.audioOnly) + + Picker("Quality", selection: $vm.quality) { + ForEach(VideoQuality.allCases, id: \.self) { q in + Text(q.title).tag(q) + } + } + .pickerStyle(.menu) + + Spacer() + } + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Save to:") + .font(.caption) + .foregroundStyle(.secondary) + + Text(vm.destinationPathDisplay) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + Button("Choose Folder…") { vm.pickFolder() } + } + + Divider() + + HStack(spacing: 12) { + Button(vm.isDownloading ? "Downloading…" : "Start") { + vm.start() + } + .disabled(!vm.canStart) + + Button("Cancel") { + vm.cancel() + } + .disabled(!vm.isDownloading) + + Spacer() + } + } + .padding(8) + } + + GroupBox("Progress") { + VStack(alignment: .leading, spacing: 10) { + ProgressView(value: vm.progress) + .progressViewStyle(.linear) + + HStack { + Text(vm.statusLine) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + + Spacer() + + if vm.isDownloading { + Text(vm.percentText) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + .padding(8) + } + + GroupBox("Log") { + ScrollView { + Text(vm.log) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(.vertical, 6) + } + .frame(height: 140) + .padding(.horizontal, 8) + } + } + .padding(18) + .alert("Error", isPresented: $vm.showError) { + Button("OK", role: .cancel) { } + } message: { + Text(vm.errorMessage) + } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("YoutubeDumper") + .font(.title2.bold()) + Text("Uses Homebrew yt-dlp. Supports videos, playlists, and audio-only.") + .foregroundStyle(.secondary) + } + Spacer() + Button("Check yt-dlp") { + vm.checkYtDlp() + } + } + } +} diff --git a/DownloadViewModel.swift b/DownloadViewModel.swift new file mode 100644 index 0000000..abd73bc --- /dev/null +++ b/DownloadViewModel.swift @@ -0,0 +1,432 @@ +import Foundation +import AppKit +import SwiftUI +import Combine + +enum VideoQuality: String, CaseIterable { + case best + case p1080 + case p720 + case p480 + + var title: String { + switch self { + case .best: return "Best" + case .p1080: return "1080p" + case .p720: return "720p" + case .p480: return "480p" + } + } + + /// yt-dlp -f format selector + var formatSelector: String { + switch self { + case .best: + return "bv*+ba/b" + case .p1080: + return "bv*[height<=1080]+ba/b[height<=1080]" + case .p720: + return "bv*[height<=720]+ba/b[height<=720]" + case .p480: + return "bv*[height<=480]+ba/b[height<=480]" + } + } +} + +@MainActor +final class DownloadViewModel: ObservableObject { + @Published var urlString: String = "" + @Published var audioOnly: Bool = false + @Published var quality: VideoQuality = .best + + @Published var progress: Double = 0 + @Published var statusLine: String = "Idle" + @Published var log: String = "" + + @Published var isDownloading: Bool = false + + @Published var showError: Bool = false + @Published var errorMessage: String = "" + + private let ytDlpBookmarkKey = "YoutubeDumper.ytDlpBookmark" + + private(set) var ytDlpURL: URL? + + + // security-scoped destination folder + private(set) var destinationURL: URL? { + didSet { updateDestinationDisplay() } + } + @Published var destinationPathDisplay: String = "Not selected" + + private var task: Process? + private var stdoutPipe: Pipe? + private var stderrPipe: Pipe? + + private let bookmarkKey = "YoutubeDumper.destinationFolderBookmark" + + var canStart: Bool { + !isDownloading && + !urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + destinationURL != nil + } + + var percentText: String { + "\(Int(progress * 100))%" + } + + init() { + destinationURL = restoreSecurityScopedBookmark(key: bookmarkKey) + ytDlpURL = restoreSecurityScopedBookmark(key: ytDlpBookmarkKey) + updateDestinationDisplay() + } + + func pickYtDlpBinary() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.prompt = "Choose" + panel.message = "Select yt-dlp executable (usually /opt/homebrew/bin/yt-dlp)." + + if panel.runModal() == .OK, let url = panel.url { + saveSecurityScopedBookmark(for: url, key: ytDlpBookmarkKey) + ytDlpURL = url + appendLog("yt-dlp выбран: \(url.path)\n") + statusLine = "yt-dlp выбран" + } + } + + private func saveSecurityScopedBookmark(for url: URL, key: String) { + do { + let data = try url.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + UserDefaults.standard.set(data, forKey: key) + } catch { + presentError("Failed to save permission: \(error.localizedDescription)") + } + } + + private func restoreSecurityScopedBookmark(key: String) -> URL? { + guard let data = UserDefaults.standard.data(forKey: key) else { return nil } + var stale = false + do { + let url = try URL( + resolvingBookmarkData: data, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &stale + ) + if stale { + saveSecurityScopedBookmark(for: url, key: key) + } + return url + } catch { + return nil + } + } + + func pickFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "Choose" + panel.message = "Select the folder where downloads will be saved." + + if panel.runModal() == .OK, let url = panel.url { + saveSecurityScopedBookmark(for: url) + destinationURL = url + appendLog("Destination: \(url.path)\n") + } + } + + func checkYtDlp() { + do { + let path = try resolveYtDlpURL() + appendLog("yt-dlp found at: \(path)\n") + statusLine = "yt-dlp OK" + } catch { + presentError(error.localizedDescription) + } + } + + func start() { + guard !isDownloading else { return } + guard let destinationURL else { + presentError("Please choose a destination folder.") + return + } + + let url = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard url.lowercased().hasPrefix("http") else { + presentError("Please enter a valid URL.") + return + } + + isDownloading = true + progress = 0 + statusLine = "Starting…" + appendLog("Starting download: \(url)\n") + + Task { + do { + let ytDlpURL = try await resolveYtDlpURL() + try runYtDlp(url: url, destination: destinationURL) + } catch { + let err = error.localizedDescription + finishWithError(error.localizedDescription) + } + } + } + + func cancel() { + guard isDownloading else { return } + appendLog("Cancelling…\n") + task?.terminate() + } + + // MARK: - yt-dlp + + /// Prefer /opt/homebrew/bin/yt-dlp (Apple Silicon) and /usr/local/bin/yt-dlp (Intel), + /// otherwise fallback to /bin/zsh -lc "command -v yt-dlp". + private func resolveYtDlpURL() throws -> URL { + // 1) если уже выбран пользователем + if let url = ytDlpURL { + return url + } + + // 2) пробуем стандартные пути (но в sandbox чаще всё равно не запустится без выбора) + let candidates = [ + URL(fileURLWithPath: "/opt/homebrew/bin/yt-dlp"), + URL(fileURLWithPath: "/usr/local/bin/yt-dlp") + ] + + for url in candidates { + if FileManager.default.fileExists(atPath: url.path) { + // Сохраним как подсказку, но запуск может упасть без прав — поэтому всё равно лучше выбрать через панель + return url + } + } + + throw NSError(domain: "YoutubeDumper", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "yt-dlp не найден. Установи brew install yt-dlp или выбери бинарь через 'Choose yt-dlp…'" + ]) + } + + + private func runYtDlp(url: String, destination: URL) throws { + let ytDlp = try resolveYtDlpURL() + + // ВАЖНО: sandbox доступ + let ytOK = ytDlp.startAccessingSecurityScopedResource() + let destOK = destination.startAccessingSecurityScopedResource() + defer { + if ytOK { ytDlp.stopAccessingSecurityScopedResource() } + if destOK { destination.stopAccessingSecurityScopedResource() } + } + + // Разрешим симлинки (чтобы реально запускать конечный файл) + let ytDlpResolved = ytDlp.resolvingSymlinksInPath() + + // security scoped access + let accessOK = destination.startAccessingSecurityScopedResource() + defer { + if accessOK { destination.stopAccessingSecurityScopedResource() } + } + + let process = Process() + process.executableURL = ytDlpResolved + process.currentDirectoryURL = destination + + // Output template + let outputTemplate = "%(title).200B [%(id)s].%(ext)s" + + // Stable progress template: + // PROGRESS + let progressTemplate = "PROGRESS %(progress._percent_str)s %(progress.downloaded_bytes)s %(progress.total_bytes)s %(progress.speed)s %(progress.eta)s" + + var args: [String] = [] + args += ["--newline"] + args += ["--progress-template", progressTemplate] + args += ["-o", outputTemplate] + + if audioOnly { + args += ["-x", "--audio-format", "mp3"] + args += ["-f", "ba/b"] + } else { + args += ["-f", quality.formatSelector] + args += ["--merge-output-format", "mkv"] + + let ffmpegCandidates = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"] + if let ffmpegPath = ffmpegCandidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) { + args += ["--ffmpeg-location", ffmpegPath] + } + } + + args += ["--no-part"] + args += ["--no-continue", "--force-overwrites"] + args += ["--retries", "10", "--fragment-retries", "10"] + + args += [url] + + process.arguments = args + + stdoutPipe = Pipe() + stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + task = process + + stdoutPipe?.fileHandleForReading.readabilityHandler = { [weak self] h in + let data = h.availableData + guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return } + Task { @MainActor in self?.handleOutput(line) } + } + + stderrPipe?.fileHandleForReading.readabilityHandler = { [weak self] h in + let data = h.availableData + guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return } + Task { @MainActor in + self?.appendLog(line) + self?.statusLine = "Working…" + } + } + + process.terminationHandler = { [weak self] proc in + Task { @MainActor in + self?.stdoutPipe?.fileHandleForReading.readabilityHandler = nil + self?.stderrPipe?.fileHandleForReading.readabilityHandler = nil + + let code = proc.terminationStatus + if code == 0 { + self?.appendLog("\nDone.\n") + self?.statusLine = "Done" + self?.progress = 1.0 + self?.isDownloading = false + } else { + self?.finishWithError("yt-dlp exited with code \(code). See log.") + } + self?.task = nil + } + } + + try process.run() + statusLine = "Downloading…" + } + + private func handleOutput(_ chunk: String) { + let lines = chunk.split(whereSeparator: \.isNewline).map(String.init) + for line in lines { + if line.hasPrefix("PROGRESS ") { + parseProgressLine(line) + } else { + appendLog(line + "\n") + } + } + } + + private func parseProgressLine(_ line: String) { + let tokens = line.split(separator: " ").map(String.init).filter { !$0.isEmpty } + guard tokens.count >= 2 else { return } + + let percentStr = tokens[1].replacingOccurrences(of: "%", with: "") + if let p = Double(percentStr) { + progress = max(0, min(1, p / 100.0)) + statusLine = "Downloading… \(Int(p))%" + } else { + statusLine = "Downloading…" + } + } + + // MARK: - Bookmark persistence + + private func saveSecurityScopedBookmark(for url: URL) { + do { + let data = try url.bookmarkData(options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil) + UserDefaults.standard.set(data, forKey: bookmarkKey) + } catch { + presentError("Failed to save folder permission: \(error.localizedDescription)") + } + } + + private func restoreDestinationFromBookmark() { + guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { return } + var stale = false + do { + let url = try URL(resolvingBookmarkData: data, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &stale) + if stale { + saveSecurityScopedBookmark(for: url) + } + destinationURL = url + } catch { + // ignore, user can pick again + } + } + + private func updateDestinationDisplay() { + destinationPathDisplay = destinationURL?.path ?? "Not selected" + } + + // MARK: - Helpers + + private func appendLog(_ s: String) { + log += s + if log.count > 80_000 { + log = String(log.suffix(60_000)) + } + } + + private func presentError(_ message: String) { + errorMessage = message + showError = true + appendLog("ERROR: \(message)\n") + statusLine = "Error" + } + + private func finishWithError(_ message: String) { + isDownloading = false + progress = 0 + presentError(message) + task = nil + } + + private func runAndCapture(_ executable: String, _ args: [String]) async throws -> String { + try await withCheckedThrowingContinuation { cont in + let p = Process() + p.executableURL = URL(fileURLWithPath: executable) + p.arguments = args + + let pipe = Pipe() + p.standardOutput = pipe + p.standardError = Pipe() + + p.terminationHandler = { proc in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let out = String(data: data, encoding: .utf8) ?? "" + if proc.terminationStatus == 0 { + cont.resume(returning: out) + } else { + cont.resume(throwing: NSError(domain: "YoutubeDumper", code: Int(proc.terminationStatus), userInfo: [ + NSLocalizedDescriptionKey: "Command failed: \(executable) \(args.joined(separator: " "))" + ])) + } + } + + do { + try p.run() + } catch { + cont.resume(throwing: error) + } + } + } +} diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/YoutubeDumper.entitlements b/YoutubeDumper.entitlements new file mode 100644 index 0000000..dae1573 --- /dev/null +++ b/YoutubeDumper.entitlements @@ -0,0 +1,17 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.temporary-exception.files.absolute-path.read-only + + /opt/homebrew/opt/yt-dlp/bin/yt-dlp + /usr/local/bin/yt-dlp + + + diff --git a/YoutubeDumperApp.swift b/YoutubeDumperApp.swift new file mode 100644 index 0000000..95ccbc8 --- /dev/null +++ b/YoutubeDumperApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct YoutubeDumperApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 720, minHeight: 420) + } + } +}