first commit
This commit is contained in:
11
Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
11
Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 954 KiB |
59
Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
59
Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
6
Assets.xcassets/Contents.json
Normal file
6
Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
128
ContentView.swift
Normal file
128
ContentView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
432
DownloadViewModel.swift
Normal file
432
DownloadViewModel.swift
Normal file
@@ -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 <percent> <downloaded_bytes> <total_bytes> <speed> <eta>
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
Info.plist
Normal file
5
Info.plist
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
17
YoutubeDumper.entitlements
Normal file
17
YoutubeDumper.entitlements
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.files.absolute-path.read-only</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/opt/yt-dlp/bin/yt-dlp</string>
|
||||
<string>/usr/local/bin/yt-dlp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
11
YoutubeDumperApp.swift
Normal file
11
YoutubeDumperApp.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct YoutubeDumperApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.frame(minWidth: 720, minHeight: 420)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user