1import 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`.
14final 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.
63final 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.
78final 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}