@@ -37,8 +37,19 @@ enum WizardPanel: Int {
3737
3838enum PatchError : LocalizedError {
3939 case msg( String )
40+ /// User-prerequisite failure (e.g. Xcode CLT not installed, license not accepted).
41+ /// Surfaces to the user normally but does NOT report to Sentry — these are
42+ /// expected setup steps, not SpliceKit bugs.
43+ case userPrereq( String )
4044 var errorDescription : String ? {
41- switch self { case . msg( let s) : return s }
45+ switch self {
46+ case . msg( let s) : return s
47+ case . userPrereq( let s) : return s
48+ }
49+ }
50+ var isUserPrereq : Bool {
51+ if case . userPrereq = self { return true }
52+ return false
4253 }
4354}
4455
@@ -325,7 +336,7 @@ class PatcherModel: ObservableObject {
325336 guard !selectedPath. isEmpty else {
326337 await logAsync ( " Xcode Command Line Tools not found. Installing... " )
327338 shell ( " xcode-select --install 2>/dev/null " )
328- throw PatchError . msg ( """
339+ throw PatchError . userPrereq ( """
329340 Xcode Command Line Tools are required.
330341
331342 An installer window should have appeared. Please complete the installation, then click Retry.
@@ -336,15 +347,15 @@ class PatcherModel: ObservableObject {
336347 guard otoolCheck. status == 0 else {
337348 let output = otoolCheck. output. trimmingCharacters ( in: . whitespacesAndNewlines)
338349 if output. localizedCaseInsensitiveContains ( " license " ) {
339- throw PatchError . msg ( """
350+ throw PatchError . userPrereq ( """
340351 Xcode Command Line Tools are installed, but the Xcode license has not been accepted.
341352
342353 Please run this once in Terminal, then retry:
343354 sudo xcodebuild -license
344355 """ )
345356 }
346357 shell ( " xcode-select --install 2>/dev/null " )
347- throw PatchError . msg ( """
358+ throw PatchError . userPrereq ( """
348359 Xcode Command Line Tools are installed, but required developer tools are not available.
349360
350361 Please complete or repair the Command Line Tools installation, then click Retry.
@@ -360,7 +371,7 @@ class PatcherModel: ObservableObject {
360371 let clangCheck = shellResult ( " xcrun clang --version 2>&1 " )
361372 guard clangCheck. status == 0 else {
362373 if clangCheck. output. localizedCaseInsensitiveContains ( " license " ) {
363- throw PatchError . msg ( """
374+ throw PatchError . userPrereq ( """
364375 Xcode Command Line Tools are installed, but the Xcode license has not been accepted.
365376
366377 Please run this once in Terminal, then retry:
@@ -604,9 +615,15 @@ class PatcherModel: ObservableObject {
604615 await MainActor . run {
605616 self . errorMessage = error. localizedDescription
606617 self . appendLog ( " ERROR: \( error. localizedDescription) " )
607- PatcherSentry . capture ( error: error,
608- context: " patch.run " ,
609- extras: [ " current_step " : self . currentStep? . rawValue ?? " unknown " ] )
618+ if ( error as? PatchError ) ? . isUserPrereq == true {
619+ // User prerequisite (e.g. Xcode CLT) — surfaced to the user
620+ // but not a SpliceKit bug, so don't pollute Sentry.
621+ self . appendLog ( " Skipping Sentry report: user-prerequisite failure. " )
622+ } else {
623+ PatcherSentry . capture ( error: error,
624+ context: " patch.run " ,
625+ extras: [ " current_step " : self . currentStep? . rawValue ?? " unknown " ] )
626+ }
610627 }
611628 }
612629 await MainActor . run {
@@ -877,14 +894,24 @@ class PatcherModel: ObservableObject {
877894 // Step 4: Create macOS framework bundle (Versions/A + symlinks)
878895 await setStepAsync ( . installFramework)
879896 let fwDir = moddedApp + " /Contents/Frameworks/SpliceKit.framework "
897+ let resourcesDir = fwDir + " /Versions/A/Resources "
880898 shell ( """
881899 rm -rf ' \( fwDir) '
882- mkdir -p ' \( fwDir ) /Versions/A/Resources '
900+ mkdir -p ' \( resourcesDir ) '
883901 cp ' \( buildDir) /SpliceKit' ' \( fwDir) /Versions/A/SpliceKit'
884902 cd ' \( fwDir) /Versions' && ln -sfn A Current
885903 cd ' \( fwDir) ' && ln -sfn Versions/Current/SpliceKit SpliceKit
886904 cd ' \( fwDir) ' && ln -sfn Versions/Current/Resources Resources
887905 """ )
906+ // Belt-and-suspenders: shell mkdir occasionally fails silently (sandbox,
907+ // permissions, weird path), and the plist.write below then fails with
908+ // NSCocoaErrorDomain Code 4 (no such file or directory) — see APPLE-MACOS-18.
909+ // Re-create with FileManager so we get a clear, throwable error if it
910+ // genuinely can't be created.
911+ if !FileManager. default. fileExists ( atPath: resourcesDir) {
912+ try FileManager . default. createDirectory ( atPath: resourcesDir,
913+ withIntermediateDirectories: true )
914+ }
888915 let currentVersion = currentSpliceKitVersion ( )
889916 let patcherVersion = currentVersion. isEmpty ? " 0.0.0 " : currentVersion
890917 let plist = """
@@ -899,9 +926,9 @@ class PatcherModel: ObservableObject {
899926 <key>CFBundleExecutable</key><string>SpliceKit</string>
900927 </dict></plist>
901928 """
902- try plist. write ( toFile: fwDir + " /Versions/A/Resources /Info.plist" , atomically: true , encoding: . utf8)
929+ try plist. write ( toFile: resourcesDir + " /Info.plist " , atomically: true , encoding: . utf8)
903930 if let sentryConfig = Bundle . main. url ( forResource: " SpliceKitSentryConfig " , withExtension: " plist " ) {
904- shell ( " cp ' \( sentryConfig. path) ' ' \( fwDir ) /Versions/A/Resources /SpliceKitSentryConfig.plist'" )
931+ shell ( " cp ' \( sentryConfig. path) ' ' \( resourcesDir ) /SpliceKitSentryConfig.plist' " )
905932 }
906933
907934 // Install BRAW plugin bundles into FCP.app/Contents/PlugIns/
@@ -1145,14 +1172,19 @@ class PatcherModel: ObservableObject {
11451172 // Install framework (overwrite existing binary)
11461173 await setStepAsync ( . installFramework)
11471174 let fwDir = moddedApp + " /Contents/Frameworks/SpliceKit.framework "
1175+ let resourcesDir = fwDir + " /Versions/A/Resources "
11481176 shell ( """
11491177 rm -rf ' \( fwDir) '
1150- mkdir -p ' \( fwDir ) /Versions/A/Resources '
1178+ mkdir -p ' \( resourcesDir ) '
11511179 cp ' \( buildDir) /SpliceKit' ' \( fwDir) /Versions/A/SpliceKit'
11521180 cd ' \( fwDir) /Versions' && ln -sfn A Current
11531181 cd ' \( fwDir) ' && ln -sfn Versions/Current/SpliceKit SpliceKit
11541182 cd ' \( fwDir) ' && ln -sfn Versions/Current/Resources Resources
11551183 """ )
1184+ if !FileManager. default. fileExists ( atPath: resourcesDir) {
1185+ try FileManager . default. createDirectory ( atPath: resourcesDir,
1186+ withIntermediateDirectories: true )
1187+ }
11561188
11571189 let currentVersion = currentSpliceKitVersion ( )
11581190 let patcherVersion = currentVersion. isEmpty ? " 0.0.0 " : currentVersion
@@ -1168,9 +1200,9 @@ class PatcherModel: ObservableObject {
11681200 <key>CFBundleExecutable</key><string>SpliceKit</string>
11691201 </dict></plist>
11701202 """
1171- try plist. write ( toFile: fwDir + " /Versions/A/Resources /Info.plist" , atomically: true , encoding: . utf8)
1203+ try plist. write ( toFile: resourcesDir + " /Info.plist " , atomically: true , encoding: . utf8)
11721204 if let sentryConfig = Bundle . main. url ( forResource: " SpliceKitSentryConfig " , withExtension: " plist " ) {
1173- shell ( " cp ' \( sentryConfig. path) ' ' \( fwDir ) /Versions/A/Resources /SpliceKitSentryConfig.plist'" )
1205+ shell ( " cp ' \( sentryConfig. path) ' ' \( resourcesDir ) /SpliceKitSentryConfig.plist' " )
11741206 }
11751207
11761208 // Refresh BRAW plugin bundles on upgrade. Same reasoning as fresh
@@ -1530,24 +1562,45 @@ class PatcherModel: ObservableObject {
15301562 appendLog ( String ( format: " Final Cut Pro process %d terminated after %.1fs " ,
15311563 app. processIdentifier, runtime) )
15321564 if runtime < 90 {
1533- for line in launchDiagnostics (
1565+ let diagnostics = launchDiagnostics (
15341566 launchTime: launchTime,
15351567 spliceKitLogDateBeforeLaunch: spliceKitLogDateBeforeLaunch
1536- ) {
1568+ )
1569+ for line in diagnostics {
15371570 appendLog ( line)
15381571 }
1539- PatcherSentry . captureMessage (
1540- " Final Cut Pro terminated shortly after launch " ,
1541- level: . error,
1542- context: " patcher.launch_termination " ,
1543- extras: [
1544- " runtime_seconds " : runtime,
1545- " diagnostics " : launchDiagnostics (
1546- launchTime: launchTime,
1547- spliceKitLogDateBeforeLaunch: spliceKitLogDateBeforeLaunch
1548- )
1549- ]
1550- )
1572+
1573+ // Only report to Sentry if there's an actual failure signal. A user
1574+ // quitting FCP within 90s after launching it from the patcher is
1575+ // normal and should not produce a crash event. Real signals:
1576+ // 1. A FCP crash report newer than launchTime exists.
1577+ // 2. The injected dylib never initialized (splicekit.log unchanged).
1578+ // 3. Runtime was extremely short (< 5s) — likely instant exit.
1579+ let hasCrashReport = latestCrashReportURL ( after: launchTime) != nil
1580+ let dylibNeverLoaded = diagnostics. contains {
1581+ $0. contains ( " dylib likely never initialized " )
1582+ }
1583+ let cleanShutdown = diagnostics. contains {
1584+ $0. contains ( " App terminating " ) || $0. contains ( " Server socket closed " )
1585+ }
1586+ let abnormal = hasCrashReport || dylibNeverLoaded || ( runtime < 5 && !cleanShutdown)
1587+
1588+ if abnormal {
1589+ PatcherSentry . captureMessage (
1590+ " Final Cut Pro terminated shortly after launch " ,
1591+ level: . error,
1592+ context: " patcher.launch_termination " ,
1593+ extras: [
1594+ " runtime_seconds " : runtime,
1595+ " has_crash_report " : hasCrashReport,
1596+ " dylib_never_loaded " : dylibNeverLoaded,
1597+ " clean_shutdown " : cleanShutdown,
1598+ " diagnostics " : diagnostics
1599+ ]
1600+ )
1601+ } else {
1602+ appendLog ( " Skipping Sentry report: no crash report and dylib initialized cleanly (likely user-initiated quit). " )
1603+ }
15511604 }
15521605 if launchedFCPRunningApp? . processIdentifier == app. processIdentifier {
15531606 launchedFCPRunningApp = nil
0 commit comments