Added Module Settings support

This commit is contained in:
cranci1 2026-07-14 16:34:30 +02:00
parent 5abcfc1aba
commit 30785549bd
5 changed files with 413 additions and 8 deletions

View file

@ -0,0 +1,126 @@
//
// ModuleSettings.swift
// Sulfur
//
// Created by Francesco on 14/07/26.
//
import Foundation
struct ModuleSetting: Identifiable, Hashable {
enum SettingType: String, Codable {
case string, bool, int, float
init(rawType: String) {
switch rawType.lowercased() {
case "bool", "boolean":
self = .bool
case "int", "integer":
self = .int
case "float", "double", "number":
self = .float
default:
self = .string
}
}
}
var id: String { key }
let key: String
var value: String
let type: SettingType
let comment: String?
let options: [String]?
init(key: String, value: String, type: SettingType, comment: String? = nil, options: [String]? = nil) {
self.key = key
self.value = value
self.type = type
self.comment = comment
self.options = options
}
}
private struct ModuleSettingSchemaEntry: Codable {
let key: String
let type: String
let comment: String?
let defaultValue: String?
let options: [String]?
enum CodingKeys: String, CodingKey {
case key, type, comment, options
case defaultValue = "default"
}
}
extension ModuleManager {
func hasSettings(_ module: ScrapingModule) -> Bool {
guard let content = try? getModuleContent(module) else { return false }
return !Self.parseSettingsSchema(from: content).isEmpty
}
func getModuleSettings(_ module: ScrapingModule) -> [ModuleSetting] {
guard let content = try? getModuleContent(module) else { return [] }
let schema = Self.parseSettingsSchema(from: content)
let overrides = loadSettingOverrides(for: module)
return schema.map { entry in
let type = ModuleSetting.SettingType(rawValue: entry.type.lowercased()) ?? .string
let storedValue = overrides[entry.key] ?? entry.defaultValue ?? ""
return ModuleSetting(
key: entry.key,
value: storedValue,
type: type,
comment: entry.comment,
options: entry.options
)
}
}
@discardableResult
func updateModuleSettings(_ module: ScrapingModule, settings: [ModuleSetting]) -> Bool {
var overrides: [String: String] = [:]
for setting in settings {
overrides[setting.key] = setting.value
}
guard let data = try? JSONEncoder().encode(overrides) else {
Logger.shared.log("Failed to encode settings for module: \(module.metadata.sourceName)", type: "Error")
return false
}
UserDefaults.standard.set(data, forKey: settingsStorageKey(for: module))
Logger.shared.log("Updated settings for module: \(module.metadata.sourceName)")
return true
}
private func settingsStorageKey(for module: ScrapingModule) -> String {
"moduleSettings_\(module.id.uuidString)"
}
private func loadSettingOverrides(for module: ScrapingModule) -> [String: String] {
guard
let data = UserDefaults.standard.data(forKey: settingsStorageKey(for: module)),
let dict = try? JSONDecoder().decode([String: String].self, from: data)
else { return [:] }
return dict
}
fileprivate static func parseSettingsSchema(from script: String) -> [ModuleSettingSchemaEntry] {
guard
let markerRange = script.range(of: "SETTINGS_SCHEMA:"),
let lineEnd = script[markerRange.upperBound...].firstIndex(of: "\n")
else { return [] }
let jsonString = String(script[markerRange.upperBound..<lineEnd]).trimmingCharacters(in: .whitespaces)
guard let data = jsonString.data(using: .utf8) else { return [] }
do {
return try JSONDecoder().decode([ModuleSettingSchemaEntry].self, from: data)
} catch {
Logger.shared.log("Failed to parse SETTINGS_SCHEMA: \(error.localizedDescription)", type: "Error")
return []
}
}
}

View file

@ -0,0 +1,243 @@
//
// ModuleSettingsView.swift
// Sulfur
//
// Created by Francesco on 14/07/26.
//
import SwiftUI
struct ModuleSettingsView: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var moduleManager: ModuleManager
@Environment(\.colorScheme) var colorScheme
let module: Module
@State private var settings: [ModuleSetting] = []
var body: some View {
ZStack {
LinearGradient(
gradient: Gradient(colors: [
colorScheme == .dark ? Color.black : Color.white,
Color.accentColor.opacity(0.05)
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 0) {
HStack {
Spacer()
Capsule()
.frame(width: 40, height: 5)
.foregroundColor(Color(.systemGray3))
.padding(.top, 10)
Spacer()
}
.padding(.bottom, 8)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(module.metadata.sourceName)
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundColor(colorScheme == .dark ? .white : .black)
Text(NSLocalizedString("Module Settings", comment: ""))
.font(.footnote)
.foregroundColor(colorScheme == .dark ? Color.white.opacity(0.6) : Color.black.opacity(0.5))
}
Spacer()
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
ScrollView(showsIndicators: false) {
if settings.isEmpty {
VStack(spacing: 12) {
Image(systemName: "gearshape")
.font(.largeTitle)
.foregroundColor(.secondary)
Text(NSLocalizedString("No Settings", comment: ""))
.font(.headline)
Text(NSLocalizedString("This module doesn't expose any editable settings.", comment: ""))
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(.vertical, 40)
.frame(maxWidth: .infinity)
} else {
VStack(spacing: 0) {
ForEach($settings) { $setting in
ModuleSettingRow(setting: $setting)
if setting.id != settings.last?.id {
Divider()
.padding(.horizontal, 16)
}
}
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
}
}
.padding(.bottom, 12)
VStack(spacing: 10) {
Button(action: save) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(colorScheme == .dark ? .black : .white)
Text(NSLocalizedString("Save Settings", comment: ""))
}
.font(.headline)
.foregroundColor(colorScheme == .dark ? .black : .white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
LinearGradient(
gradient: Gradient(colors: [
colorScheme == .dark ? Color.white : Color.black,
colorScheme == .dark ? Color.white.opacity(0.9) : Color.black.opacity(0.9)
]),
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 18))
)
.shadow(
color: colorScheme == .dark
? Color.black.opacity(0.3)
: Color.accentColor.opacity(0.25),
radius: 8, x: 0, y: 4
)
.padding(.horizontal, 20)
}
.disabled(settings.isEmpty)
.opacity(settings.isEmpty ? 0.6 : 1)
Button(action: { presentationMode.wrappedValue.dismiss() }) {
Text(NSLocalizedString("Cancel", comment: ""))
.font(.body)
.foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6))
.padding(.vertical, 8)
}
}
.padding(.bottom, 24)
}
}
.onAppear {
settings = moduleManager.getModuleSettings(module)
}
}
private func save() {
moduleManager.updateModuleSettings(module, settings: settings)
DropManager.shared.showDrop(
title: NSLocalizedString("Settings Saved", comment: ""),
subtitle: "",
duration: 1.5,
icon: UIImage(systemName: "checkmark.circle.fill")
)
presentationMode.wrappedValue.dismiss()
}
}
private struct ModuleSettingRow: View {
@Binding var setting: ModuleSetting
private var icon: String {
switch setting.type {
case .bool: return "switch.2"
case .int, .float: return "number"
case .string: return "textformat"
}
}
var body: some View {
HStack(alignment: .top) {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 2) {
Text(setting.comment ?? setting.key)
.foregroundStyle(.primary)
if setting.comment != nil {
Text(setting.key)
.font(.caption2)
.foregroundStyle(.gray)
}
}
Spacer()
control
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
@ViewBuilder
private var control: some View {
if let options = setting.options, !options.isEmpty {
Menu {
ForEach(options, id: \.self) { option in
Button(option) { setting.value = option }
}
} label: {
HStack(spacing: 4) {
Text(setting.value.isEmpty ? NSLocalizedString("Select", comment: "") : setting.value)
.foregroundStyle(.secondary)
.lineLimit(1)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
} else {
switch setting.type {
case .bool:
Toggle("", isOn: Binding(
get: { setting.value.lowercased() == "true" },
set: { setting.value = $0 ? "true" : "false" }
))
.labelsHidden()
.tint(.accentColor.opacity(0.7))
case .int:
TextField("0", text: $setting.value)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 100)
case .float:
TextField("0.0", text: $setting.value)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 100)
case .string:
TextField(NSLocalizedString("Value", comment: ""), text: $setting.value)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 160)
}
}
}
}

View file

@ -101,8 +101,10 @@ fileprivate struct SettingsToggleRow: View {
fileprivate struct ModuleListItemView: View {
let module: Module
let selectedModuleId: String?
let hasSettings: Bool
let onDelete: () -> Void
let onSelect: () -> Void
let onEditSettings: () -> Void
var body: some View {
VStack(spacing: 0) {
@ -148,6 +150,16 @@ fileprivate struct ModuleListItemView: View {
Spacer()
if hasSettings {
Button(action: onEditSettings) {
Image(systemName: "pencil.circle")
.foregroundStyle(.gray)
.frame(width: 20, height: 20)
}
.buttonStyle(PlainButtonStyle())
.padding(.trailing, module.id.uuidString == selectedModuleId ? 12 : 0)
}
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
@ -237,12 +249,16 @@ struct SettingsViewModule: View {
ModuleListItemView(
module: module,
selectedModuleId: selectedModuleId,
hasSettings: moduleManager.hasSettings(module),
onDelete: {
moduleManager.deleteModule(module)
DropManager.shared.showDrop(title: NSLocalizedString("Module Removed", comment: ""), subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash"))
},
onSelect: {
selectedModuleId = module.id.uuidString
},
onEditSettings: {
showModuleSettings(module)
}
)
@ -394,4 +410,17 @@ struct SettingsViewModule: View {
}
}
}
func showModuleSettings(_ module: Module) {
DispatchQueue.main.async {
let settingsView = ModuleSettingsView(module: module)
.environmentObject(self.moduleManager)
let hostingController = UIHostingController(rootView: settingsView)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.rootViewController?.present(hostingController, animated: true, completion: nil)
}
}
}
}

View file

@ -62,6 +62,8 @@
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1202D99951700A0140B /* JSController-Streams.swift */; };
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; };
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; };
132C590F30067E590049A742 /* ModuleSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132C590E30067E590049A742 /* ModuleSettings.swift */; };
132C591130067EAF0049A742 /* ModuleSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132C591030067EAF0049A742 /* ModuleSettingsView.swift */; };
13367ECC2DF70698009CB33F /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 13367ECB2DF70698009CB33F /* Nuke */; };
13367ECE2DF70698009CB33F /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 13367ECD2DF70698009CB33F /* NukeUI */; };
133CF6A62DFEBE9000BD13F9 /* VideoWatchingActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133CF6A32DFEBE8F00BD13F9 /* VideoWatchingActivity.swift */; };
@ -195,6 +197,8 @@
132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = "<group>"; };
132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = "<group>"; };
132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = "<group>"; };
132C590E30067E590049A742 /* ModuleSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleSettings.swift; sourceTree = "<group>"; };
132C591030067EAF0049A742 /* ModuleSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleSettingsView.swift; sourceTree = "<group>"; };
133CF6A32DFEBE8F00BD13F9 /* VideoWatchingActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoWatchingActivity.swift; sourceTree = "<group>"; };
133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; };
133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = "<group>"; };
@ -688,6 +692,8 @@
139935652D468C450065CEFF /* ModuleManager.swift */,
1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */,
133D7C892D2BE2640075467E /* Modules.swift */,
132C590E30067E590049A742 /* ModuleSettings.swift */,
132C591030067EAF0049A742 /* ModuleSettingsView.swift */,
);
path = Modules;
sourceTree = "<group>";
@ -1140,6 +1146,7 @@
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
0414ECFE2E32D6EF00A7E76A /* Bundle+Language.swift in Sources */,
0414ECFF2E32D6EF00A7E76A /* LocalizationManager.swift in Sources */,
132C590F30067E590049A742 /* ModuleSettings.swift in Sources */,
7273F0402E26B19700DF083D /* DownloadPersistence.swift in Sources */,
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */,
@ -1153,6 +1160,7 @@
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */,
13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */,
7260B66D2E32A8CB00365CDA /* OrphanedDownloadsView.swift in Sources */,
132C591130067EAF0049A742 /* ModuleSettingsView.swift in Sources */,
146A48DE2F02F1980017D145 /* IntroDB-FetchIntro.swift in Sources */,
0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */,
0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */,
@ -1465,7 +1473,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\"";
DEVELOPMENT_TEAM = 399LMK6Q2Y;
DEVELOPMENT_TEAM = USF65A4WGS;
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1482,7 +1490,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1507,7 +1515,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\"";
DEVELOPMENT_TEAM = 399LMK6Q2Y;
DEVELOPMENT_TEAM = USF65A4WGS;
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1524,7 +1532,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1574,8 +1582,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/cranci1/SoraCore";
requirement = {
branch = main;
kind = branch;
kind = revision;
revision = 50ddc160fd606e9708adefc1d9fc7003b574446b;
};
};
13637B8E2DE0ECD200BDA2FC /* XCRemoteSwiftPackageReference "Drops" */ = {

View file

@ -32,8 +32,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/cranci1/SoraCore",
"state" : {
"branch" : "main",
"revision" : "543fe1c8c1d421201aeb10e7d2438a91c90c8ac5"
"revision" : "50ddc160fd606e9708adefc1d9fc7003b574446b"
}
}
],