Skip to content

Commit c2ccc3f

Browse files
committed
feat: add secure native SponsorBlock settings import and export
1 parent 3583414 commit c2ccc3f

23 files changed

Lines changed: 523 additions & 3 deletions

scripts/run-ci-tests.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ compile_direct_test cloud-timestamp-formatter \
128128
compile_core_test cloud-upload-coordinator scripts/test_cloud_sync_upload_coordinator.swift
129129
compile_core_test dark-reader-appearance scripts/test_dark_reader_appearance_preference.swift
130130
compile_core_test tube-cleaner-dearrow scripts/test_tube_cleaner_dearrow_preference.swift
131+
compile_core_test sponsorblock-settings-transfer scripts/test_sponsorblock_settings_transfer.swift
131132
compile_core_test disabled-sites-normalization scripts/test_disabled_sites_normalization.swift
132133
compile_core_test filter-list-flags scripts/test_filter_list_flags.swift
133134
compile_direct_test filter-update-popup-status \
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import Foundation
2+
@testable import wBlockCoreService
3+
4+
private func require(_ value: Bool) { precondition(value) }
5+
6+
@main
7+
struct SponsorBlockSettingsTransferTests {
8+
static func main() async throws {
9+
typealias Transfer = SponsorBlockSettingsTransfer
10+
let source = Data(#"{"userID":"PRIVATE-DO-NOT-COPY","payments":{"licenseKey":"LICENSE-SECRET"},"categorySelections":[{"name":"sponsor","option":2},{"name":"intro","option":1},{"name":"outro","option":0},{"name":"poi_highlight","option":1}],"disableSkipping":false,"dontShowNotice":true,"minDuration":3.5,"whitelistedChannels":["UC-example"]}"#.utf8)
11+
let settings = try Transfer.parse(source)
12+
require(settings.enabled && !settings.showNotice && settings.minimumDuration == 3.5)
13+
require(settings.modes["sponsor"] == "auto" && settings.modes["intro"] == "ask")
14+
require(settings.modes["outro"] == "off" && settings.modes["filler"] == "off")
15+
require(settings.modes["poi_highlight"] == nil && settings.excludedChannels == ["UC-example"])
16+
let exported = try Transfer.exportData(settings)
17+
let text = String(decoding: exported, as: UTF8.self)
18+
require(!text.contains("PRIVATE") && !text.contains("LICENSE") && !text.contains("userID"))
19+
require(try Transfer.parse(exported) == settings)
20+
let modern = try Transfer.parse(Data(#"{"categorySelections":[]}"#.utf8), current: settings)
21+
require(modern.modes.values.allSatisfy { $0 == "off" })
22+
require(modern.excludedChannels == settings.excludedChannels)
23+
print("PASS: upstream mapping, omitted categories, native round-trip and credential stripping")
24+
25+
for invalid in [
26+
#"{"userID":"SECRET"}"#, #"{"debug":{},"config":{"categorySelections":[]}}"#,
27+
#"{"categorySelections":[],"minDuration":-1}"#,
28+
#"{"categorySelections":[],"minDuration":true}"#,
29+
#"{"categorySelections":[],"disableSkipping":1}"#,
30+
#"{"categorySelections":[],"whitelistedChannels":[42]}"#,
31+
#"{"categorySelections":[{"name":"sponsor","option":9}]}"#,
32+
#"{"categorySelections":[{"name":"sponsor","option":2},{"name":"sponsor","option":1}]}"#,
33+
#"{"format":"wblock-sponsorblock-settings","version":2,"settings":{}}"#,
34+
"[]", "null", "not JSON"
35+
] {
36+
do { _ = try Transfer.parse(Data(invalid.utf8)); fatalError("accepted invalid input") }
37+
catch {}
38+
}
39+
do { _ = try Transfer.parse(Data(repeating: 32, count: Transfer.maximumBytes + 1)); fatalError("accepted oversized input") }
40+
catch {}
41+
print("PASS: malformed, unsupported, mistyped and oversized backups rejected")
42+
43+
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
44+
defer { try? FileManager.default.removeItem(at: directory) }
45+
let storage = UserScriptStorageManager(directoryURL: directory)
46+
let id = UUID()
47+
require(try await Transfer.settings(scriptID: id, storage: storage) == nil)
48+
try await Transfer.persist(settings, scriptID: id, storage: storage)
49+
let snapshot = await storage.snapshot(for: id.uuidString)
50+
let raw = snapshot[Transfer.storageKey]!
51+
require(!raw.contains("PRIVATE") && !raw.contains("LICENSE"))
52+
require(try JSONDecoder().decode(Transfer.Settings.self, from: Data(raw.utf8)) == settings)
53+
let secondProcess = UserScriptStorageManager(directoryURL: directory)
54+
require(try await Transfer.settings(scriptID: id, storage: secondProcess) == settings)
55+
var edited = settings
56+
edited.modes["intro"] = "auto"
57+
let pageJSON = String(decoding: try JSONEncoder().encode(edited), as: UTF8.self)
58+
let result = await secondProcess.setSerializedValue(pageJSON, forKey: Transfer.storageKey, scriptID: id.uuidString)
59+
require(result.ok)
60+
let refreshed = try await Transfer.settings(scriptID: id, storage: storage)!
61+
require(try Transfer.parse(Transfer.exportData(refreshed)) == edited)
62+
var invalid = edited
63+
invalid.minimumDuration = -.infinity
64+
do { try await Transfer.persist(invalid, scriptID: id, storage: storage); fatalError("persisted invalid settings") }
65+
catch {}
66+
require(try await Transfer.settings(scriptID: id, storage: storage) == edited)
67+
print("PASS: native import -> GM snapshot -> player edit -> refreshed native export; failed writes preserve settings")
68+
if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--browser-edit" {
69+
let browserData = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2]))
70+
let browserSettings = try Transfer.validated(JSONDecoder().decode(Transfer.Settings.self, from: browserData))
71+
let write = await storage.setSerializedValue(String(decoding: browserData, as: UTF8.self), forKey: Transfer.storageKey, scriptID: id.uuidString)
72+
require(write.ok && browserSettings.modes["intro"] == "auto")
73+
let native = try await Transfer.settings(scriptID: id, storage: storage)!
74+
require(try Transfer.parse(Transfer.exportData(native)) == browserSettings)
75+
print("PASS: real browser GM message persisted and exported back through native code")
76+
}
77+
if CommandLine.arguments.count == 2 {
78+
// A browser probe can consume the native-produced snapshot verbatim.
79+
try JSONEncoder().encode(snapshot).write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
80+
}
81+
}
82+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import SwiftUI
2+
import UniformTypeIdentifiers
3+
import wBlockCoreService
4+
5+
struct SponsorBlockTransferButton: View {
6+
let scriptID: UUID
7+
@State private var showingTransfer = false
8+
9+
var body: some View {
10+
Button { showingTransfer = true } label: {
11+
Label("SponsorBlock Settings", systemImage: "arrow.up.arrow.down.document")
12+
.font(.caption)
13+
}
14+
.buttonStyle(.borderless)
15+
.sheet(isPresented: $showingTransfer) {
16+
SponsorBlockTransferView(scriptID: scriptID)
17+
}
18+
}
19+
}
20+
21+
private struct SponsorBlockSettingsDocument: FileDocument {
22+
static var readableContentTypes: [UTType] { [.json] }
23+
var data: Data
24+
init(data: Data) { self.data = data }
25+
init(configuration: ReadConfiguration) throws {
26+
data = configuration.file.regularFileContents ?? Data()
27+
}
28+
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
29+
FileWrapper(regularFileWithContents: data)
30+
}
31+
}
32+
33+
private struct SponsorBlockTransferView: View {
34+
let scriptID: UUID
35+
@Environment(\.dismiss) private var dismiss
36+
@State private var importing = false
37+
@State private var exporting = false
38+
@State private var busy = false
39+
@State private var pending: SponsorBlockSettingsTransfer.Settings?
40+
@State private var confirming = false
41+
@State private var document: SponsorBlockSettingsDocument?
42+
@State private var status: LocalizedStringKey = ""
43+
@State private var showingStatus = false
44+
45+
private var form: some View {
46+
Form {
47+
Section {
48+
Text("Transfers category choices, skip notices, minimum segment length, and legacy channel allowlists. Private user IDs, contributions, and per-channel profiles are excluded. Seek-bar-only categories become Disabled.")
49+
Text("Use Tube Cleaner 0.1.36 or later. Reload YouTube after importing; open a video before exporting settings changed in Safari.")
50+
.foregroundStyle(.secondary)
51+
}
52+
Section {
53+
Button("Import SponsorBlock Settings") { importing = true }
54+
Button("Export SponsorBlock Settings") {
55+
busy = true
56+
Task {
57+
defer { busy = false }
58+
do {
59+
guard let settings = try await SponsorBlockSettingsTransfer.settings(scriptID: scriptID) else {
60+
notify("No synced SponsorBlock settings yet. Open YouTube with Tube Cleaner enabled, then try again.")
61+
return
62+
}
63+
document = SponsorBlockSettingsDocument(data: try SponsorBlockSettingsTransfer.exportData(settings))
64+
exporting = true
65+
} catch { transferFailed() }
66+
}
67+
}
68+
}
69+
.disabled(busy)
70+
if busy { ProgressView() }
71+
}
72+
.groupedFormStyleCompat()
73+
}
74+
75+
var body: some View {
76+
Group {
77+
#if os(macOS)
78+
VStack(spacing: 0) {
79+
Text("SponsorBlock Settings")
80+
.font(.headline)
81+
.frame(maxWidth: .infinity, alignment: .leading)
82+
.padding(20)
83+
Divider()
84+
form
85+
Divider()
86+
HStack {
87+
Spacer()
88+
Button("Done") { dismiss() }
89+
.keyboardShortcut(.defaultAction)
90+
.disabled(busy)
91+
}
92+
.padding(20)
93+
}
94+
.frame(width: 520, height: 420)
95+
#else
96+
CompatibleNavigationStack {
97+
form
98+
.navigationTitle("SponsorBlock Settings")
99+
.toolbar {
100+
ToolbarItem(placement: .confirmationAction) {
101+
Button("Done") { dismiss() }.disabled(busy)
102+
}
103+
}
104+
}
105+
#endif
106+
}
107+
.fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { result in
108+
switch result {
109+
case .success(let url):
110+
busy = true
111+
Task {
112+
defer { busy = false }
113+
let scoped = url.startAccessingSecurityScopedResource()
114+
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
115+
do {
116+
// Bound the read itself, not just JSON parsing. Never save
117+
// or log the original file, which may contain a private ID.
118+
let handle = try FileHandle(forReadingFrom: url)
119+
defer { try? handle.close() }
120+
let data = try handle.read(upToCount: SponsorBlockSettingsTransfer.maximumBytes + 1) ?? Data()
121+
let current = try await SponsorBlockSettingsTransfer.settings(scriptID: scriptID)
122+
pending = try SponsorBlockSettingsTransfer.parse(data, current: current ?? .init())
123+
confirming = true
124+
} catch { transferFailed() }
125+
}
126+
case .failure(let error):
127+
if (error as NSError).code != NSUserCancelledError { transferFailed() }
128+
}
129+
}
130+
.fileExporter(isPresented: $exporting, document: document, contentType: .json,
131+
defaultFilename: "wBlock-SponsorBlock-settings") { result in
132+
if case .failure(let error) = result, (error as NSError).code != NSUserCancelledError { transferFailed() }
133+
document = nil
134+
}
135+
.confirmationDialog("Replace SponsorBlock settings?", isPresented: $confirming, titleVisibility: .visible) {
136+
Button("Import") {
137+
guard let settings = pending else { return }
138+
pending = nil
139+
busy = true
140+
Task {
141+
defer { busy = false }
142+
do {
143+
try await SponsorBlockSettingsTransfer.save(settings, scriptID: scriptID)
144+
notify("Settings imported. Reload YouTube to apply them.")
145+
} catch { transferFailed() }
146+
}
147+
}
148+
Button("Cancel", role: .cancel) { pending = nil }
149+
}
150+
.alert("SponsorBlock Settings", isPresented: $showingStatus) {
151+
Button("OK") {}
152+
} message: { Text(status) }
153+
}
154+
155+
private func notify(_ message: LocalizedStringKey) { status = message; showingStatus = true }
156+
private func transferFailed() {
157+
pending = nil
158+
notify("Could not transfer SponsorBlock settings. Use a valid settings JSON file and check available storage.")
159+
}
160+
}

wBlock/UserScriptManagerView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,7 @@ struct UserScriptManagerView: View {
873873
}
874874

875875
if script.isTubeCleaner {
876+
SponsorBlockTransferButton(scriptID: script.id)
876877
TubeCleanerFeaturesPicker(
877878
features: Binding(
878879
get: { userScriptManager.tubeCleanerFeatures },

wBlock/ar.lproj/Localizable.strings

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
"SponsorBlock Settings" = "إعدادات SponsorBlock";
2+
"Import SponsorBlock Settings" = "استيراد إعدادات SponsorBlock";
3+
"Export SponsorBlock Settings" = "تصدير إعدادات SponsorBlock";
4+
"Transfers category choices, skip notices, minimum segment length, and legacy channel allowlists. Private user IDs, contributions, and per-channel profiles are excluded. Seek-bar-only categories become Disabled." = "ينقل الفئات وإشعارات التخطي والحد الأدنى لطول المقطع وقوائم القنوات المسموح بها القديمة. تُستبعد معرّفات المستخدم الخاصة والمساهمات وملفات إعداد القنوات. تُعطّل الفئات المعروضة على شريط التقدم فقط.";
5+
"Use Tube Cleaner 0.1.36 or later. Reload YouTube after importing; open a video before exporting settings changed in Safari." = "استخدم Tube Cleaner 0.1.36 أو أحدث. أعد تحميل YouTube بعد الاستيراد؛ وافتح فيديو قبل تصدير الإعدادات المعدّلة في Safari.";
6+
"No synced SponsorBlock settings yet. Open YouTube with Tube Cleaner enabled, then try again." = "لا توجد إعدادات SponsorBlock متزامنة بعد. افتح YouTube مع تفعيل Tube Cleaner ثم أعد المحاولة.";
7+
"Replace SponsorBlock settings?" = "استبدال إعدادات SponsorBlock؟";
8+
"Settings imported. Reload YouTube to apply them." = "تم استيراد الإعدادات. أعد تحميل YouTube لتطبيقها.";
9+
"Could not transfer SponsorBlock settings. Use a valid settings JSON file and check available storage." = "تعذّر نقل إعدادات SponsorBlock. استخدم ملف إعدادات JSON صالحًا وتحقق من مساحة التخزين المتاحة.";
110
" · Last success: %@" = " · آخر نجاح: %@";
211
" · Scheduled: %@" = " · مجدول: %@";
312
"!#include fetch failed" = "تعذّر جلب !#include";

wBlock/de.lproj/Localizable.strings

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
"SponsorBlock Settings" = "SponsorBlock-Einstellungen";
2+
"Import SponsorBlock Settings" = "SponsorBlock-Einstellungen importieren";
3+
"Export SponsorBlock Settings" = "SponsorBlock-Einstellungen exportieren";
4+
"Transfers category choices, skip notices, minimum segment length, and legacy channel allowlists. Private user IDs, contributions, and per-channel profiles are excluded. Seek-bar-only categories become Disabled." = "Überträgt Kategorien, Überspringhinweise, Mindestsegmentlänge und ältere Kanal-Ausnahmelisten. Private Nutzer-IDs, Beiträge und kanalbezogene Profile sind ausgeschlossen. Kategorien nur für die Zeitleiste werden deaktiviert.";
5+
"Use Tube Cleaner 0.1.36 or later. Reload YouTube after importing; open a video before exporting settings changed in Safari." = "Tube Cleaner 0.1.36 oder neuer verwenden. YouTube nach dem Import neu laden; vor dem Export von in Safari geänderten Einstellungen ein Video öffnen.";
6+
"No synced SponsorBlock settings yet. Open YouTube with Tube Cleaner enabled, then try again." = "Noch keine synchronisierten SponsorBlock-Einstellungen. YouTube mit aktiviertem Tube Cleaner öffnen und erneut versuchen.";
7+
"Replace SponsorBlock settings?" = "SponsorBlock-Einstellungen ersetzen?";
8+
"Settings imported. Reload YouTube to apply them." = "Einstellungen importiert. YouTube neu laden, um sie anzuwenden.";
9+
"Could not transfer SponsorBlock settings. Use a valid settings JSON file and check available storage." = "SponsorBlock-Einstellungen konnten nicht übertragen werden. Eine gültige JSON-Einstellungsdatei verwenden und den verfügbaren Speicher prüfen.";
110
" · Last success: %@" = " · Letzter Erfolg: %@";
211
" · Scheduled: %@" = " · Geplant: %@";
312
"!#include fetch failed" = "!#include-Abruf fehlgeschlagen";

wBlock/el.lproj/Localizable.strings

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
"SponsorBlock Settings" = "Ρυθμίσεις SponsorBlock";
2+
"Import SponsorBlock Settings" = "Εισαγωγή ρυθμίσεων SponsorBlock";
3+
"Export SponsorBlock Settings" = "Εξαγωγή ρυθμίσεων SponsorBlock";
4+
"Transfers category choices, skip notices, minimum segment length, and legacy channel allowlists. Private user IDs, contributions, and per-channel profiles are excluded. Seek-bar-only categories become Disabled." = "Μεταφέρει κατηγορίες, ειδοποιήσεις παράλειψης, ελάχιστη διάρκεια και παλιές λίστες επιτρεπόμενων καναλιών. Εξαιρούνται ιδιωτικά αναγνωριστικά, συνεισφορές και προφίλ καναλιών. Οι κατηγορίες μόνο για τη γραμμή προόδου απενεργοποιούνται.";
5+
"Use Tube Cleaner 0.1.36 or later. Reload YouTube after importing; open a video before exporting settings changed in Safari." = "Χρησιμοποιήστε Tube Cleaner 0.1.36 ή νεότερο. Φορτώστε ξανά το YouTube μετά την εισαγωγή· ανοίξτε βίντεο πριν εξαγάγετε ρυθμίσεις που αλλάξατε στο Safari.";
6+
"No synced SponsorBlock settings yet. Open YouTube with Tube Cleaner enabled, then try again." = "Δεν υπάρχουν συγχρονισμένες ρυθμίσεις SponsorBlock. Ανοίξτε το YouTube με ενεργό Tube Cleaner και δοκιμάστε ξανά.";
7+
"Replace SponsorBlock settings?" = "Αντικατάσταση ρυθμίσεων SponsorBlock;";
8+
"Settings imported. Reload YouTube to apply them." = "Οι ρυθμίσεις εισήχθησαν. Φορτώστε ξανά το YouTube για εφαρμογή.";
9+
"Could not transfer SponsorBlock settings. Use a valid settings JSON file and check available storage." = "Δεν ήταν δυνατή η μεταφορά ρυθμίσεων SponsorBlock. Χρησιμοποιήστε έγκυρο αρχείο JSON και ελέγξτε τον διαθέσιμο χώρο.";
110
" · Last success: %@" = " · Τελευταία επιτυχία: %@";
211
" · Scheduled: %@" = " · Προγραμματισμένο: %@";
312
"!#include fetch failed" = "Αποτυχία λήψης !#include";

0 commit comments

Comments
 (0)