🔧 Programmierung 🕛 vor 5 Monaten 9 Min Lesezeit
0

From Vibe-Coding to Reality: Building MarvinSync

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

If you saw my posts back in February on , you know I’ve been deep in a .






What is MarvinSync? (And why Vibe-Code it?)



MarvinSync is a utility designed for a specific niche: people who still believe in local media ownership. It bridges the gap between a curated macOS music library and an Android device. No streaming, no cloud—just your folders, your metadata, and a clean sync via ADB (Android Debug Bridge).



But there’s a meta-story here. As an Android GDE, I spend my life deep in Kotlin, Compose, and Kotlin Multiplatform. Naturally, KMP would have been the logical choice for a cross-platform sync tool. However, I wanted to take this opportunity to go fully native on the Mac side using Swift and SwiftUI.



I’ll be the first to admit: I am no Swift expert. This is where vibe-coding comes in. I used Cursor to bridge the gap between my architectural knowledge and my lack of Swift syntax fluency. I provided the vibe—the logic, the structure, and the constraints—and the AI handled the boilerplate and the nuances of a language I’m still learning.






Trusting the folder, not the tag



One of the first big hurdles was handling music metadata. Initially, we tried the traditional route of scanning ID3 tags using standard APIs. The result was a mess of duplicates and wrong titles that didn't match how my files were actually organized.



The solution was to stop being smart with metadata and start being literal with the file system. We shifted to a strict folder-based hierarchy where the directory structure itself defines the library.




CODE
/// Inside `MusicFolderStore`. Structure: base / Artist / Album / tracks.
/// `extractArtworkFromFirstTrack` walks audio files and uses `AVMetadataItem` (identifier-based artwork only).
private func scanForAlbumsFolderBased(in baseURL: URL) -> [Album] {
let artistURLs: [URL]
do {
let contents = try fileManager.contentsOfDirectory(
at: baseURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)
artistURLs = contents.filter { url in
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
}
} catch {
NSLog("MusicFolderStore: failed to list base folder: \(error.localizedDescription)")
return []
}

var result: [Album] = []

for artistURL in artistURLs {
let artistName = artistURL.lastPathComponent

let albumURLs: [URL]
do {
let contents = try fileManager.contentsOfDirectory(
at: artistURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)
albumURLs = contents.filter { url in
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
}
} catch {
continue
}

for albumURL in albumURLs {
result.append(Album(
title: albumURL.lastPathComponent,
artist: artistName,
artwork: extractArtworkFromFirstTrack(in: albumURL),
albumURL: albumURL
))
}
}

return result.sorted { ($0.artist, $0.title) < ($1.artist, $1.title) }
}






By using a non-recursive scan of the directory structure instead of metadata deep-dives, the app finally reflected the vibe of the actual library. It’s a reminder that sometimes the simplest architecture—the folder tree—is more robust than the most modern API.






Making Android Feel Native



A huge part of the session was dedicated to the handshake between macOS and Android. Making ADB feel like a native Mac service requires some plumbing. We built a ConnectedDeviceChecker that polls for devices every two seconds via ADB.



The checker publishes a @Published deviceStatus (ADBDeviceStatus) for the window’s status line; isDeviceConnected is simply deviceStatus == .oneDevice. The Sync button is context-aware: it only turns on when exactly one authorized device shows up in adb devices—because with two targets (say, a phone and an emulator), which device? is ambiguous until we teach the app to choose.




CODE
enum ADBDeviceStatus: Sendable {
case adbNotSet
case adbNotAccessible
case noDevice
case oneDevice
case multipleDevices

var statusMessage: String {
switch self {
case .adbNotSet: return "ADB not configured"
case .adbNotAccessible: return "ADB not found or not accessible"
case .noDevice: return "No device connected"
case .oneDevice: return "One device connected"
case .multipleDevices: return "More than one device connected"
}
}
}

/// Polls `adb devices` every 2s; updates `deviceStatus` from bookmarked `store.adbURL`.
final class ConnectedDeviceChecker: ObservableObject {
@Published private(set) var deviceStatus: ADBDeviceStatus = .adbNotSet
var isDeviceConnected: Bool { deviceStatus == .oneDevice }

private weak var store: MusicFolderStore?
private var timer: Timer?
private let queue = DispatchQueue(label: "com.example.MarvinSync.adbCheck", qos: .utility)

init(store: MusicFolderStore) {
self.store = store
timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
self?.check()
}
RunLoop.main.add(timer!, forMode: .common)
check()
}

deinit { timer?.invalidate() }

private func check() {
guard let store = store else {
DispatchQueue.main.async { [weak self] in self?.deviceStatus = .adbNotSet }
return
}
guard let adbURL = store.adbURL else {
DispatchQueue.main.async { [weak self] in self?.deviceStatus = .adbNotSet }
return
}
queue.async { [weak self] in
let status = Self.runADBDevices(executableURL: adbURL)
DispatchQueue.main.async { self?.deviceStatus = status }
}
}

private static func runADBDevices(executableURL: URL) -> ADBDeviceStatus {
let process = Process()
process.executableURL = executableURL
process.arguments = ["devices"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
} catch {
return .adbNotAccessible
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
pipe.fileHandleForReading.closeFile()
guard let output = String(data: data, encoding: .utf8) else { return .adbNotAccessible }
let n = output.components(separatedBy: .newlines).filter { $0.contains("\tdevice") }.count
switch n {
case 0: return .noDevice
case 1: return .oneDevice
default: return .multipleDevices
}
}
}






Because MarvinSync is a well-behaved macOS citizen, it respects app sandboxing. However, a sandboxed app can't remember a file path after a reboot without help. To solve this, we implemented security-scoped bookmarks. This ensures that once you grant permission to access your Music folder (and separately the adb binary, often hiding under ~/Library), the app can resolve that permission on the next launch.




CODE
// Same pattern as `MusicFolderStore`: two bookmarks, two keys.
private enum Defaults {
static let musicFolderBookmarkKey = "musicFolderBookmark"
static let adbBookmarkKey = "adbBookmark"
}

private func saveBookmark(for url: URL) throws {
let data = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
UserDefaults.standard.set(data, forKey: Defaults.musicFolderBookmarkKey)
}

private func saveADBBookmark(for url: URL) throws {
let data = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
UserDefaults.standard.set(data, forKey: Defaults.adbBookmarkKey)
}

/// On startup: load `Data`, resolve, call `startAccessingSecurityScopedResource()`.
/// MarvinSync then checks `isStale` and, if needed, calls `saveBookmark` or `saveADBBookmark` again for that URL.
private func resolvedURL(bookmarkKey: String) -> URL? {
guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { return nil }
var isStale = false
guard let url = try? URL(
resolvingBookmarkData: data,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
) else { return nil }
guard url.startAccessingSecurityScopedResource() else { return nil }
return url
}






Sync itself grew into a small pipeline: verify the Android base path, remove ignored albums on the device with carefully validated paths and shell-safe quoting (parentheses in folder names bite), then incremental adb push with size checks so we only transfer files that are missing or changed. All of that is more interesting in the repo than in a short essay—but it’s the kind of boring engineering AI accelerates when you already know what must be true.






What the code fences don’t show (but the session did)



The snippets above are the architectural spine. The rest of the Cursor log is mostly product and UX: a sectioned A–Z grid; tap an album to include or exclude it for sync, with a green checkmark when it will be copied and ignored paths persisted as relatives of the music folder. Sync opens a sheet with per-step spinners, checkmarks, and failures—errors stay on the row that broke, not in a separate alert. Removal runs only for ignored folders that still exist on the device; copy steps appear only when a size comparison shows something missing or stale, with human-readable labels (Album (Artist)) instead of raw paths. Cancel stops the pipeline; a “Checking …” line covers the otherwise silent “do we need to copy?” work. Fixing sheet height so the window stopped juddering as rows appeared took the same stubbornness as the Settings form. None of that required a fourth code listing for this post—the Codeberg tree is the ground truth—but it deserved ink here so the story matches the full chat, not only the plumbing.






The Friction of Vibe-Coding



People often ask if AI makes coding effortless. The chat log tells a different story. Cursor is excellent at bulk work and at iterating when you give crisp specs—but SwiftUI Form on macOS still caught me in a loop of almost-right layouts: labels, value columns, and path text fields that looked centered or pushed to the wrong edge no matter how many HIG-aligned refactors we tried.



At one point the chat reads like an argument, not a pull request. I wrote things I would not put in a colleague’s review—frustration that went personal (What is wrong with you?, and worse). The punchline is not that the model deserved it; it doesn’t have feelings or pride. The punchline is that I still needed to vent, then to stop accepting close enough. The fix, when it came, was almost embarrassingly small: in our case, making the Android base path TextField behave in the form the way Apple intends—.labelsHidden() so the field wasn’t fighting an implicit label column—and refusing another round of decorative layout hacks.



That friction is the human bit of AI-assisted development: not mistaking the chat for a person, but not mistaking plausible for shipped either. The best sessions, this one included, end with you back in control—exact snippet in hand, build green, behavior finally matching the picture in your head.






What’s Next?



The grid, sync sheet, and guardrails sketched above are in the repo; I even handcrafted the app icon myself (no AI involved there!).



As I mentioned in my February posts, the binaries are coming. But I wanted to document this journey first. Building MarvinSync hasn't just been about creating a tool I needed; it’s been an experiment in how a veteran Android dev can use AI to build natively for the Mac.



The source is open, the vibe is set, and soon, it'll be time to sync.

Vollständiger Original-Artikel
Den kompletten Beitrag mit allen Details direkt auf dev.to lesen.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
Staatliche Hacker treiben laut Chainalysis einen Anstieg von 420 % bei Onchain-Malware voran
1 Quelle
Chinesische KI-Modelle verursachen Anstieg der auf der Blockchain platzierten Malware ...
1 Quelle
Millionen-Lösegeld nach Hacker-Angriff auf Revolut: Was Kunden jetzt wissen müssen
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten From Vibe-Coding to Reality: Building MarvinSync

Thematisch verwandte Begriffe: From, VibeCoding, Reality, Building · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...