DEV Community

Cover image for From Vibe-Coding to Reality: Building MarvinSync
Thomas Künneth
Thomas Künneth

Posted on

From Vibe-Coding to Reality: Building MarvinSync

If you saw my posts back in February on LinkedIn and Mastodon, you know I’ve been deep in a Cursor session. I promised to pull back the curtain on how MarvinSync—my new macOS utility for syncing local music to Android—came to life through the lens of AI-assisted development.

Before we get to the binaries (which are coming soon, I promise!), I want to share the vibe-coding post-mortem of how the first version actually took shape. If you want to follow along with the code as I describe it, the full project is already live on Codeberg.

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.

/// 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) }
}
Enter fullscreen mode Exit fullscreen mode

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.

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
jill_builds_apps profile image
Jill Mercer

vibe coding is the only way i build these days. starting in cursor makes the initial setup feel like magic — but moving from the vibe to a finished product is where the real work starts. i'm currently fighting some mobile animations on my tracker that keep janking on older android devices. seeing marvinsync move from a concept to reality is exactly the push i needed. austin taught me: just start the thing.