| 1 | import AppKit |
| 2 | |
| 3 | /// Document controller that reliably opens `.sq` project *packages*. |
| 4 | /// |
| 5 | /// A `.sq` is a directory bundle. The app exports a UTType conforming to |
| 6 | /// `com.apple.package`, but LaunchServices doesn't always know about it (a dev |
| 7 | /// build rebuilt in place, or another app claiming `.sq`). When it doesn't, the |
| 8 | /// system types a `.sq` as a plain `public.folder`: the stock Open panel won't |
| 9 | /// let you choose it, and even if you could, `NSDocumentController` fails with |
| 10 | /// "cannot open files in the folder format" because no document handles a |
| 11 | /// folder. We fix both ends: run our own Open panel that makes `.sq` choosable, |
| 12 | /// and force the document type for any `.sq` URL — regardless of what |
| 13 | /// LaunchServices believes — so opening always resolves to `ProjectDocument`. |
| 14 | final class ProjectDocumentController: NSDocumentController { |
| 15 | private static let projectType = "net.paperclover.sequencer.project" |
| 16 | private let sqPanelDelegate = SQOpenPanelDelegate() |
| 17 | |
| 18 | /// Pin the document type for `.sq` URLs so it never resolves to a folder, |
| 19 | /// whatever LaunchServices thinks. Covers the Open panel, Open Recent, and |
| 20 | /// drag-drop paths alike (they all route through here). |
| 21 | override func typeForContents(of url: URL) throws -> String { |
| 22 | if url.pathExtension.lowercased() == "sq" { return Self.projectType } |
| 23 | return try super.typeForContents(of: url) |
| 24 | } |
| 25 | |
| 26 | /// Drive the Open panel ourselves so a `.sq` bundle is always selectable — |
| 27 | /// the stock panel greys it out when the type reads as a plain folder. |
| 28 | override func openDocument(_ sender: Any?) { |
| 29 | let panel = NSOpenPanel() |
| 30 | panel.canChooseFiles = true |
| 31 | panel.canChooseDirectories = true // a `.sq` may read as a folder |
| 32 | panel.allowsMultipleSelection = true |
| 33 | panel.treatsFilePackagesAsDirectories = false |
| 34 | panel.delegate = sqPanelDelegate |
| 35 | panel.begin { [weak self] response in |
| 36 | guard response == .OK, let self else { return } |
| 37 | for url in panel.urls { |
| 38 | self.openDocument(withContentsOf: url, display: true) { _, _, error in |
| 39 | if let error { self.presentError(error) } |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /// Opening a project from a pristine untitled window replaces that window |
| 46 | /// instead of leaving an empty one behind. |
| 47 | override func openDocument(withContentsOf url: URL, display displayDocument: Bool, |
| 48 | completionHandler: @escaping (NSDocument?, Bool, Error?) -> Void) { |
| 49 | let blanks = documents.compactMap { $0 as? ProjectDocument }.filter(\.isPristineUntitled) |
| 50 | super.openDocument(withContentsOf: url, display: displayDocument) { doc, alreadyOpen, error in |
| 51 | if doc != nil, error == nil { |
| 52 | for blank in blanks where blank.isPristineUntitled && blank !== doc { |
| 53 | blank.close() |
| 54 | } |
| 55 | } |
| 56 | completionHandler(doc, alreadyOpen, error) |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | /// Enables only `.sq` items (package directories or legacy flat files) in the |
| 62 | /// Open panel; other folders remain navigable but not choosable. |
| 63 | final class SQOpenPanelDelegate: NSObject, NSOpenSavePanelDelegate { |
| 64 | func panel(_ sender: Any, shouldEnable url: URL) -> Bool { |
| 65 | url.pathExtension.lowercased() == "sq" |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// One open `.sq` project. The on-disk format is a **document package** (a |
| 70 | /// directory Finder shows as one file): |
| 71 | /// ``` |
| 72 | /// MyProject.sq/ |
| 73 | /// ├─ project.json (the SequencerDocument envelope: model + view state) |
| 74 | /// └─ Storyboard/NN.png (per-panel drawing layers, by storyboard order) |
| 75 | /// ``` |
| 76 | /// Legacy flat `.sq` JSON files (with a sibling `Storyboard/` folder) still |
| 77 | /// open; the first save rewrites them as a package. |
| 78 | final class ProjectDocument: NSDocument { |
| 79 | let ctx = DocumentContext() |
| 80 | |
| 81 | override init() { |
| 82 | super.init() |
| 83 | ctx.document = self |
| 84 | } |
| 85 | |
| 86 | /// Autosave in place: silent background saves, Versions, and crash recovery, |
| 87 | /// and the standard "save where?" prompt only on an untitled document's |
| 88 | /// first explicit save. |
| 89 | override class var autosavesInPlace: Bool { true } |
| 90 | |
| 91 | /// Never saved, never edited, and nothing on the timeline — safe to close |
| 92 | /// when a real project opens over it. |
| 93 | var isPristineUntitled: Bool { |
| 94 | fileURL == nil && !isDocumentEdited |
| 95 | && ctx.store.project.media.isEmpty && ctx.store.project.clips.isEmpty |
| 96 | } |
| 97 | |
| 98 | /// Stop per-document services before AppKit tears the document down, so a |
| 99 | /// proxy build or the playback clock finishing after close can't touch the |
| 100 | /// now-dangling `unowned` context. |
| 101 | override func close() { |
| 102 | ctx.shutdown() |
| 103 | super.close() |
| 104 | } |
| 105 | |
| 106 | override func makeWindowControllers() { |
| 107 | let wc = SequencerWindowController(ctx: ctx) |
| 108 | addWindowController(wc) |
| 109 | // A brand-new untitled document starts with one empty track. |
| 110 | if fileURL == nil, ctx.store.project.tracks.isEmpty { |
| 111 | ctx.store.adopt(ProjectModel()) |
| 112 | } |
| 113 | wc.startDocumentServices() |
| 114 | } |
| 115 | |
| 116 | // MARK: - Read |
| 117 | |
| 118 | override func read(from url: URL, ofType typeName: String) throws { |
| 119 | let fm = FileManager.default |
| 120 | var isDir: ObjCBool = false |
| 121 | fm.fileExists(atPath: url.path, isDirectory: &isDir) |
| 122 | let jsonURL = isDir.boolValue ? url.appendingPathComponent("project.json") : url |
| 123 | let data = try Data(contentsOf: jsonURL) |
| 124 | let doc = try JSONDecoder().decode(SequencerDocument.self, from: data) |
| 125 | // Restore portable view state BEFORE adopting the model: adopt posts |
| 126 | // .projectChanged, which reconciles hide/focus against the live tracks. |
| 127 | ctx.session.apply(doc.view) |
| 128 | ctx.store.adopt(doc.project) |
| 129 | // Rasters live in the package's Storyboard/ dir; for a legacy flat file |
| 130 | // fall back to the sibling folder (best effort — its ordinals were |
| 131 | // shared across projects, see the migration note in the plan). |
| 132 | let storyboardDir = isDir.boolValue |
| 133 | ? url.appendingPathComponent("Storyboard", isDirectory: true) |
| 134 | : url.deletingLastPathComponent().appendingPathComponent("Storyboard", isDirectory: true) |
| 135 | ctx.boards.loadRasters(fromDirectory: storyboardDir, project: ctx.store.project) |
| 136 | } |
| 137 | |
| 138 | // MARK: - Write (document package) |
| 139 | |
| 140 | override func fileWrapper(ofType typeName: String) throws -> FileWrapper { |
| 141 | let enc = JSONEncoder() |
| 142 | enc.outputFormatting = [.prettyPrinted, .sortedKeys] |
| 143 | let envelope = SequencerDocument(project: ctx.store.project, |
| 144 | view: ctx.session.captureViewState()) |
| 145 | let json = try enc.encode(envelope) |
| 146 | let root = FileWrapper(directoryWithFileWrappers: [ |
| 147 | "project.json": FileWrapper(regularFileWithContents: json), |
| 148 | ]) |
| 149 | let pngs = ctx.boards.rasterPNGs(of: ctx.store.project) |
| 150 | if !pngs.isEmpty { |
| 151 | var wrappers: [String: FileWrapper] = [:] |
| 152 | for (name, data) in pngs { |
| 153 | wrappers[name] = FileWrapper(regularFileWithContents: data) |
| 154 | } |
| 155 | let storyboard = FileWrapper(directoryWithFileWrappers: wrappers) |
| 156 | storyboard.preferredFilename = "Storyboard" |
| 157 | root.addFileWrapper(storyboard) |
| 158 | } |
| 159 | return root |
| 160 | } |
| 161 | } |