Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion App/HoshiReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import WebKit
struct HoshiReaderApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) private var scenePhase
@State private var userConfig = UserConfig()
@AppStorage("cloudKitStatus") private var cloudKitStatus = CloudKitStatus.none
@State private var userConfig = UserConfig.shared
@State private var pendingImportURL: URL?
@State private var pendingRemoteImportURL: URL?
@State private var pendingLookup: String?
Expand All @@ -28,6 +29,11 @@ struct HoshiReaderApp: App {
WebViewPreloader.shared.warmup()
_ = DictionaryManager.shared
_ = GoogleDriveHandler.shared
if userConfig.enableCloudKitSync {
Task {
await CloudKitSyncManager.shared.initialize()
}
}
configureTabBarAppearance()
}

Expand Down Expand Up @@ -81,6 +87,9 @@ struct HoshiReaderApp: App {
}
shortcutHandler.pendingType = nil
}
.task {
await observeCloudKitEvents()
}
}
}

Expand All @@ -106,6 +115,28 @@ struct HoshiReaderApp: App {
pendingImportURL = url
}
}

private func observeCloudKitEvents() async {
for await event in await CloudKitSyncManager.shared.events() {
if case let .account(accountEvent) = event {
switch accountEvent {
case .signOut:
userConfig.enableCloudKitSync = false
cloudKitStatus = .signOut
case .signIn:
fallthrough
case .accountChanged:
cloudKitStatus = .none
}
} else if case let .error(syncError) = event {
switch syncError {
case .quotaExceeded:
userConfig.enableCloudKitSync = false
cloudKitStatus = .quotaExceeded
}
}
}
}
}

@Observable
Expand Down
70 changes: 62 additions & 8 deletions Core/BookStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
import EPUBKit
import Foundation
import ZIPFoundation
import OSLog

enum FileNames: Sendable {
nonisolated enum FileNames: Sendable {
static let metadata = "metadata.json"
static let bookmark = "bookmark.json"
static let bookinfo = "bookinfo.json"
Expand All @@ -22,7 +23,7 @@ enum FileNames: Sendable {
}

struct BookStorage {
static func getAppDirectory() throws -> URL {
nonisolated static func getAppDirectory() throws -> URL {
guard let url = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
Expand Down Expand Up @@ -154,10 +155,14 @@ struct BookStorage {
}
}

static func getBooksDirectory() throws -> URL {
nonisolated static func getBooksDirectory() throws -> URL {
try getAppDirectory().appendingPathComponent("Books")
}

nonisolated static func getCloudKitSyncDirectory() throws -> URL {
try getAppDirectory().appendingPathComponent("CloudKitSync")
}

@discardableResult
static func copySecurityScopedFile(from fileURL: URL, to destinationPath: String? = nil) throws -> URL {
guard fileURL.startAccessingSecurityScopedResource() else {
Expand Down Expand Up @@ -200,7 +205,7 @@ struct BookStorage {
try FileManager.default.copyItem(at: source, to: destination)
}

static func delete(at url: URL) throws {
nonisolated static func delete(at url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else {
return
}
Expand All @@ -210,14 +215,63 @@ struct BookStorage {
static func save<T: Encodable>(_ object: T, inside directory: URL, as fileName: String) throws {
let targetURL = directory.appendingPathComponent(fileName)

try saveLocal(object, url: targetURL)

saveCloudKitFile(object, url: targetURL)
}

nonisolated static func saveLocal<T: Encodable>(_ object: T, url: URL) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(object)

try data.write(to: targetURL, options: .atomic)
try data.write(to: url, options: .atomic)
}

static func saveCloudKitFile<T: Encodable>(_ object: T, url: URL) {
guard UserConfig.shared.enableCloudKitSync else { return }

if T.self == [BookShelf].self {
Task {
await CloudKitSyncManager.shared.saveCloudShelves()
}
return
}

if T.self == BookMetadata.self {
guard let metadata = object as? BookMetadata else { fatalError() }
Task {
await CloudKitSyncManager.shared.saveCloudFile(
uuid: metadata.id,
fileType: .metadata,
fileName: FileNames.metadata,
folderName: url.deletingLastPathComponent().lastPathComponent,
)
}
return
}

guard let metadata = Self.loadMetadata(root: url.deletingLastPathComponent()) else {
CloudKitSyncManager.logger.error("Failed to load BookMetadata in url \(url)")
return
}
let folderName = url.deletingLastPathComponent().lastPathComponent
let fileName = url.lastPathComponent
guard let fileType = CloudKitFileType(fileName: fileName) else {
CloudKitSyncManager.logger.error("Tried to upload an unknown file type in \(folderName, privacy: .public)/\(fileName, privacy: .public)")
return
}
Task {
await CloudKitSyncManager.shared.saveCloudFile(
uuid: metadata.id,
fileType: fileType,
fileName: fileName,
folderName: folderName,
)
}
}

static func load<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
nonisolated static func load<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)),
let data = try? Data(contentsOf: url) else {
return nil
Expand Down Expand Up @@ -253,11 +307,11 @@ struct BookStorage {
load([Highlight].self, from: root.appendingPathComponent(FileNames.highlights))
}

static func loadShelves() -> [BookShelf]? {
nonisolated static func loadShelves() -> [BookShelf]? {
load([BookShelf].self, from: try! getBooksDirectory().appendingPathComponent(FileNames.shelves))
}

static func loadAllBooks() throws -> [BookMetadata] {
nonisolated static func loadAllBooks() throws -> [BookMetadata] {
let booksDirectory = try getBooksDirectory()

if !FileManager.default.fileExists(atPath: booksDirectory.path(percentEncoded: false)) {
Expand Down
4 changes: 1 addition & 3 deletions Core/DictionaryManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -460,9 +460,7 @@ class DictionaryManager {
return
}

let interval = UserDefaults.standard.string(forKey: "dictionaryUpdateInterval")
.flatMap(DictionaryUpdateInterval.init)?
.timeInterval ?? DictionaryUpdateInterval.weekly.timeInterval
let interval = UserConfig.shared.dictionaryUpdateInterval.timeInterval
let lastUpdate = UserDefaults.standard.object(forKey: "lastDictionaryUpdate") as? Date ?? .distantPast
guard Date().timeIntervalSince(lastUpdate) >= interval else {
return
Expand Down
20 changes: 20 additions & 0 deletions Core/UserConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ enum Themes: String, CaseIterable, Codable {

@Observable
class UserConfig {

static let shared = UserConfig()

var bookshelfSortOption: SortOption {
didSet { UserDefaults.standard.set(bookshelfSortOption.rawValue, forKey: "bookshelfSortOption") }
}
Expand Down Expand Up @@ -142,6 +145,21 @@ class UserConfig {
didSet { UserDefaults.standard.set(syncUploadBooks, forKey: "syncUploadBooks") }
}

var enableCloudKitSync: Bool {
didSet {
UserDefaults.standard.set(enableCloudKitSync, forKey: "enableCloudKitSync")
if enableCloudKitSync {
Task {
await CloudKitSyncManager.shared.initialize()
}
} else {
Task {
await CloudKitSyncManager.shared.disableSync()
}
}
}
}

var theme: Themes {
didSet { UserDefaults.standard.set(theme.rawValue, forKey: "theme") }
}
Expand Down Expand Up @@ -428,6 +446,8 @@ class UserConfig {
self.googleClientId = defaults.object(forKey: "googleClientId") as? String ?? ""
self.syncUploadBooks = defaults.object(forKey: "syncUploadBooks") as? Bool ?? true

self.enableCloudKitSync = defaults.object(forKey: "enableCloudKitSync") as? Bool ?? false

self.theme = defaults.string(forKey: "theme")
.flatMap(Themes.init) ?? .system
self.uiTheme = defaults.string(forKey: "uiTheme")
Expand Down
39 changes: 38 additions & 1 deletion Features/Bookshelf/BookCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct BookCell: View {
@State private var markReadConfirmation = false
@State private var showRenameAlert = false
@State private var renameText = ""
@State private var downloaded = true
let book: BookMetadata
var viewModel: BookshelfViewModel
var currentShelf: String?
Expand Down Expand Up @@ -40,7 +41,18 @@ struct BookCell: View {
onSelect()
}
} label: {
BookView(book: book, progress: viewModel.progress(for: book), isSelected: isSelecting && isSelected)
BookView(book: book, progress: viewModel.progress(for: book), downloaded: downloaded, isSelected: isSelecting && isSelected)
}
.task(id: userConfig.enableCloudKitSync) {
refreshEpubState(book: book)
guard userConfig.enableCloudKitSync else { return }

for await event in await CloudKitSyncManager.shared.events() {
if case let .epubDownloaded(uuid: uuid) = event,
uuid == book.id {
refreshEpubState(book: book)
}
}
}
.buttonStyle(.plain)
.contextMenu(isSelecting ? nil : ContextMenu {
Expand All @@ -65,6 +77,17 @@ struct BookCell: View {
}
}

if userConfig.enableCloudKitSync && !downloaded {
Button {
Task {
guard let cloudEpub = CloudKitBookEpub(from: book) else { return }
await CloudKitSyncManager.shared.downloadCloudEpub(cloudEpub)
}
} label: {
Label("Download from iCloud", systemImage: "icloud")
}
}

if userConfig.enableSync {
if userConfig.syncMode == .manual {
Menu {
Expand Down Expand Up @@ -171,4 +194,18 @@ struct BookCell: View {
}
}
}

func refreshEpubState(book: BookMetadata) {
Task.detached {
guard let fileName = book.epub,
let booksDir = try? BookStorage.getBooksDirectory() else {
return
}
let epubURL = booksDir.appending(path: book.folder).appending(path: fileName)
let downloaded = FileManager.default.fileExists(atPath: epubURL.path(percentEncoded: false))
await MainActor.run {
self.downloaded = downloaded
}
}
}
}
12 changes: 11 additions & 1 deletion Features/Bookshelf/BookView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@
import SwiftUI

struct BookView: View {
@Environment(UserConfig.self) var userConfig
let book: BookMetadata
let progress: Double
let downloaded: Bool
var isSelected: Bool = false

var titleText: Text {
if userConfig.enableCloudKitSync && !downloaded {
return Text(Image(systemName: "icloud")) + Text(" ") + Text(book.displayTitle)
} else {
return Text(book.displayTitle)
}
}

var body: some View {
VStack(spacing: 6) {
Expand All @@ -21,7 +31,7 @@ struct BookView: View {
isSelected: isSelected
)

Text(book.displayTitle)
titleText
.font(.system(size: 16))
.lineLimit(2)
.frame(height: 40, alignment: .top)
Expand Down
7 changes: 7 additions & 0 deletions Features/Bookshelf/BookshelfView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ struct BookshelfView: View {
}
}
}
.task(id: userConfig.enableCloudKitSync) {
guard userConfig.enableCloudKitSync else { return }

for await _ in await CloudKitSyncManager.shared.events() {
viewModel.loadBooks()
}
}
.fileImporter(
isPresented: $viewModel.isImporting,
allowedContentTypes: [.epub],
Expand Down
27 changes: 26 additions & 1 deletion Features/Bookshelf/BookshelfViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,20 @@ class BookshelfViewModel {
for i in shelves.indices {
shelves[i].bookIds.removeAll { $0 == book.id }
}
deleteCloudBook(book)
saveShelves()
} catch {
showError(message: error.localizedDescription)
}
}

func deleteCloudBook(_ book: BookMetadata) {
guard UserConfig.shared.enableCloudKitSync else { return }
Task {
await CloudKitSyncManager.shared.deleteCloudBook(book)
}
}

func renameBook(_ book: BookMetadata, title: String) {
guard let index = books.firstIndex(where: { $0.id == book.id }) else {
return
Expand Down Expand Up @@ -304,7 +312,7 @@ class BookshelfViewModel {
}
}
let title = await GoogleDriveHandler.desanitizeTtuFilename(folder.name)
let book = await BookMetadata(title: title, cover: cover, folder: folder.id, lastAccess: .distantPast)
let book = BookMetadata(title: title, cover: cover, folder: folder.id, lastAccess: .distantPast)
return (book, files)
}
}
Expand Down Expand Up @@ -525,6 +533,23 @@ class BookshelfViewModel {

try BookStorage.save(metadata, inside: bookFolder, as: FileNames.metadata)
try BookStorage.save(bookinfo, inside: bookFolder, as: FileNames.bookinfo)

if UserConfig.shared.enableCloudKitSync {
Task.detached {
let folderName = bookFolder.lastPathComponent
if let coverURL {
await CloudKitSyncManager.shared.saveCloudFile(
uuid: metadata.id,
fileType: .cover,
fileName: (coverURL as NSString).lastPathComponent,
folderName: folderName,
)
}
if let cloudKitEpub = CloudKitBookEpub(from: metadata) {
await CloudKitSyncManager.shared.saveCloudEpub(cloudKitEpub)
}
}
}
} catch {
try? BookStorage.delete(at: localURL)
try? BookStorage.delete(at: bookFolder)
Expand Down
Loading