This commit is contained in:
Balazs Perlaki-Horvath 2025-07-09 00:05:05 +02:00 committed by BPH
parent 6af7f7d568
commit f58c2007a8
6 changed files with 196 additions and 133 deletions

View File

@ -16,8 +16,7 @@
import Foundation
import Defaults
@MainActor
final class Hotspot: ObservableObject {
final class Hotspot {
@MainActor
static let shared = Hotspot()
@ -26,36 +25,36 @@ final class Hotspot: ObservableObject {
nonisolated static let defaultPort = 8080
private static let maxPort = 9999
@ZimActor
private var hotspot: KiwixHotspot?
@Published private(set) var isStarted: Bool = false
@Published var selection = MultiSelectedZimFilesViewModel()
@MainActor
@Published var isStarted: Bool = false
@ZimActor
func toggle() async {
if let hotspot {
hotspot.__stop()
self.hotspot = nil
await MainActor.run { self.isStarted = false }
return
} else {
let zimFileIds: Set<UUID> = await MainActor.run(
resultType: Set<UUID>.self,
body: {
Set(selection.selectedZimFiles.map { $0.fileID })
})
guard !zimFileIds.isEmpty else {
debugPrint("no zim files were set for Hotspot to start")
return
}
let portNumber = Int32(Defaults[.hotspotPortNumber])
self.hotspot = KiwixHotspot(__zimFileIds: zimFileIds, onPort: portNumber)
await MainActor.run {
isStarted = true
private var hotspot: KiwixHotspot? {
didSet {
let started = hotspot != nil
Task { @MainActor [weak self] in
self?.isStarted = started
}
}
}
@ZimActor
func startWith(zimFileIds: Set<UUID>) async {
guard hotspot == nil else { return }
guard !zimFileIds.isEmpty else {
debugPrint("no zim files were set for Hotspot to start")
return
}
let portNumber = Int32(Defaults[.hotspotPortNumber])
hotspot = KiwixHotspot(__zimFileIds: zimFileIds, onPort: portNumber)
}
@ZimActor
func stop() async {
guard let hotspot else { return }
hotspot.__stop()
self.hotspot = nil
}
@MainActor
func serverAddress() async -> URL? {
@ -65,14 +64,14 @@ final class Hotspot: ObservableObject {
return URL(string: address)
}
static func isValid(port: Int) -> Bool {
nonisolated static func isValid(port: Int) -> Bool {
switch port {
case minPort...maxPort: return true
default: return false
}
}
static var invalidPortMessage: String {
nonisolated static var invalidPortMessage: String {
LocalString.hotspot_settings_invalid_port_message(withArgs: "\(minPort)", "\(maxPort)")
}
}

View File

@ -0,0 +1,40 @@
// This file is part of Kiwix for iOS & macOS.
//
// Kiwix is free software; you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// any later version.
//
// Kiwix is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
import SwiftUI
struct HotspotAddress: View {
let serverAddress: URL
let qrCodeImage: Image?
var body: some View {
Section(LocalString.hotspot_server_running_title) {
AttributeLink(title: LocalString.hotspot_server_running_address,
destination: serverAddress)
if let qrCodeImage {
qrCodeImage
.resizable()
.frame(width: 250, height: 250)
} else {
ProgressView()
.progressViewStyle(.circular)
.frame(width: 250, height: 250)
}
}
#if os(macOS)
.collapsible(false)
#endif
}
}

View File

@ -18,17 +18,11 @@ import SwiftUI
#if os(macOS)
/// Hotspot multi ZIM files side panel
struct HotspotDetails: View {
let zimFiles: Set<ZimFile>
@State private var serverAddress: URL?
@State private var qrCodeImage: Image?
@ObservedObject private var hotspot = Hotspot.shared
private var buttonTitle: String {
hotspot.isStarted ? LocalString.hotspot_action_stop_server_title : LocalString.hotspot_action_start_server_title
}
let zimFileIds: Set<UUID>
@ObservedObject var hotspot: HotspotObservable
private func zimFilesCount() -> String {
Formatter.number.string(from: NSNumber(value: zimFiles.count)) ?? ""
Formatter.number.string(from: NSNumber(value: zimFileIds.count)) ?? ""
}
var body: some View {
@ -41,52 +35,19 @@ struct HotspotDetails: View {
}
.collapsible(false)
Section(LocalString.zim_file_list_actions_text) {
Action(title: buttonTitle) {
await hotspot.toggle()
Action(title: hotspot.buttonTitle) {
await hotspot.toggleWith(zimFileIds: zimFileIds)
}
.buttonStyle(.borderedProminent)
}
.collapsible(false)
if let serverAddress {
Section(LocalString.hotspot_server_running_title) {
AttributeLink(title: LocalString.hotspot_server_running_address,
destination: serverAddress)
if let qrCodeImage {
qrCodeImage
.resizable()
.frame(width: 250, height: 250)
} else {
ProgressView()
.progressViewStyle(.circular)
.frame(width: 250, height: 250)
}
}
.collapsible(false)
if case .started(let address, let qrCodeImage) = hotspot.state {
HotspotAddress(serverAddress: address, qrCodeImage: qrCodeImage)
}
Section {
Text(LocalString.hotspot_server_explanation)
.font(.subheadline)
.multilineTextAlignment(.leading)
.lineLimit(nil)
}.collapsible(false)
HotspotExplanation()
}
.listStyle(.sidebar)
.onReceive(hotspot.$isStarted) { isStarted in
if isStarted {
Task {
serverAddress = await hotspot.serverAddress()
if let serverAddress {
qrCodeImage = await QRCode.image(from: serverAddress.absoluteString)
} else {
qrCodeImage = nil
}
}
} else {
serverAddress = nil
qrCodeImage = nil
}
}
}
}
#endif

View File

@ -0,0 +1,30 @@
// This file is part of Kiwix for iOS & macOS.
//
// Kiwix is free software; you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// any later version.
//
// Kiwix is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
import SwiftUI
struct HotspotExplanation: View {
var body: some View {
Section {
Text(LocalString.hotspot_server_explanation)
.font(.subheadline)
.multilineTextAlignment(.leading)
.lineLimit(nil)
}
#if os(macOS)
.collapsible(false)
#endif
}
}

View File

@ -0,0 +1,73 @@
// This file is part of Kiwix for iOS & macOS.
//
// Kiwix is free software; you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// any later version.
//
// Kiwix is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
import SwiftUI
import Combine
enum HotspotState {
@MainActor static let selection = MultiSelectedZimFilesViewModel()
case stopped
case started(URL, Image?)
var isStarted: Bool {
switch self {
case .stopped: return false
case .started: return true
}
}
}
@MainActor
final class HotspotObservable: ObservableObject {
@Published var buttonTitle: String = LocalString.hotspot_action_start_server_title
@Published var state: HotspotState = .stopped
private var hotspot = Hotspot.shared
private var cancellables = Set<AnyCancellable>()
init() {
hotspot.$isStarted.sink { [weak self] isStarted in
Task { [weak self] in
await self?.update(isStarted: isStarted)
}
}.store(in: &cancellables)
}
func toggleWith(zimFileIds: Set<UUID>) async {
if hotspot.isStarted {
await hotspot.stop()
} else {
await hotspot.startWith(zimFileIds: zimFileIds)
}
}
private func update(isStarted: Bool) async {
if isStarted {
buttonTitle = LocalString.hotspot_action_stop_server_title
let address = await hotspot.serverAddress()
if let address {
state = .started(address, nil)
let qrCodeImage = await QRCode.image(from: address.absoluteString)
state = .started(address, qrCodeImage)
} else {
state = .stopped
}
} else {
buttonTitle = LocalString.hotspot_action_start_server_title
state = .stopped
}
}
}

View File

@ -24,18 +24,14 @@ struct HotspotZimFilesSelection: View {
predicate: ZimFile.openedPredicate,
animation: .easeInOut
) private var zimFiles: FetchedResults<ZimFile>
@State private var isFileImporterPresented = false
@ObservedObject private var hotspot: Hotspot
@StateObject private var selection: MultiSelectedZimFilesViewModel
#if os(iOS)
@State private var serverAddress: URL?
@State private var qrCodeImage: Image?
#endif
@ObservedObject private var hotspot = HotspotObservable()
init(hotspotProvider: @MainActor () -> Hotspot = { @MainActor in Hotspot.shared }) {
let hotspotInstance = hotspotProvider()
self.hotspot = hotspotInstance
_selection = StateObject(wrappedValue: hotspotInstance.selection)
init(
selectionProvider: @MainActor () -> MultiSelectedZimFilesViewModel = { @MainActor in HotspotState.selection }
) {
let selectionInstance = selectionProvider()
_selection = StateObject(wrappedValue: selectionInstance)
}
var body: some View {
@ -61,7 +57,7 @@ struct HotspotZimFilesSelection: View {
)
}
}
.disabled(hotspot.isStarted)
.disabled(hotspot.state.isStarted)
.modifier(GridCommon(edges: .all))
.modifier(ToolbarRoleBrowser())
.navigationTitle(MenuItem.hotspot.name)
@ -74,61 +70,24 @@ struct HotspotZimFilesSelection: View {
if zimFiles.isEmpty {
Message(text: LocalString.zim_file_opened_overlay_no_opened_message)
}
if let serverAddress {
if case .started(let address, let qrCodeImage) = hotspot.state {
List {
Section(LocalString.hotspot_server_running_title) {
AttributeLink(title: LocalString.hotspot_server_running_address,
destination: serverAddress)
Section {
if let qrCodeImage {
qrCodeImage
.resizable()
.frame(width: 250, height: 250)
} else {
ProgressView()
.progressViewStyle(.circular)
.frame(width: 250, height: 250)
}
}
}
Section {
Text(LocalString.hotspot_server_explanation)
.font(.subheadline)
.multilineTextAlignment(.leading)
.lineLimit(nil)
}
HotspotAddress(serverAddress: address, qrCodeImage: qrCodeImage)
HotspotExplanation()
}
}
}
.onReceive(hotspot.$isStarted) { isStarted in
if isStarted {
Task {
serverAddress = await hotspot.serverAddress()
if let serverAddress {
qrCodeImage = await QRCode.image(from: serverAddress.absoluteString)
} else {
qrCodeImage = nil
}
}
} else {
serverAddress = nil
qrCodeImage = nil
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
AsyncButton {
await hotspot.toggle()
await hotspot.toggleWith(
zimFileIds: Set(selection.selectedZimFiles.map { $0.fileID })
)
} label: {
let text = if hotspot.isStarted {
LocalString.hotspot_action_stop_server_title
} else {
LocalString.hotspot_action_start_server_title
}
Text(text)
Text(hotspot.buttonTitle)
.bold()
}
.disabled(selection.selectedZimFiles.isEmpty && !hotspot.isStarted)
.disabled(selection.selectedZimFiles.isEmpty && !hotspot.state.isStarted)
.padding(.leading, 32)
.modifier(BadgeModifier(count: selection.selectedZimFiles.count))
}
@ -148,7 +107,8 @@ struct HotspotZimFilesSelection: View {
Message(text: LocalString.hotspot_zim_file_selection_message)
.background(.thickMaterial)
default:
HotspotDetails(zimFiles: selection.selectedZimFiles)
HotspotDetails(zimFileIds: Set(selection.selectedZimFiles.map { $0.fileID }),
hotspot: hotspot)
}
}
.frame(width: 275)