The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .github
    ├── FUNDING.yml
    └── workflows
    │   ├── lint.yml
    │   ├── nightly.yml
    │   ├── supporting
    │       ├── altstore
    │       │   └── apps.json
    │       └── update_altstore_json.py
    │   └── update_altstore_source.yml
├── .gitignore
├── .swiftlint.yml
├── Aidoku.xcodeproj
    ├── project.pbxproj
    ├── project.xcworkspace
    │   ├── contents.xcworkspacedata
    │   └── xcshareddata
    │   │   └── swiftpm
    │   │       └── Package.resolved
    └── xcshareddata
    │   └── xcschemes
    │       ├── Aidoku (iOS).xcscheme
    │       └── Aidoku (macOS).xcscheme
├── LICENSE
├── README.md
├── Shared
    ├── Aidoku.xcconfig
    ├── Aidoku.xcdatamodeld
    │   ├── .xccurrentversion
    │   ├── 0.5.xcdatamodel
    │   │   └── contents
    │   ├── 0.6.10.xcdatamodel
    │   │   └── contents
    │   ├── 0.6.7.xcdatamodel
    │   │   └── contents
    │   ├── 0.6.8.xcdatamodel
    │   │   └── contents
    │   ├── 0.6.xcdatamodel
    │   │   └── contents
    │   ├── 0.7.xcdatamodel
    │   │   └── contents
    │   └── Shared.xcdatamodel
    │   │   └── contents
    ├── Assets.xcassets
    │   ├── AccentColor.colorset
    │   │   └── Contents.json
    │   ├── AppIcon.appiconset
    │   │   ├── 167.png
    │   │   ├── 20.png
    │   │   ├── 40-1.png
    │   │   ├── 40.png
    │   │   ├── 60.png
    │   │   ├── AppIcon29x29.png
    │   │   ├── AppIcon29x29@2x-1.png
    │   │   ├── AppIcon29x29@2x.png
    │   │   ├── AppIcon29x29@3x.png
    │   │   ├── AppIcon40x40.png
    │   │   ├── AppIcon40x40@2x-1.png
    │   │   ├── AppIcon40x40@2x.png
    │   │   ├── AppIcon40x40@3x.png
    │   │   ├── AppIcon60x60@2x.png
    │   │   ├── AppIcon60x60@3x.png
    │   │   ├── AppIcon76x76.png
    │   │   ├── AppIcon76x76@2x.png
    │   │   ├── Contents.json
    │   │   └── Icon.png
    │   ├── BannerPlaceholder.imageset
    │   │   ├── BannerPlaceholder-dark.png
    │   │   ├── BannerPlaceholder-light.png
    │   │   └── Contents.json
    │   ├── Contents.json
    │   ├── MangaPlaceholder.imageset
    │   │   ├── Contents.json
    │   │   ├── MangaPlaceholder-dark.png
    │   │   └── MangaPlaceholder-light.png
    │   ├── anilist.imageset
    │   │   ├── Contents.json
    │   │   └── anilist.png
    │   ├── bookmark.imageset
    │   │   ├── Contents.json
    │   │   ├── bookmark.png
    │   │   ├── bookmark@2x.png
    │   │   └── bookmark@3x.png
    │   ├── kavita.imageset
    │   │   ├── Contents.json
    │   │   └── kavita.png
    │   ├── komga.imageset
    │   │   ├── Contents.json
    │   │   └── komga.png
    │   ├── local.imageset
    │   │   ├── Contents.json
    │   │   └── local.png
    │   ├── mal.imageset
    │   │   ├── Contents.json
    │   │   └── mal.png
    │   └── shikimori.imageset
    │   │   ├── Contents.json
    │   │   └── shikimori.png
    ├── Data
    │   ├── Backup
    │   │   ├── BackupManager.swift
    │   │   └── Models
    │   │   │   ├── Backup.swift
    │   │   │   ├── BackupChapter.swift
    │   │   │   ├── BackupHistory.swift
    │   │   │   ├── BackupLibraryManga.swift
    │   │   │   ├── BackupManga.swift
    │   │   │   └── BackupTrackItem.swift
    │   ├── Database
    │   │   └── Objects
    │   │   │   ├── ChapterObject.swift
    │   │   │   ├── HistoryObject.swift
    │   │   │   ├── LibraryMangaObject.swift
    │   │   │   ├── MangaObject.swift
    │   │   │   ├── MangaUpdateObject.swift
    │   │   │   ├── SourceObject.swift
    │   │   │   └── TrackObject.swift
    │   └── Downloads
    │   │   ├── DownloadCache.swift
    │   │   ├── DownloadManager.swift
    │   │   ├── DownloadQueue.swift
    │   │   ├── DownloadTask.swift
    │   │   └── Models
    │   │       └── Download.swift
    ├── Extensions
    │   ├── AidokuRunner.swift
    │   ├── Collection.swift
    │   ├── Date.swift
    │   ├── Dictionary.swift
    │   ├── FileManager.swift
    │   ├── NSLocalizedString.swift
    │   ├── NSPersistentContainer.swift
    │   ├── NotificationName.swift
    │   ├── Reachability.swift
    │   ├── Sequence.swift
    │   ├── String.swift
    │   ├── URL.swift
    │   └── URLSession.swift
    ├── Localization
    │   ├── af.lproj
    │   │   └── Localizable.strings
    │   ├── ar.lproj
    │   │   └── Localizable.strings
    │   ├── bg.lproj
    │   │   └── Localizable.strings
    │   ├── cs.lproj
    │   │   └── Localizable.strings
    │   ├── de.lproj
    │   │   └── Localizable.strings
    │   ├── el.lproj
    │   │   └── Localizable.strings
    │   ├── en.lproj
    │   │   ├── InfoPlist.strings
    │   │   └── Localizable.strings
    │   ├── eo.lproj
    │   │   └── Localizable.strings
    │   ├── es.lproj
    │   │   └── Localizable.strings
    │   ├── fr.lproj
    │   │   └── Localizable.strings
    │   ├── he.lproj
    │   │   └── Localizable.strings
    │   ├── hi.lproj
    │   │   └── Localizable.strings
    │   ├── hr.lproj
    │   │   └── Localizable.strings
    │   ├── hu.lproj
    │   │   └── Localizable.strings
    │   ├── id.lproj
    │   │   └── Localizable.strings
    │   ├── it.lproj
    │   │   └── Localizable.strings
    │   ├── ja.lproj
    │   │   ├── InfoPlist.strings
    │   │   └── Localizable.strings
    │   ├── ka.lproj
    │   │   └── Localizable.strings
    │   ├── km.lproj
    │   │   └── Localizable.strings
    │   ├── ko.lproj
    │   │   └── Localizable.strings
    │   ├── ml.lproj
    │   │   └── Localizable.strings
    │   ├── nb-NO.lproj
    │   │   └── Localizable.strings
    │   ├── ne.lproj
    │   │   └── Localizable.strings
    │   ├── nl.lproj
    │   │   └── Localizable.strings
    │   ├── pl.lproj
    │   │   └── Localizable.strings
    │   ├── pt-BR.lproj
    │   │   └── Localizable.strings
    │   ├── pt.lproj
    │   │   └── Localizable.strings
    │   ├── ro.lproj
    │   │   └── Localizable.strings
    │   ├── ru.lproj
    │   │   └── Localizable.strings
    │   ├── sq.lproj
    │   │   └── Localizable.strings
    │   ├── sr.lproj
    │   │   └── Localizable.strings
    │   ├── sv.lproj
    │   │   └── Localizable.strings
    │   ├── sw.lproj
    │   │   └── Localizable.strings
    │   ├── ta.lproj
    │   │   └── Localizable.strings
    │   ├── th.lproj
    │   │   └── Localizable.strings
    │   ├── tr.lproj
    │   │   └── Localizable.strings
    │   ├── uk.lproj
    │   │   └── Localizable.strings
    │   ├── ur.lproj
    │   │   └── Localizable.strings
    │   ├── vi.lproj
    │   │   └── Localizable.strings
    │   ├── zh-Hans.lproj
    │   │   └── Localizable.strings
    │   └── zh-Hant.lproj
    │   │   └── Localizable.strings
    ├── Logging
    │   ├── LogManager.swift
    │   ├── LogStore.swift
    │   └── Logger.swift
    ├── Managers
    │   ├── CoreData
    │   │   ├── CoreDataManager+Category.swift
    │   │   ├── CoreDataManager+Chapter.swift
    │   │   ├── CoreDataManager+History.swift
    │   │   ├── CoreDataManager+LibraryManga.swift
    │   │   ├── CoreDataManager+Manga.swift
    │   │   ├── CoreDataManager+MangaUpdates.swift
    │   │   ├── CoreDataManager+Source.swift
    │   │   ├── CoreDataManager+Track.swift
    │   │   └── CoreDataManager.swift
    │   └── Manga
    │   │   ├── HistoryManager.swift
    │   │   ├── MangaManager.swift
    │   │   └── MangaUpdateManager.swift
    ├── Models
    │   ├── ChapterFilterOption.swift
    │   ├── ChapterFlagMask.swift
    │   ├── ChapterSortOption.swift
    │   ├── CustomSourceConfig.swift
    │   ├── Manga.swift
    │   ├── MangaInfo.swift
    │   ├── SourceInfo.swift
    │   └── SourceList.swift
    ├── Old Models
    │   ├── Chapter.swift
    │   ├── DeepLink.swift
    │   ├── Filter.swift
    │   ├── KVCObject.swift
    │   ├── Listing.swift
    │   ├── MangaModels.swift
    │   └── Page.swift
    ├── Sources
    │   ├── ExternalSourceInfo.swift
    │   ├── JsonAnyValue.swift
    │   ├── Komga
    │   │   ├── KomgaModels.swift
    │   │   └── KomgaSource.swift
    │   ├── LegacySource.swift
    │   ├── Local
    │   │   ├── LocalFileDataManager.swift
    │   │   ├── LocalFileManager.swift
    │   │   ├── LocalModels.swift
    │   │   └── LocalSource.swift
    │   ├── SettingItem.swift
    │   ├── Source.swift
    │   ├── SourceActor.swift
    │   ├── SourceManager.swift
    │   └── UserAgentProvider.swift
    ├── Tracking
    │   ├── Auth
    │   │   ├── OAuthClient.swift
    │   │   └── OAuthResponse.swift
    │   ├── Models
    │   │   ├── TrackItem.swift
    │   │   ├── TrackScoreType.swift
    │   │   ├── TrackSearchItem.swift
    │   │   ├── TrackState.swift
    │   │   ├── TrackStatus.swift
    │   │   └── TrackUpdate.swift
    │   ├── OAuthTracker.swift
    │   ├── Tracker.swift
    │   ├── TrackerManager.swift
    │   └── Trackers
    │   │   ├── anilist
    │   │       ├── AniListApi.swift
    │   │       ├── AniListQueries.swift
    │   │       └── AniListTracker.swift
    │   │   ├── myanimelist
    │   │       ├── MyAnimeListApi.swift
    │   │       ├── MyAnimeListModels.swift
    │   │       └── MyAnimeListTracker.swift
    │   │   └── shikimori
    │   │       ├── ShikimoriApi.swift
    │   │       ├── ShikimoriQueries.swift
    │   │       └── ShikimoriTracker.swift
    ├── Utilities
    │   ├── BlockingTask.swift
    │   ├── CloudflareHandler.swift
    │   ├── ObjectActorSerialExecutor.swift
    │   ├── PageInterceptorProcessor.swift
    │   ├── PopupWebViewHandler.swift
    │   ├── SemanticVersion.swift
    │   └── UIKitShim.swift
    └── Wasm
    │   ├── Imports
    │       ├── WasmAidoku.swift
    │       ├── WasmDefaults.swift
    │       ├── WasmHtml.swift
    │       ├── WasmJson.swift
    │       ├── WasmNet.swift
    │       ├── WasmStd.swift
    │       └── WebView
    │       │   ├── WasmNetWebViewHandler.swift
    │       │   └── WebViewViewController.swift
    │   ├── WasmGlobalStore.swift
    │   └── WasmImports.swift
├── iOS
    ├── Aidoku-IOS.xcconfig
    ├── AppDelegate.swift
    ├── Extensions
    │   ├── Dates.swift
    │   ├── HostingController.swift
    │   ├── UICollectionView+CellRegistration.swift
    │   ├── UIImage.swift
    │   ├── UINavigationItem.swift
    │   ├── UIStepper.swift
    │   ├── UISwitch.swift
    │   ├── UIToolbar.swift
    │   ├── UIView.swift
    │   ├── UIViewController.swift
    │   └── View.swift
    ├── Info.plist
    ├── New
    │   ├── Extensions
    │   │   ├── Array.swift
    │   │   ├── EdgeInsets.swift
    │   │   ├── UIApplication.swift
    │   │   ├── UIHostingController+SafeArea.swift
    │   │   └── WKWebView.swift
    │   ├── Utilities
    │   │   ├── ButtonStyles
    │   │   │   ├── BetterBorderedButtonStyle.swift
    │   │   │   ├── DarkOverlayButtonStyle.swift
    │   │   │   ├── ListButtonStyle.swift
    │   │   │   ├── MangaGridButtonStyle.swift
    │   │   │   └── SelectHighlightButtonStyle.swift
    │   │   ├── CustomSearchable.swift
    │   │   ├── DownsampleProcessor.swift
    │   │   ├── Modifiers
    │   │   │   └── Shimmer.swift
    │   │   ├── NavigationCoordinator.swift
    │   │   └── UserDefaultsObserver.swift
    │   └── Views
    │   │   ├── Browse
    │   │       ├── AddSourceView.swift
    │   │       ├── ExternalSourceTableCell.swift
    │   │       ├── GetButton.swift
    │   │       ├── IconView.swift
    │   │       ├── KomgaSetupView.swift
    │   │       ├── LocalFileImportView.swift
    │   │       ├── LocalSetupView.swift
    │   │       ├── SourceListsView.swift
    │   │       └── SourceTableCell.swift
    │   │   ├── Common
    │   │       ├── Carousel.swift
    │   │       ├── CloseButton.swift
    │   │       ├── CollectionView.swift
    │   │       ├── DocumentPickerView.swift
    │   │       ├── ErrorView.swift
    │   │       ├── GIFImage.swift
    │   │       ├── HCollectionGrid.swift
    │   │       ├── ImagePicker.swift
    │   │       ├── MangaCoverView.swift
    │   │       ├── MangaGridItem.swift
    │   │       ├── PlatformNavigationStack.swift
    │   │       ├── Settings
    │   │       │   ├── SettingView.swift
    │   │       │   └── WebView.swift
    │   │       ├── SourceImageView.swift
    │   │       └── WrappingHStack.swift
    │   │   ├── Manga
    │   │       ├── ChapterListHeaderView.swift
    │   │       ├── ChapterTableCell.swift
    │   │       ├── ExpandableTextView2.swift
    │   │       ├── MangaCoverPageView.swift
    │   │       ├── MangaDetailsHeaderView.swift
    │   │       ├── MangaView+ViewModel.swift
    │   │       └── MangaView.swift
    │   │   └── Source
    │   │       ├── Filter
    │   │           ├── CheckFilterView.swift
    │   │           ├── FilterBadgeView.swift
    │   │           ├── FilterHeaderView.swift
    │   │           ├── FilterLabelView.swift
    │   │           ├── FilterListSheetView.swift
    │   │           ├── MultiSelectFilterView.swift
    │   │           ├── SelectFilterView.swift
    │   │           └── SortFilterView.swift
    │   │       ├── HomeComponents
    │   │           ├── HomeBigScrollerView.swift
    │   │           ├── HomeChapterListView.swift
    │   │           ├── HomeFiltersView.swift
    │   │           ├── HomeImageScrollerView.swift
    │   │           ├── HomeLinksView.swift
    │   │           ├── HomeListView.swift
    │   │           ├── HomeScrollerView.swift
    │   │           └── TitleView.swift
    │   │       ├── HomeGridView.swift
    │   │       ├── ListingsHeaderView.swift
    │   │       ├── MangaListView.swift
    │   │       ├── NewSourceViewController.swift
    │   │       ├── SearchFilterHeaderView.swift
    │   │       ├── SourceHomeContentView.swift
    │   │       ├── SourceHomeSkeletonView.swift
    │   │       ├── SourceListingViewController.swift
    │   │       ├── SourceSearchView.swift
    │   │       └── SourceSettingsView.swift
    ├── Old UI
    │   ├── Base
    │   │   ├── CategorySelectViewController.swift
    │   │   ├── CircularProgressView.swift
    │   │   ├── Manga
    │   │   │   ├── MangaCoverCell.swift
    │   │   │   └── MangaListSelectionHeader.swift
    │   │   ├── MiniModalViewController.swift
    │   │   └── Settings
    │   │   │   ├── SegmentTableViewCell.swift
    │   │   │   ├── SettingSelectViewController.swift
    │   │   │   ├── SettingsTableViewController.swift
    │   │   │   └── TextInputTableViewCell.swift
    │   ├── Browse
    │   │   ├── Filters
    │   │   │   ├── FilterCell.swift
    │   │   │   ├── FilterModalViewController.swift
    │   │   │   └── FilterStackView.swift
    │   │   ├── Info
    │   │   │   ├── SourceInfoHeaderView.swift
    │   │   │   └── SourceInfoViewController.swift
    │   │   ├── LanguageSelectViewController.swift
    │   │   ├── SourceCell
    │   │   │   └── GetButtonView.swift
    │   │   └── SourceSectionHeaderView.swift
    │   ├── History
    │   │   ├── HistoryTableViewCell.swift
    │   │   └── HistoryViewController.swift
    │   ├── Library
    │   │   ├── DownloadQueueViewController.swift
    │   │   └── DownloadTableViewCell.swift
    │   ├── Manga
    │   │   ├── ExpandableTextView.swift
    │   │   └── Tracking
    │   │   │   ├── TrackerAddView.swift
    │   │   │   ├── TrackerListView.swift
    │   │   │   ├── TrackerModalViewController.swift
    │   │   │   ├── TrackerSearchTableViewCell.swift
    │   │   │   ├── TrackerSearchViewController.swift
    │   │   │   ├── TrackerSettingOptionView.swift
    │   │   │   ├── TrackerSettingOptionViewCoordinator.swift
    │   │   │   └── TrackerView.swift
    │   ├── Reader
    │   │   ├── ReaderInfoPageView.swift
    │   │   ├── ReaderNavigationController.swift
    │   │   ├── ReaderSettingsViewController.swift
    │   │   └── ZoomableScrollView.swift
    │   ├── Search
    │   │   └── SearchViewController.swift
    │   └── Settings
    │   │   ├── BackupsViewController.swift
    │   │   ├── CategoriesViewController.swift
    │   │   ├── LogViewController.swift
    │   │   ├── SettingsAboutViewController.swift
    │   │   ├── SettingsViewController.swift
    │   │   └── TrackersViewController.swift
    ├── SceneDelegate.swift
    ├── UI
    │   ├── Browse
    │   │   ├── BrowseViewController.swift
    │   │   ├── BrowseViewModel.swift
    │   │   ├── SmallSectionHeaderConfiguration.swift
    │   │   └── SourceTableViewCell.swift
    │   ├── Common
    │   │   ├── BaseCollectionViewController.swift
    │   │   ├── BaseObservingCellNode.swift
    │   │   ├── BaseObservingViewController.swift
    │   │   ├── BaseTableViewController.swift
    │   │   ├── BaseViewController.swift
    │   │   ├── EmptyPageStackView.swift
    │   │   ├── LockedPageStackView.swift
    │   │   ├── Manga
    │   │   │   ├── MangaCollectionViewController.swift
    │   │   │   └── MangaGridCell.swift
    │   │   ├── SwiftUINavigationController.swift
    │   │   ├── SwiftUINavigationView.swift
    │   │   └── Zooming
    │   │   │   ├── ZoomableCollectionView.swift
    │   │   │   ├── ZoomableCollectionViewController.swift
    │   │   │   └── ZoomableLayoutProtocol.swift
    │   ├── Library
    │   │   ├── AddToCategoryViewController.swift
    │   │   ├── LibraryViewController.swift
    │   │   └── LibraryViewModel.swift
    │   ├── Manga
    │   │   ├── ChapterCellConfiguration.swift
    │   │   ├── ChapterListHeaderConfiguration.swift
    │   │   ├── MangaCoverViewController.swift
    │   │   ├── MangaDetailHeaderView.swift
    │   │   ├── MangaLabelView.swift
    │   │   ├── MangaViewController.swift
    │   │   ├── MangaViewModel.swift
    │   │   ├── MangaViewOld.swift
    │   │   ├── SizeChangeListenerDelegate.swift
    │   │   └── TouchDownGestureRecognizer.swift
    │   ├── Migration
    │   │   ├── MangaGridView.swift
    │   │   ├── MangaToMangaView.swift
    │   │   ├── MigrateMangaView.swift
    │   │   ├── MigrateSearchMatchView.swift
    │   │   ├── MigrateSourceSelectionView.swift
    │   │   ├── MigrateSourcesView.swift
    │   │   ├── Models
    │   │   │   ├── MigrationState.swift
    │   │   │   └── MigrationStrategy.swift
    │   │   └── SearchBar.swift
    │   ├── Reader
    │   │   ├── Page
    │   │   │   ├── CropBordersProcessor.swift
    │   │   │   ├── MarkdownView.swift
    │   │   │   ├── ReaderPageDescriptionButtonView.swift
    │   │   │   ├── ReaderPageView.swift
    │   │   │   └── ReaderTransitionNode.swift
    │   │   ├── ReaderChapterListView.swift
    │   │   ├── ReaderHoldingDelegate.swift
    │   │   ├── ReaderSliderView.swift
    │   │   ├── ReaderToolbarView.swift
    │   │   ├── ReaderViewController.swift
    │   │   ├── Readers
    │   │   │   ├── Paged
    │   │   │   │   ├── ReaderDoublePageViewController.swift
    │   │   │   │   ├── ReaderPageViewController.swift
    │   │   │   │   ├── ReaderPagedViewController.swift
    │   │   │   │   └── ReaderPagedViewModel.swift
    │   │   │   ├── ReaderReaderDelegate.swift
    │   │   │   ├── Text
    │   │   │   │   ├── ReaderTextViewController.swift
    │   │   │   │   └── ReaderTextViewModel.swift
    │   │   │   └── Webtoon
    │   │   │   │   ├── GIFImageNode.swift
    │   │   │   │   ├── HeightQueryable.swift
    │   │   │   │   ├── HostingNode.swift
    │   │   │   │   ├── ReaderWebtoonPageNode.swift
    │   │   │   │   ├── ReaderWebtoonTransitionNode.swift
    │   │   │   │   ├── ReaderWebtoonViewController.swift
    │   │   │   │   ├── ReaderWebtoonViewModel.swift
    │   │   │   │   └── VerticalContentOffsetPreservingLayout.swift
    │   │   └── ReadingMode.swift
    │   ├── Source
    │   │   ├── SourceViewController.swift
    │   │   └── SourceViewModel.swift
    │   └── Updates
    │   │   ├── MangaUpdateItemView.swift
    │   │   └── MangaUpdatesView.swift
    └── iOS.entitlements
└── macOS
    ├── Aidoku-MACOS.xcconfig
    ├── AidokuApp.swift
    ├── Info.plist
    ├── Views
        └── LibraryView.swift
    └── macOS.entitlements


/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | ko_fi: skittyblock
2 | 


--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
 1 | name: SwiftLint
 2 | 
 3 | on:
 4 |   pull_request:
 5 |     paths:
 6 |       - '.github/workflows/swiftlint.yml'
 7 |       - '.swiftlint.yml'
 8 |       - '**/*.swift'
 9 | 
10 | jobs:
11 |   SwiftLint:
12 |     runs-on: ubuntu-latest
13 |     steps:
14 |       - uses: actions/checkout@v4
15 |       - name: GitHub Action for SwiftLint
16 |         uses: norio-nomura/action-swiftlint@3.2.1
17 |       - name: GitHub Action for SwiftLint with --strict
18 |         uses: norio-nomura/action-swiftlint@3.2.1
19 |         with:
20 |           args: --strict
21 |       - name: GitHub Action for SwiftLint (Only files changed in the PR)
22 |         uses: norio-nomura/action-swiftlint@3.2.1
23 |         env:
24 |           DIFF_BASE: ${{ github.base_ref }}
25 |       - name: GitHub Action for SwiftLint (Different working directory)
26 |         uses: norio-nomura/action-swiftlint@3.2.1
27 |         env:
28 |           WORKING_DIRECTORY: Source
29 | 


--------------------------------------------------------------------------------
/.github/workflows/nightly.yml:
--------------------------------------------------------------------------------
 1 | name: Build and upload nightly ipa
 2 | 
 3 | on: [ push, workflow_dispatch ]
 4 | 
 5 | jobs:
 6 |   build:
 7 |     runs-on: macos-15
 8 |     steps:
 9 |       - uses: actions/checkout@v4
10 |       - name: Setup Xcode
11 |         uses: maxim-lobanov/setup-xcode@v1
12 |         with:
13 |           xcode-version: '26.0-beta'
14 |       - name: Install Xcode platforms
15 |         run: xcodebuild -downloadPlatform iOS
16 |       - name: Get commit SHA
17 |         id: commitinfo
18 |         run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
19 |       - name: Build
20 |         run: xcodebuild -scheme "Aidoku (iOS)" -configuration Release archive -archivePath build/Aidoku.xcarchive -skipPackagePluginValidation CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
21 |       - name: Package ipa
22 |         run: |
23 |           mkdir Payload
24 |           cp -r build/Aidoku.xcarchive/Products/Applications/Aidoku.app Payload
25 |           zip -r Aidoku-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa Payload
26 |       - name: Upload artifacts
27 |         uses: actions/upload-artifact@v4
28 |         with:
29 |           name: Aidoku-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa
30 |           path: Aidoku-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa
31 |           if-no-files-found: error
32 | 


--------------------------------------------------------------------------------
/.github/workflows/supporting/altstore/apps.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "Aidoku Source",
 3 |   "identifier": "app.aidoku.altstore",
 4 |   "sourceURL": "https://raw.githubusercontent.com/Aidoku/Aidoku/refs/heads/altstore/apps.json",
 5 |   "subtitle": "Home of Aidoku, free and open source manga reader for iOS and iPadOS",
 6 |   "description": "This is the AltStore Source for Aidoku, free and open source manga reader for iOS and iPadOS.\n\nFor full details on what's included, check the website: https://aidoku.app/",
 7 |   "website": "https://aidoku.app/",
 8 |   "featuredApps": [
 9 |     "app.aidoku.Aidoku"
10 |   ],
11 |   "apps": [
12 |     {
13 |       "name": "Aidoku",
14 |       "bundleIdentifier": "app.aidoku.Aidoku",
15 |       "developerName": "Skitty",
16 |       "subtitle": "Free and open source manga reader for iOS and iPadOS",
17 |       "localizedDescription": "A free and open source manga reading application for iOS and iPadOS.",
18 |       "iconURL": "https://aidoku.app/images/apple-touch-icon.png",
19 |       "tintColor": "ff375f",
20 |       "category": "entertainment",
21 |       "screenshots": [
22 |         {
23 |           "imageURL": "https://aidoku.app/images/library.png",
24 |           "width": 450,
25 |           "height": 908
26 |         },
27 |         {
28 |           "imageURL": "https://aidoku.app/images/source.png",
29 |           "width": 450,
30 |           "height": 908
31 |         },
32 |         {
33 |           "imageURL": "https://aidoku.app/images/reader.png",
34 |           "width": 450,
35 |           "height": 908
36 |         }
37 |       ],
38 |       "appPermissions": {
39 |         "entitlements": [
40 |           "aps-environment",
41 |           "com.apple.developer.icloud-container-environment",
42 |           "com.apple.developer.icloud-container-identifiers",
43 |           "com.apple.developer.icloud-services",
44 |           "com.apple.developer.ubiquity-kvstore-identifier"
45 |         ],
46 |         "privacy": {
47 |           "NSFaceIDUsageDescription": "Aidoku needs FaceID permissions for user-defined settings",
48 |           "NSPhotoLibraryUsageDescription": "Aidoku needs permission to save pages in your photo library",
49 |           "NSLocalNetworkUsageDescription": "Aidoku needs permission to connect to local Komga servers"
50 |         }
51 |       },
52 |       "versions": [
53 |       ]
54 |     }
55 |   ],
56 |   "news": []
57 | }
58 | 


--------------------------------------------------------------------------------
/.github/workflows/update_altstore_source.yml:
--------------------------------------------------------------------------------
 1 | name: Update AltStore Source
 2 | 
 3 | on:
 4 |   release:
 5 |     types: [published]
 6 |   workflow_dispatch:  # Allow manual trigger
 7 | 
 8 | jobs:
 9 |   update-source:
10 |     runs-on: ubuntu-latest
11 |     steps:
12 |       - name: Checkout repository
13 |         uses: actions/checkout@v4
14 |         with:
15 |           fetch-depth: 0
16 | 
17 |       - name: Set up Python
18 |         uses: actions/setup-python@v4
19 |         with:
20 |           python-version: '3.x'
21 | 
22 |       - name: Install dependencies
23 |         run: |
24 |           python -m pip install --upgrade pip
25 |           pip install requests
26 | 
27 |       - name: Update AltStore source
28 |         id: update_source
29 |         run: |
30 |           python .github/workflows/supporting/update_altstore_json.py
31 | 
32 |       - name: Deploy to GitHub Pages
33 |         uses: JamesIves/github-pages-deploy-action@v4.7.2
34 |         with:
35 |           branch: altstore
36 |           folder: .github/workflows/supporting/altstore
37 |           git-config-name: GitHub Actions
38 |           git-config-email: github-actions[bot]@users.noreply.github.com
39 |           commit-message: Update AltStore Source
40 |           single-commit: true
41 | 


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | # Xcode
 2 | *.xcodeproj/*
 3 | !*.xcodeproj/project.pbxproj
 4 | !*.xcodeproj/xcshareddata/
 5 | !*.xcodeproj/*.xcworkspace/
 6 | 
 7 | *.xcodeproj/*.xcworkspace/*
 8 | !*.xcodeproj/*.xcworkspace/contents.xcworkspacedata
 9 | !*.xcodeproj/*.xcworkspace/xcshareddata/
10 | 
11 | *.xcodeproj/*.xcworkspace/xcshareddata/*
12 | !*.xcodeproj/*.xcworkspace/xcshareddata/swiftpm/
13 | 
14 | # Other
15 | .DS_Store
16 | 
17 | # Zed
18 | .zed
19 | 
20 | # Xcode-Build-Server
21 | buildServer.json
22 | 


--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
 1 | disabled_rules:
 2 |   - todo
 3 |   - identifier_name
 4 |   - opening_brace
 5 |   - switch_case_alignment
 6 |   - nesting
 7 |   - nslocalizedstring_key
 8 | opt_in_rules:
 9 |   - closure_end_indentation
10 |   - closure_spacing
11 |   - empty_count
12 |   - explicit_init
13 |   - fatal_error_message
14 |   - first_where
15 |   - implicit_return
16 |   - joined_default_parameter
17 |   - literal_expression_end_indentation
18 |   - overridden_super_call
19 |   - prohibited_super_call
20 |   - sorted_first_last
21 |   - unneeded_parentheses_in_closure_argument
22 |   - vertical_parameter_alignment_on_call
23 |   - yoda_condition
24 |   - nslocalizedstring_key
25 |   - unused_setter_value
26 |   - optional_enum_case_matching
27 |   - prefer_self_type_over_type_of_self
28 |   - contains_over_range_nil_comparison
29 |   - flatmap_over_map_reduce
30 |   - empty_collection_literal
31 |   - contains_over_first_not_nil
32 |   - contains_over_filter_count
33 |   - contains_over_filter_is_empty
34 |   - reduce_into
35 | analyzer_rules:
36 |   - unused_import
37 | custom_rules:
38 |   comment_whitespace:
39 |     name: "Comment Whitespace"
40 |     regex: //\S
41 |     match_kinds: comment
42 |     message: "Comments must begin with a whitespace character"
43 |   spaces_not_tabs:
44 |     name: "Spaces not Tabs"
45 |     regex: ^\t
46 |     message: "Use four spaces, not tabs"
47 |   point_zero:
48 |     name: "Point Zero"
49 |     regex: '(?<!iOS\s)(?<!macOS\s)(?<!\.)\b[\d_]+\.0\b'
50 |     match_kinds:
51 |       - number
52 |       - attribute.builtin
53 |     message: "Don't add a .0 to the end of floating point literals"
54 | type_body_length: 500
55 | function_body_length: 200
56 | line_length:
57 |     warning: 150
58 |     error: 200
59 |     ignores_comments: true
60 |     ignores_urls: true
61 | file_length: 1500
62 | cyclomatic_complexity: 30
63 | 


--------------------------------------------------------------------------------
/Aidoku.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 | <?xml version="1.0" encoding="UTF-8"?>
2 | <Workspace
3 |    version = "1.0">
4 |    <FileRef
5 |       location = "self:">
6 |    </FileRef>
7 | </Workspace>
8 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | # Aidoku
 2 | A free and open source manga reading application for iOS and iPadOS.
 3 | 
 4 | ## Features
 5 | - [x] No ads
 6 | - [x] Robust WASM source system
 7 | - [x] Online reading through external sources
 8 | - [x] Downloads
 9 | - [x] Tracker integration (AniList, MyAnimeList)
10 | 
11 | ## Installation
12 | 
13 | For detailed installation instructions, check out [the website](https://aidoku.app).
14 | 
15 | ### TestFlight
16 | 
17 | To join the TestFlight, you will need to join the [Aidoku Discord](https://discord.gg/kh2PYT8V8d).
18 | 
19 | ### AltStore
20 | 
21 | We have an AltStore repo that contains the latest releases ipa. You can copy the [direct source URL](https://raw.githubusercontent.com/Aidoku/Aidoku/altstore/apps.json) and paste it into AltStore. Note that AltStore PAL is not supported.
22 | 
23 | ### Manual Installation
24 | 
25 | The latest ipa file will always be available from the [releases page](https://github.com/Aidoku/Aidoku/releases).
26 | 
27 | ## Contributing
28 | Aidoku is still in a beta phase, and there are a lot of planned features and fixes. If you're interested in contributing, I'd first recommend checking with me on [Discord](https://discord.gg/kh2PYT8V8d) in the app development channel.
29 | 
30 | This repo (excluding translations) is licensed under [GPLv3](https://github.com/Aidoku/Aidoku/blob/main/LICENSE), but contributors must also sign the project [CLA](https://gist.github.com/Skittyblock/893952ff23f0df0e5cd02abbaddc2be9). Essentially, this just gives me (Skittyblock) the ability to distribute Aidoku via TestFlight/the App Store, but others must obtain an exception from me in order to do the same. Otherwise, GPLv3 applies and this code can be used freely as long as the modified source code is made available.
31 | 
32 | ### Translations
33 | Interested in translating Aidoku? We use [Weblate](https://hosted.weblate.org/engage/aidoku/) to crowdsource translations, so anyone can create an account and contribute!
34 | 
35 | Translations are licensed separately from the app code, under [Apache 2.0](https://spdx.org/licenses/Apache-2.0.html).
36 | 


--------------------------------------------------------------------------------
/Shared/Aidoku.xcconfig:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Aidoku.xcconfig
 3 | //  Aidoku
 4 | //
 5 | //  Created by Nikolai Schumacher on 17.05.25.
 6 | //
 7 | 
 8 | // base identifiers
 9 | APP_ID_PREFIX               = app.aidoku
10 | APP_ID_SUFFIX               = Aidoku
11 | 
12 | PRODUCT_BUNDLE_IDENTIFIER   = $(APP_ID_PREFIX).$(APP_ID_SUFFIX)
13 | 
14 | // ----- iCloud / CloudKit -----
15 | ICLOUD_CONTAINER_ID         = iCloud.$(PRODUCT_BUNDLE_IDENTIFIER)
16 | UBIQUITY_KVSTORE_ID         = $(TeamIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)
17 | 
18 | // Store build
19 | // Dev / sideload build (leave flag 'CANONICAL_BUILD' undefined)
20 | OTHER_SWIFT_FLAGS = $(inherited) -DCANONICAL_BUILD
21 | 


--------------------------------------------------------------------------------
/Shared/Aidoku.xcdatamodeld/.xccurrentversion:
--------------------------------------------------------------------------------
1 | <?xml version="1.0" encoding="UTF-8"?>
2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 | <plist version="1.0">
4 | <dict>
5 | 	<key>_XCCurrentVersionName</key>
6 | 	<string>0.7.xcdatamodel</string>
7 | </dict>
8 | </plist>
9 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "platform" : "ios",
 6 |         "reference" : "systemPinkColor"
 7 |       },
 8 |       "idiom" : "universal"
 9 |     }
10 |   ],
11 |   "info" : {
12 |     "author" : "xcode",
13 |     "version" : 1
14 |   }
15 | }
16 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/167.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/167.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/20.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/20.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/40-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/40-1.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/40.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/40.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/60.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon29x29.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon29x29.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@2x-1.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@2x.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@3x.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon40x40.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon40x40.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@2x-1.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@2x.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@3x.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon60x60@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon60x60@2x.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon60x60@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon60x60@3x.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon76x76.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon76x76@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/AppIcon76x76@2x.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/Icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/AppIcon.appiconset/Icon.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/BannerPlaceholder.imageset/BannerPlaceholder-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/BannerPlaceholder.imageset/BannerPlaceholder-dark.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/BannerPlaceholder.imageset/BannerPlaceholder-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/BannerPlaceholder.imageset/BannerPlaceholder-light.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/BannerPlaceholder.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "BannerPlaceholder-light.png",
 5 |       "idiom" : "universal"
 6 |     },
 7 |     {
 8 |       "appearances" : [
 9 |         {
10 |           "appearance" : "luminosity",
11 |           "value" : "dark"
12 |         }
13 |       ],
14 |       "filename" : "BannerPlaceholder-dark.png",
15 |       "idiom" : "universal"
16 |     }
17 |   ],
18 |   "info" : {
19 |     "author" : "xcode",
20 |     "version" : 1
21 |   }
22 | }
23 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 |   "info" : {
3 |     "author" : "xcode",
4 |     "version" : 1
5 |   }
6 | }
7 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/MangaPlaceholder.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "MangaPlaceholder-light.png",
 5 |       "idiom" : "universal"
 6 |     },
 7 |     {
 8 |       "appearances" : [
 9 |         {
10 |           "appearance" : "luminosity",
11 |           "value" : "dark"
12 |         }
13 |       ],
14 |       "filename" : "MangaPlaceholder-dark.png",
15 |       "idiom" : "universal"
16 |     }
17 |   ],
18 |   "info" : {
19 |     "author" : "xcode",
20 |     "version" : 1
21 |   }
22 | }
23 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/MangaPlaceholder.imageset/MangaPlaceholder-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/MangaPlaceholder.imageset/MangaPlaceholder-dark.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/MangaPlaceholder.imageset/MangaPlaceholder-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/MangaPlaceholder.imageset/MangaPlaceholder-light.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/anilist.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "anilist.png",
 5 |       "idiom" : "universal"
 6 |     }
 7 |   ],
 8 |   "info" : {
 9 |     "author" : "xcode",
10 |     "version" : 1
11 |   }
12 | }
13 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/anilist.imageset/anilist.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/anilist.imageset/anilist.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/bookmark.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "bookmark.png",
 5 |       "idiom" : "universal",
 6 |       "scale" : "1x"
 7 |     },
 8 |     {
 9 |       "filename" : "bookmark@2x.png",
10 |       "idiom" : "universal",
11 |       "scale" : "2x"
12 |     },
13 |     {
14 |       "filename" : "bookmark@3x.png",
15 |       "idiom" : "universal",
16 |       "scale" : "3x"
17 |     }
18 |   ],
19 |   "info" : {
20 |     "author" : "xcode",
21 |     "version" : 1
22 |   },
23 |   "properties" : {
24 |     "template-rendering-intent" : "template"
25 |   }
26 | }
27 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/bookmark.imageset/bookmark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/bookmark.imageset/bookmark.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/bookmark.imageset/bookmark@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/bookmark.imageset/bookmark@2x.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/bookmark.imageset/bookmark@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/bookmark.imageset/bookmark@3x.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/kavita.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "kavita.png",
 5 |       "idiom" : "universal"
 6 |     }
 7 |   ],
 8 |   "info" : {
 9 |     "author" : "xcode",
10 |     "version" : 1
11 |   }
12 | }
13 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/kavita.imageset/kavita.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/kavita.imageset/kavita.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/komga.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "komga.png",
 5 |       "idiom" : "universal"
 6 |     }
 7 |   ],
 8 |   "info" : {
 9 |     "author" : "xcode",
10 |     "version" : 1
11 |   }
12 | }
13 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/komga.imageset/komga.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/komga.imageset/komga.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/local.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "local.png",
 5 |       "idiom" : "universal"
 6 |     }
 7 |   ],
 8 |   "info" : {
 9 |     "author" : "xcode",
10 |     "version" : 1
11 |   }
12 | }
13 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/local.imageset/local.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/local.imageset/local.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/mal.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "mal.png",
 5 |       "idiom" : "universal"
 6 |     }
 7 |   ],
 8 |   "info" : {
 9 |     "author" : "xcode",
10 |     "version" : 1
11 |   }
12 | }
13 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/mal.imageset/mal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/mal.imageset/mal.png


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/shikimori.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "shikimori.png",
 5 |       "idiom" : "universal"
 6 |     }
 7 |   ],
 8 |   "info" : {
 9 |     "author" : "xcode",
10 |     "version" : 1
11 |   }
12 | }
13 | 


--------------------------------------------------------------------------------
/Shared/Assets.xcassets/shikimori.imageset/shikimori.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aidoku/Aidoku/aa1d3f5749738b56cc19b6267ba26e44f7c498b8/Shared/Assets.xcassets/shikimori.imageset/shikimori.png


--------------------------------------------------------------------------------
/Shared/Data/Backup/Models/Backup.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Backup.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/26/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | struct Backup: Codable {
11 |     var library: [BackupLibraryManga]?
12 |     var history: [BackupHistory]?
13 |     var manga: [BackupManga]?
14 |     var chapters: [BackupChapter]?
15 |     var trackItems: [BackupTrackItem]?
16 |     var categories: [String]?
17 |     var sources: [String]?
18 |     var sourceLists: [String]?
19 |     var date: Date
20 |     var name: String?
21 |     var version: String?
22 | 
23 |     static func load(from url: URL) -> Backup? {
24 |         guard let json = try? Data(contentsOf: url) else { return nil }
25 | 
26 |         if let backup = try? PropertyListDecoder().decode(Backup.self, from: json) {
27 |             return backup
28 |         } else {
29 |             let decoder = JSONDecoder()
30 |             decoder.dateDecodingStrategy = .secondsSince1970
31 |             return try? decoder.decode(Backup.self, from: json)
32 |         }
33 |     }
34 | }
35 | 


--------------------------------------------------------------------------------
/Shared/Data/Backup/Models/BackupChapter.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BackupChapter.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/26/22.
 6 | //
 7 | 
 8 | import CoreData
 9 | 
10 | struct BackupChapter: Codable {
11 |     var sourceId: String
12 |     var mangaId: String
13 |     var id: String
14 |     var title: String?
15 |     var scanlator: String?
16 |     var lang: String
17 |     var chapter: Float?
18 |     var volume: Float?
19 |     var dateUploaded: Date?
20 |     var sourceOrder: Int
21 | 
22 |     init(chapterObject: ChapterObject) {
23 |         sourceId = chapterObject.sourceId
24 |         mangaId = chapterObject.mangaId
25 |         id = chapterObject.id
26 |         title = chapterObject.title
27 |         scanlator = chapterObject.scanlator
28 |         lang = chapterObject.lang
29 |         chapter = chapterObject.chapter?.floatValue
30 |         volume = chapterObject.volume?.floatValue
31 |         dateUploaded = chapterObject.dateUploaded
32 |         sourceOrder = Int(chapterObject.sourceOrder)
33 |     }
34 | 
35 |     func toObject(context: NSManagedObjectContext? = nil) -> ChapterObject {
36 |         let obj: ChapterObject
37 |         if let context = context {
38 |             obj = ChapterObject(context: context)
39 |         } else {
40 |             obj = ChapterObject(context: CoreDataManager.shared.context)
41 |         }
42 |         obj.sourceId = sourceId
43 |         obj.mangaId = mangaId
44 |         obj.id = id
45 |         obj.title = title
46 |         obj.scanlator = scanlator
47 |         obj.lang = lang
48 |         if let chapter = chapter {
49 |             obj.chapter = NSNumber(value: chapter)
50 |         } else {
51 |             obj.chapter = nil
52 |         }
53 |         if let volume = volume {
54 |             obj.volume = NSNumber(value: volume)
55 |         } else {
56 |             obj.volume = nil
57 |         }
58 |         obj.dateUploaded = dateUploaded
59 |         obj.sourceOrder = Int16(sourceOrder)
60 |         return obj
61 |     }
62 | }
63 | 


--------------------------------------------------------------------------------
/Shared/Data/Backup/Models/BackupHistory.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BackupHistory.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/26/22.
 6 | //
 7 | 
 8 | import CoreData
 9 | 
10 | struct BackupHistory: Codable {
11 |     var dateRead: Date
12 |     var sourceId: String
13 |     var chapterId: String
14 |     var mangaId: String
15 |     var progress: Int?
16 |     var total: Int?
17 |     var completed: Bool
18 | 
19 |     init(historyObject: HistoryObject) {
20 |         dateRead = historyObject.dateRead ?? Date.distantPast
21 |         sourceId = historyObject.sourceId
22 |         chapterId = historyObject.chapterId
23 |         mangaId = historyObject.mangaId
24 |         progress = Int(historyObject.progress)
25 |         total = Int(historyObject.total)
26 |         completed = historyObject.completed
27 |     }
28 | 
29 |     func toObject(context: NSManagedObjectContext? = nil) -> HistoryObject {
30 |         let obj: HistoryObject
31 |         if let context = context {
32 |             obj = HistoryObject(context: context)
33 |         } else {
34 |             obj = HistoryObject()
35 |         }
36 |         obj.dateRead = dateRead
37 |         obj.sourceId = sourceId
38 |         obj.chapterId = chapterId
39 |         obj.mangaId = mangaId
40 |         obj.progress = Int16(progress ?? -1)
41 |         obj.total = Int16(total ?? 0)
42 |         obj.completed = completed
43 |         return obj
44 |     }
45 | }
46 | 


--------------------------------------------------------------------------------
/Shared/Data/Backup/Models/BackupLibraryManga.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BackupLibraryManga.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/26/22.
 6 | //
 7 | 
 8 | import CoreData
 9 | 
10 | struct BackupLibraryManga: Codable {
11 |     var lastOpened: Date
12 |     var lastUpdated: Date
13 |     var lastRead: Date?
14 |     var dateAdded: Date
15 |     var categories: [String]
16 | 
17 |     var mangaId: String
18 |     var sourceId: String
19 | 
20 |     init(libraryObject: LibraryMangaObject) {
21 |         lastOpened = libraryObject.lastOpened
22 |         lastUpdated = libraryObject.lastUpdated
23 |         lastRead = libraryObject.lastRead
24 |         dateAdded = libraryObject.dateAdded
25 |         mangaId = libraryObject.manga?.id ?? ""
26 |         sourceId = libraryObject.manga?.sourceId ?? ""
27 |         categories = (libraryObject.categories?.allObjects as? [CategoryObject])?.compactMap { $0.title } ?? []
28 |     }
29 | 
30 |     func toObject(context: NSManagedObjectContext? = nil) -> LibraryMangaObject {
31 |         let obj: LibraryMangaObject
32 |         if let context = context {
33 |             obj = LibraryMangaObject(context: context)
34 |         } else {
35 |             obj = LibraryMangaObject()
36 |         }
37 |         obj.lastOpened = lastOpened
38 |         obj.lastUpdated = lastUpdated
39 |         obj.lastRead = lastRead
40 |         obj.dateAdded = dateAdded
41 |         return obj
42 |     }
43 | }
44 | 


--------------------------------------------------------------------------------
/Shared/Data/Backup/Models/BackupManga.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BackupManga.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/26/22.
 6 | //
 7 | 
 8 | import CoreData
 9 | 
10 | struct BackupManga: Codable {
11 |     var id: String
12 |     var sourceId: String
13 |     var title: String
14 |     var author: String?
15 |     var artist: String?
16 |     var desc: String?
17 |     var tags: [String]?
18 |     var cover: String?
19 |     var url: String?
20 |     var status: Int
21 |     var nsfw: Int
22 |     var viewer: Int
23 |     var chapterFlags: Int?
24 |     var langFilter: String?
25 |     var scanlatorFilter: [String]?
26 | 
27 |     init(mangaObject: MangaObject) {
28 |         id = mangaObject.id
29 |         sourceId = mangaObject.sourceId
30 |         title = mangaObject.title
31 |         author = mangaObject.author
32 |         artist = mangaObject.artist
33 |         desc = mangaObject.desc
34 |         tags = mangaObject.tags
35 |         cover = mangaObject.cover
36 |         url = mangaObject.url
37 |         status = Int(mangaObject.status)
38 |         nsfw = Int(mangaObject.nsfw)
39 |         viewer = Int(mangaObject.viewer)
40 |         chapterFlags = Int(mangaObject.chapterFlags)
41 |         langFilter = mangaObject.langFilter
42 |         scanlatorFilter = mangaObject.scanlatorFilter
43 |     }
44 | 
45 |     func toObject(context: NSManagedObjectContext? = nil) -> MangaObject {
46 |         let obj: MangaObject
47 |         if let context = context {
48 |             obj = MangaObject(context: context)
49 |         } else {
50 |             obj = MangaObject()
51 |         }
52 |         obj.id = id
53 |         obj.sourceId = sourceId
54 |         obj.title = title
55 |         obj.author = author
56 |         obj.artist = artist
57 |         obj.desc = desc
58 |         obj.tags = tags
59 |         obj.cover = cover
60 |         obj.url = url
61 |         obj.status = Int16(status)
62 |         obj.nsfw = Int16(nsfw)
63 |         obj.viewer = Int16(viewer)
64 |         obj.chapterFlags = Int16(chapterFlags ?? 0)
65 |         obj.langFilter = langFilter
66 |         obj.scanlatorFilter = scanlatorFilter
67 |         return obj
68 |     }
69 | }
70 | 


--------------------------------------------------------------------------------
/Shared/Data/Backup/Models/BackupTrackItem.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BackupTrackItem.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 7/21/22.
 6 | //
 7 | 
 8 | import CoreData
 9 | 
10 | struct BackupTrackItem: Codable {
11 |     var id: String
12 |     var trackerId: String
13 |     var mangaId: String
14 |     var sourceId: String
15 |     var title: String?
16 | 
17 |     init(trackObject: TrackObject) {
18 |         id = trackObject.id ?? ""
19 |         trackerId = trackObject.trackerId ?? ""
20 |         mangaId = trackObject.mangaId ?? ""
21 |         sourceId = trackObject.sourceId ?? ""
22 |         title = trackObject.title
23 | 
24 |     }
25 | 
26 |     func toObject(context: NSManagedObjectContext? = nil) -> TrackObject {
27 |         let obj: TrackObject
28 |         if let context = context {
29 |             obj = TrackObject(context: context)
30 |         } else {
31 |             obj = TrackObject()
32 |         }
33 |         obj.id = id
34 |         obj.trackerId = trackerId
35 |         obj.mangaId = mangaId
36 |         obj.sourceId = sourceId
37 |         obj.title = title
38 |         return obj
39 |     }
40 | }
41 | 


--------------------------------------------------------------------------------
/Shared/Data/Database/Objects/HistoryObject.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  HistoryObject.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 1/27/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | import CoreData
10 | 
11 | @objc(HistoryObject)
12 | public class HistoryObject: NSManagedObject {
13 | 
14 |     public override func awakeFromInsert() {
15 |         super.awakeFromInsert()
16 |         dateRead = Date.distantPast
17 |         progress = -1
18 |         total = 0
19 |         completed = false
20 |     }
21 | }
22 | 
23 | extension HistoryObject {
24 | 
25 |     @nonobjc public class func fetchRequest() -> NSFetchRequest<HistoryObject> {
26 |         NSFetchRequest<HistoryObject>(entityName: "History")
27 |     }
28 | 
29 |     @NSManaged public var dateRead: Date?
30 |     @NSManaged public var sourceId: String
31 |     @NSManaged public var chapterId: String
32 |     @NSManaged public var mangaId: String
33 | 
34 |     @NSManaged public var progress: Int16
35 |     @NSManaged public var total: Int16
36 |     @NSManaged public var completed: Bool
37 | 
38 |     @NSManaged public var chapter: ChapterObject?
39 | }
40 | 
41 | extension HistoryObject: Identifiable {
42 | 
43 | }
44 | 


--------------------------------------------------------------------------------
/Shared/Data/Database/Objects/LibraryMangaObject.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  LibraryMangaObject.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 1/27/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | import CoreData
10 | 
11 | @objc(LibraryMangaObject)
12 | public class LibraryMangaObject: NSManagedObject {
13 |     public override func awakeFromInsert() {
14 |         super.awakeFromInsert()
15 |         lastOpened = Date()
16 |         lastUpdated = Date().addingTimeInterval(-5)
17 |         lastRead = Date()
18 |         dateAdded = Date()
19 |     }
20 | }
21 | 
22 | extension LibraryMangaObject {
23 | 
24 |     @nonobjc public class func fetchRequest() -> NSFetchRequest<LibraryMangaObject> {
25 |         NSFetchRequest<LibraryMangaObject>(entityName: "LibraryManga")
26 |     }
27 | 
28 |     @NSManaged public var lastOpened: Date
29 |     @NSManaged public var lastUpdated: Date
30 |     @NSManaged public var lastRead: Date?
31 |     @NSManaged public var dateAdded: Date
32 |     @NSManaged public var manga: MangaObject?
33 | 
34 |     @NSManaged public var categories: NSSet?
35 | 
36 | }
37 | 
38 | // MARK: Generated accessors for categories
39 | extension LibraryMangaObject {
40 | 
41 |     @objc(addCategoriesObject:)
42 |     @NSManaged public func addToCategories(_ value: CategoryObject)
43 | 
44 |     @objc(removeCategoriesObject:)
45 |     @NSManaged public func removeFromCategories(_ value: CategoryObject)
46 | 
47 | //    @objc(addCategories:)
48 | //    @NSManaged public func addToCategories(_ values: NSSet)
49 | 
50 | //    @objc(removeCategories:)
51 | //    @NSManaged public func removeFromCategories(_ values: NSSet)
52 | 
53 | }
54 | 
55 | extension LibraryMangaObject: Identifiable {
56 | 
57 | }
58 | 


--------------------------------------------------------------------------------
/Shared/Data/Database/Objects/MangaUpdateObject.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MangaUpdateObject.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by axiel7 on 09/02/2024.
 6 | //
 7 | 
 8 | import CoreData
 9 | 
10 | extension MangaUpdateObject {
11 |     public var id: String {
12 |         (sourceId ?? "") + (chapterId ?? "") + (mangaId ?? "")
13 |     }
14 | 
15 |     func toItem() -> MangaUpdateItem {
16 |         MangaUpdateItem(
17 |             sourceId: sourceId,
18 |             chapterId: chapterId,
19 |             mangaId: mangaId,
20 |             viewed: viewed
21 |         )
22 |     }
23 | }
24 | 
25 | struct MangaUpdateItem {
26 |     let sourceId: String?
27 |     let chapterId: String?
28 |     let mangaId: String?
29 |     let viewed: Bool
30 | }
31 | 


--------------------------------------------------------------------------------
/Shared/Data/Database/Objects/SourceObject.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SourceObject.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/14/22.
 6 | //
 7 | 
 8 | import AidokuRunner
 9 | import CoreData
10 | 
11 | extension SourceObject {
12 |     func load(from source: Source) {
13 |         id = source.id
14 |         apiVersion = source.apiVersion
15 |         path = source.url.pathComponents[source.url.pathComponents.count - 2..<source.url.pathComponents.count]
16 |             .joined(separator: "/")
17 |     }
18 | 
19 |     func load(from source: AidokuRunner.Source) {
20 |         id = source.id
21 |         apiVersion = source.apiVersion
22 |         if let url = source.url {
23 |             path = url.pathComponents[url.pathComponents.count - 2..<url.pathComponents.count]
24 |                 .joined(separator: "/")
25 |         }
26 |     }
27 | 
28 |     func toData() -> SourceObjectData {
29 |         .init(
30 |             objectID: objectID,
31 |             id: id ?? "",
32 |             apiVersion: apiVersion,
33 |             path: path,
34 |             listing: listing,
35 |             customSource: customSource
36 |         )
37 |     }
38 | }
39 | 
40 | struct SourceObjectData {
41 |     let objectID: NSManagedObjectID
42 |     let id: String
43 |     let apiVersion: String?
44 |     let path: String?
45 |     let listing: Int16
46 |     let customSource: NSObject?
47 | }
48 | 
49 | extension SourceObjectData {
50 |     func toSource() -> Source? {
51 |         if apiVersion == "0.6", let path {
52 |             return try? Source(from: FileManager.default.documentDirectory.appendingPathComponent(path))
53 |         }
54 |         return nil
55 |     }
56 | 
57 |     func toNewSource() async -> AidokuRunner.Source? {
58 |         if apiVersion == "0.6" {
59 |             let source = toSource()
60 |             return source.flatMap({ .legacy(source: $0) })
61 |         } else if
62 |             let data = customSource as? Data,
63 |             let config = try? CustomSourceConfig(from: data)
64 |         {
65 |             return config.toSource()
66 |         } else if let path {
67 |             let url = FileManager.default.documentDirectory.appendingPathComponent(path)
68 |             return try? await AidokuRunner.Source(id: id, url: url)
69 |         }
70 |         return nil
71 |     }
72 | }
73 | 


--------------------------------------------------------------------------------
/Shared/Data/Database/Objects/TrackObject.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackObject.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 7/20/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension TrackObject {
11 |     func toItem() -> TrackItem {
12 |         TrackItem(id: id ?? "", trackerId: trackerId ?? "", sourceId: sourceId ?? "", mangaId: mangaId ?? "", title: title)
13 |     }
14 | }
15 | 


--------------------------------------------------------------------------------
/Shared/Data/Downloads/Models/Download.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Download.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/14/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | enum DownloadStatus: Int, Codable {
11 |     case none = 0
12 |     case queued
13 |     case downloading
14 |     case paused
15 |     case cancelled
16 |     case finished
17 |     case failed
18 | }
19 | 
20 | struct Download: Equatable, Codable {
21 |     let sourceId: String
22 |     let mangaId: String
23 |     let chapterId: String
24 | 
25 |     var status: DownloadStatus = .queued
26 | 
27 |     var progress: Int = 0
28 |     var total: Int = 0
29 | 
30 |     var manga: Manga?
31 |     var chapter: Chapter?
32 | 
33 |     static func == (lhs: Download, rhs: Download) -> Bool {
34 |         lhs.sourceId == rhs.sourceId && lhs.mangaId == rhs.mangaId && lhs.chapterId == rhs.chapterId
35 |     }
36 | 
37 |     static func from(chapter: Chapter, status: DownloadStatus = .queued) -> Download {
38 |         Download(
39 |             sourceId: chapter.sourceId,
40 |             mangaId: chapter.mangaId,
41 |             chapterId: chapter.id,
42 |             status: status,
43 |             chapter: chapter
44 |         )
45 |     }
46 | 
47 |     func toChapter() -> Chapter {
48 |         if let chapter {
49 |             return chapter
50 |         } else {
51 |             return Chapter(
52 |                 sourceId: sourceId,
53 |                 id: chapterId,
54 |                 mangaId: mangaId,
55 |                 title: nil,
56 |                 sourceOrder: -1
57 |             )
58 |         }
59 |     }
60 | 
61 |     func toManga() -> Manga {
62 |         if let manga {
63 |             return manga
64 |         } else {
65 |             return Manga(
66 |                 sourceId: sourceId,
67 |                 id: mangaId
68 |             )
69 |         }
70 |     }
71 | }
72 | 


--------------------------------------------------------------------------------
/Shared/Extensions/Collection.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Collection.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Jim Phieffer on 5/14/22.
 6 | //
 7 | //  Credit to Nikita Kukushkin for this
 8 | //  https://stackoverflow.com/questions/25329186/safe-bounds-checked-array-lookup-in-swift-through-optional-bindings
 9 | //
10 | 
11 | import Foundation
12 | 
13 | extension Collection {
14 |     subscript (safe index: Index) -> Element? {
15 |         indices.contains(index) ? self[index] : nil
16 |     }
17 | }
18 | 


--------------------------------------------------------------------------------
/Shared/Extensions/Date.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Date.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/17/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension Date {
11 |     func dateString(format: String) -> String? {
12 |         let formatter = DateFormatter()
13 |         formatter.dateFormat = format
14 |         return formatter.string(from: self)
15 |     }
16 | }
17 | 
18 | // for komga extension
19 | extension Date {
20 | //    var year: Int {
21 | //        Calendar.current.component(.year, from: self)
22 | //    }
23 | 
24 |     static func firstOf(year: Int) -> Date? {
25 |         Calendar.current.date(from: DateComponents(year: year, month: 1, day: 1))
26 |     }
27 | 
28 |     static func lastOf(year: Int) -> Date? {
29 |         if let firstOfNextYear = Calendar.current.date(from: DateComponents(year: year + 1, month: 1, day: 1)) {
30 |             return Calendar.current.date(byAdding: .day, value: -1, to: firstOfNextYear)
31 |         }
32 |         return nil
33 |     }
34 | }
35 | 


--------------------------------------------------------------------------------
/Shared/Extensions/Dictionary.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Dictionary.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/17/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension Dictionary {
11 |     func percentEncoded() -> Data? {
12 |         map { key, value in
13 |             let escapedKey = "\(key)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
14 |             let escapedValue = "\(value)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
15 |             return escapedKey + "=" + escapedValue
16 |         }
17 |         .joined(separator: "&")
18 |         .data(using: .utf8)
19 |     }
20 | }
21 | 


--------------------------------------------------------------------------------
/Shared/Extensions/NSLocalizedString.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  NSLocalizedString.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 4/23/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | let fallbackBundle = Bundle.main.path(forResource: "en", ofType: "lproj")
11 |     .flatMap { Bundle(path: $0) }
12 | 
13 | // falls back to english for localized strings
14 | func NSLocalizedString(
15 |     _ key: String,
16 |     tableName _: String? = nil,
17 |     bundle _: Bundle = Bundle.main,
18 |     value _: String = "",
19 |     comment: String = ""
20 | ) -> String {
21 |     guard let fallbackBundle else { return key }
22 |     let fallbackString = fallbackBundle.localizedString(forKey: key, value: comment, table: nil)
23 |     return Bundle.main.localizedString(forKey: key, value: fallbackString, table: nil)
24 | }
25 | 


--------------------------------------------------------------------------------
/Shared/Extensions/NSPersistentContainer.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  NSPersistentContainer.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/12/22.
 6 | //
 7 | 
 8 | import CoreData
 9 | 
10 | extension NSPersistentContainer {
11 |     func performBackgroundTask<T: Sendable>(_ block: @escaping (NSManagedObjectContext) -> T) async -> T {
12 |         await withCheckedContinuation({ continuation in
13 |             self.performBackgroundTask { context in
14 |                 let result = block(context)
15 |                 continuation.resume(returning: result)
16 |             }
17 |         })
18 |     }
19 | }
20 | 


--------------------------------------------------------------------------------
/Shared/Extensions/NotificationName.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  NotificationName.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 4/29/25.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension Notification.Name {
11 |     static let updateSourceLists = Self("updateSourceLists")
12 | 
13 |     // manga
14 |     static let addToLibrary = Self("addToLibrary")
15 |     static let migratedManga = Self("migratedManga")
16 | 
17 |     // history
18 |     static let updateHistory = Self("updateHistory")
19 |     static let historyAdded = Self("historyAdded")
20 |     static let historyRemoved = Self("historyRemoved")
21 |     static let historySet = Self("historySet")
22 | 
23 |     // trackers
24 |     static let updateTrackers = Self("updateTrackers")
25 |     static let trackItemAdded = Self("trackItemAdded")
26 |     static let syncTrackItem = Self("syncTrackItem")
27 | 
28 |     // downloads
29 |     static let downloadProgressed = Self("downloadProgressed")
30 |     static let downloadFinished = Self("downloadFinished")
31 |     static let downloadRemoved = Self("downloadRemoved")
32 |     static let downloadCancelled = Self("downloadCancelled")
33 |     static let downloadsRemoved = Self("downloadsRemoved")
34 |     static let downloadsCancelled = Self("downloadsCancelled")
35 |     static let downloadsQueued = Self("downloadsQueued")
36 | 
37 |     // browse
38 |     static let browseLanguages = Self("Browse.languages")
39 | 
40 |     // settings
41 |     static let portraitRowsSetting = Self("General.portraitRows")
42 |     static let landscapeRowsSetting = Self("General.landscapeRows")
43 | }
44 | 


--------------------------------------------------------------------------------
/Shared/Extensions/Reachability.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Reachability.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/1/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SystemConfiguration
10 | import Network
11 | 
12 | enum NetworkDataType {
13 |     case none
14 |     case cellular
15 |     case wifi
16 | }
17 | 
18 | final class Reachability {
19 |     private static var observers: [UUID: NWPathMonitor] = [:]
20 |     private static let queue = DispatchQueue(label: "ReachabilityMonitorQueue")
21 | 
22 |     static func getConnectionType() -> NetworkDataType {
23 |         guard let reachability = SCNetworkReachabilityCreateWithName(kCFAllocatorDefault, "www.apple.com/library/test/success.html") else {
24 |             return .none
25 |         }
26 | 
27 |         var flags = SCNetworkReachabilityFlags()
28 |         SCNetworkReachabilityGetFlags(reachability, &flags)
29 | 
30 |         guard flags.contains(.reachable) else { return .none }
31 | 
32 |         #if os(OSX)
33 |             return .wifi
34 |         #else
35 |             return flags.contains(.isWWAN) ? .cellular : .wifi
36 |         #endif
37 |     }
38 | 
39 |     static func registerConnectionTypeObserver(
40 |         _ handle: @escaping (NetworkDataType) -> Void,
41 |         queue: DispatchQueue = .main
42 |     ) -> UUID {
43 |         let monitor = NWPathMonitor()
44 | 
45 |         monitor.pathUpdateHandler = { path in
46 |             let connectionType: NetworkDataType
47 |             if path.usesInterfaceType(.wifi) || path.usesInterfaceType(.wiredEthernet) {
48 |                 connectionType = .wifi
49 |             } else if path.usesInterfaceType(.cellular) {
50 |                 connectionType = .cellular
51 |             } else {
52 |                 connectionType = .none
53 |             }
54 |             queue.async {
55 |                 handle(connectionType)
56 |             }
57 |         }
58 | 
59 |         monitor.start(queue: self.queue)
60 | 
61 |         let id = UUID()
62 |         observers[id] = monitor
63 |         return id
64 |     }
65 | 
66 |     static func unregisterConnectionTypeObserver(_ id: UUID) {
67 |         observers[id]?.cancel()
68 |         observers.removeValue(forKey: id)
69 |     }
70 | }
71 | 


--------------------------------------------------------------------------------
/Shared/Extensions/Sequence.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Sequence.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 1/3/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension Sequence where Self: Sendable, Element: Sendable {
11 |     func concurrentMap<T: Sendable>(
12 |         _ transform: @escaping (Element) async throws -> T
13 |     ) async rethrows -> [T] {
14 |         let tasks = map { element in
15 |             Task {
16 |                 try await transform(element)
17 |             }
18 |         }
19 |         return try await tasks.asyncMap { task in
20 |             try await task.value
21 |         }
22 |     }
23 | }
24 | 
25 | extension Sequence where Element: Sendable {
26 |     func asyncMap<T>(
27 |         _ transform: (Element) async throws -> T
28 |     ) async rethrows -> [T] {
29 |         var values = [T]()
30 | 
31 |         for element in self {
32 |             try await values.append(transform(element))
33 |         }
34 | 
35 |         return values
36 |     }
37 | 
38 |     func asyncCompactMap<T>(
39 |         _ transform: (Element) async throws -> T?
40 |     ) async rethrows -> [T] {
41 |         var values = [T]()
42 | 
43 |         for element in self {
44 |             let result = try await transform(element)
45 |             if let result {
46 |                 values.append(result)
47 |             }
48 |         }
49 | 
50 |         return values
51 |     }
52 | 
53 | //    func asyncForEach(
54 | //        _ operation: (Element) async throws -> Void
55 | //    ) async rethrows {
56 | //        for element in self {
57 | //            try await operation(element)
58 | //        }
59 | //    }
60 | 
61 | //    func concurrentForEach(
62 | //        _ operation: @escaping (Element) async -> Void
63 | //    ) async {
64 | //        // A task group automatically waits for all of its
65 | //        // sub-tasks to complete, while also performing those
66 | //        // tasks in parallel:
67 | //        await withTaskGroup(of: Void.self) { group in
68 | //            for element in self {
69 | //                group.addTask {
70 | //                    await operation(element)
71 | //                }
72 | //            }
73 | //        }
74 | //    }
75 | }
76 | 
77 | extension Sequence where Iterator.Element: Hashable {
78 |     func unique() -> [Iterator.Element] {
79 |         var seen: Set<Iterator.Element> = []
80 |         return filter { seen.insert($0).inserted }
81 |     }
82 | }
83 | 


--------------------------------------------------------------------------------
/Shared/Extensions/String.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  String.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/25/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension String {
11 |     func take(first: Int) -> String {
12 |         first < count ? String(self[self.startIndex..<self.index(self.startIndex, offsetBy: first)]) : self
13 |     }
14 | 
15 | //    func take(last: Int) -> String {
16 | //        last < count ? String(self[self.index(self.endIndex, offsetBy: -last)..<self.endIndex]) : self
17 | //    }
18 | //
19 | //    func drop(first: Int) -> String {
20 | //        first < count ? String(self[self.index(self.startIndex, offsetBy: first)..<self.endIndex]) : ""
21 | //    }
22 | //
23 | //    func drop(last: Int) -> String {
24 | //        last < count ? String(self[self.startIndex..<self.index(self.endIndex, offsetBy: -last)]) : ""
25 | //    }
26 | 
27 |     func date(format: String) -> Date? {
28 |         let formatter = DateFormatter()
29 |         formatter.dateFormat = format
30 |         return formatter.date(from: self)
31 |     }
32 | 
33 |     func fuzzyMatch(_ pattern: String) -> Bool? {
34 |         if pattern.isEmpty { return false }
35 |         var rem = pattern[...]
36 |         for char in self where char == rem[rem.startIndex] {
37 |             rem.removeFirst()
38 |             if rem.isEmpty { return true }
39 |         }
40 |         return false
41 |     }
42 | }
43 | 
44 | extension String {
45 |     func removingExtension() -> String {
46 |         if let idx = lastIndex(of: ".") {
47 |             String(self[..<idx])
48 |         } else {
49 |             self
50 |         }
51 |     }
52 | }
53 | 


--------------------------------------------------------------------------------
/Shared/Extensions/URL.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  URL.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/17/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension URL {
11 |     var queryParameters: [String: String]? {
12 |         get {
13 |             guard
14 |                 let components = URLComponents(url: self, resolvingAgainstBaseURL: true),
15 |                 let queryItems = components.queryItems
16 |             else { return nil }
17 |             return queryItems.reduce(into: [String: String]()) { result, item in
18 |                 result[item.name] = item.value
19 |             }
20 |         }
21 |         set {
22 |             var components = URLComponents(url: self, resolvingAgainstBaseURL: true)
23 |             components?.queryItems = newValue?.map {
24 |                 URLQueryItem(name: $0.key, value: $0.value)
25 |             }
26 |             if let url = components?.url {
27 |                 self = url
28 |             }
29 |         }
30 |     }
31 | }
32 | 


--------------------------------------------------------------------------------
/Shared/Localization/en.lproj/InfoPlist.strings:
--------------------------------------------------------------------------------
 1 | /* 
 2 |   InfoPlist.strings
 3 |   Aidoku
 4 | 
 5 |   Created by Skitty on 4/24/22.
 6 |   
 7 | */
 8 | 
 9 | "CFBundleDisplayName" = "Aidoku";
10 | 


--------------------------------------------------------------------------------
/Shared/Localization/ja.lproj/InfoPlist.strings:
--------------------------------------------------------------------------------
 1 | /* 
 2 |   InfoPlist.strings
 3 |   Aidoku
 4 | 
 5 |   Created by Skitty on 4/24/22.
 6 |   
 7 | */
 8 | 
 9 | "CFBundleDisplayName" = "愛読";
10 | 


--------------------------------------------------------------------------------
/Shared/Localization/ml.lproj/Localizable.strings:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | "UNKNOWN" = "അജ്ഞാതം";
 4 | "DOWNLOAD_QUEUE" = "ഡൗൺലോഡ് ക്യൂ";
 5 | // General
 6 | "CONTINUE" = "തുടരുക";
 7 | "CANCEL" = "റദ്ദാക്കുക";
 8 | "UNAVAILABLE" = "ലഭ്യമല്ല";
 9 | "RESET" = "പുനഃസജ്ജമാക്കുക";
10 | "RENAME" = "പേരുമാറ്റുക";
11 | "DELETE" = "ഇല്ലാതാക്കുക";
12 | "REMOVE" = "നീക്കം ചെയ്യുക";
13 | 
14 | // Downloads
15 | "DOWNLOAD" = "ഡൗൺലോഡ്";
16 | "DOWNLOADED" = "ഡൗൺലോഡ് ചെയ്തു";
17 | "OK" = "ശരി";
18 | "SHARE" = "പങ്കിടുക";
19 | "LOADING_ELLIPSIS" = "ലോഡുചെയ്യുന്നു…";
20 | "PAUSE" = "വിരാമം";
21 | "RESUME" = "പുനരാരംഭിക്കുക";
22 | "COPY" = "പകർത്തുക";
23 | "WARNING" = "മുന്നറിയിപ്പ്";
24 | "CANCEL_DOWNLOAD" = "ഡൗൺലോഡ് റദ്ദാക്കുക";
25 | "REMOVE_DOWNLOAD" = "ഡൗൺലോഡ് നീക്കം ചെയ്യുക";
26 | "CLEAR" = "നീക്കുക";
27 | "REMOVE_ALL_DOWNLOADS" = "എല്ലാ ഡൗൺലോഡുകളും നീക്കുക";
28 | "REMOVE_DOWNLOADS" = "ഡൗൺലോഡുകൾ നീക്കുക";
29 | "REMOVE_DOWNLOADS_CONFIRM" = "എല്ലാ തിരഞ്ഞെടുക്കപ്പെട്ട ഡൗൺലോഡുകളും നീക്കണമോ?";
30 | "NO_WIFI_ALERT_TITLE" = "വൈ-ഫൈ ഇല്ല";
31 | "LIBRARY" = "ലൈബ്രറി";
32 | "LIBRARY_SEARCH" = "ലൈബ്രറിയിൽ തിരയുക";
33 | "LIBRARY_EMPTY" = "ലൈബ്രറി ശൂന്യമാണ്";
34 | "MANGA_INFO" = "മാംഗ വിവരങ്ങൾ";
35 | "ADD_TO_LIBRARY" = "ലൈബ്രറിയിലേക്ക് ചേർക്കുക";
36 | "NO_WIFI_ALERT_MESSAGE" = "ഈ ഉള്ളടക്കം ഡൗൺലോഡ് ചെയ്യാൻ നിങ്ങൾ വൈഫൈയിലേക്ക് കണക്റ്റ് ആയിരിക്കണം. ദയവായി വൈഫൈയിലേക്ക് കണക്റ്റ് ചെയ്ത് വീണ്ടും ശ്രമിക്കുക.";
37 | "REMOVE_FROM_LIBRARY" = "ലൈബ്രറിയിൽ നിന്ന് നീക്കം ചെയ്യുക";
38 | "DOWNLOAD_ANYWAY_MESSAGE" = "നിങ്ങൾ ഇപ്പോൾ വൈഫൈയിൽ ഇല്ല. എങ്കിലും ഡൗൺലോഡ് ചെയ്യണോ?";
39 | "LIBRARY_ADD_FROM_BROWSE" = "ബ്രൗസ് ടാബിൽ നിന്ന് മാംഗ ചേർക്കുക";
40 | 


--------------------------------------------------------------------------------
/Shared/Localization/sw.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | 
2 | 
3 | // General
4 | "CONTINUE" = "kuendelea";
5 | 


--------------------------------------------------------------------------------
/Shared/Logging/LogManager.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  LogManager.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/24/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | class LogManager {
11 | 
12 |     static let logger = Logger(streamUrl: URL(string: UserDefaults.standard.string(forKey: "Logs.logServer") ?? ""))
13 | 
14 |     static let directory = FileManager.default.documentDirectory.appendingPathComponent("Logs", isDirectory: true)
15 | 
16 |     static func export(to fileUrl: URL? = nil) -> URL {
17 |         Self.directory.createDirectory()
18 |         let dateFormatter = DateFormatter()
19 |         dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
20 |         let url = fileUrl ?? Self.directory.appendingPathComponent("log_\(dateFormatter.string(from: Date())).txt")
21 |         Self.logger.store.export(to: url)
22 |         return url
23 |     }
24 | }
25 | 
26 | // func log(_ items: Any...) {
27 | //     LogManager.logger.log(items.map { String(describing: $0) }.joined(separator: " "))
28 | // }
29 | 


--------------------------------------------------------------------------------
/Shared/Logging/LogStore.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  LogStore.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/24/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | struct LogEntry {
11 |     let date: Date
12 |     let type: LogType
13 |     let message: String
14 | 
15 |     func formatted() -> String {
16 |         let dateFormatter = DateFormatter()
17 |         dateFormatter.dateFormat = "MM/dd HH:mm:ss.SSS"
18 |         return "[\(dateFormatter.string(from: date))] \(type != .default ? "[\(type.toString())] " : "")\(message)"
19 |     }
20 | }
21 | 
22 | class LogStore {
23 | 
24 |     var entries: [LogEntry] = []
25 | 
26 |     var observers: [UUID: (LogEntry) -> Void] = [:]
27 | 
28 |     func addEntry(level: LogType, message: String) {
29 |         let entry = LogEntry(date: Date(), type: level, message: message)
30 |         entries.append(entry)
31 |         for observer in observers {
32 |             observer.value(entry)
33 |         }
34 |     }
35 | 
36 |     func clear() {
37 |         entries = []
38 |     }
39 | 
40 |     func export(to fileUrl: URL) {
41 |         let string = entries
42 |             .map { $0.formatted() }
43 |             .joined(separator: "\n")
44 |         do {
45 |             try string.write(to: fileUrl, atomically: true, encoding: .utf8)
46 |         } catch {
47 |             LogManager.logger.error("Failed to export log store \(error.localizedDescription)")
48 |         }
49 |     }
50 | 
51 |     @discardableResult
52 |     func addObserver(_ block: @escaping (LogEntry) -> Void) -> UUID {
53 |         let id = UUID()
54 |         observers[id] = block
55 |         return id
56 |     }
57 | 
58 |     func removeObserver(id: UUID) {
59 |         observers.removeValue(forKey: id)
60 |     }
61 | }
62 | 


--------------------------------------------------------------------------------
/Shared/Logging/Logger.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Logger.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/24/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | enum LogType {
11 |     case `default`
12 |     case info
13 |     case debug
14 |     case warning
15 |     case error
16 | 
17 |     func toString() -> String {
18 |         switch self {
19 |         case .default:
20 |             return ""
21 |         case .info:
22 |             return "INFO"
23 |         case .debug:
24 |             return "DEBUG"
25 |         case .warning:
26 |             return "WARN"
27 |         case .error:
28 |             return "ERROR"
29 |         }
30 |     }
31 | }
32 | 
33 | class Logger {
34 | 
35 |     let store: LogStore
36 | 
37 |     var printLogs = true
38 | 
39 |     private var streamObserverId: UUID?
40 |     var streamUrl: URL? {
41 |         didSet {
42 |             updateStreamUrl()
43 |         }
44 |     }
45 | 
46 |     deinit {
47 |         if let streamObserverId = streamObserverId {
48 |             store.removeObserver(id: streamObserverId)
49 |         }
50 |     }
51 | 
52 |     init(store: LogStore = LogStore(), streamUrl: URL? = nil) {
53 |         self.store = store
54 |         self.streamUrl = streamUrl
55 |         updateStreamUrl()
56 |     }
57 | 
58 |     private func updateStreamUrl() {
59 |         if let oldId = streamObserverId { store.removeObserver(id: oldId) }
60 |         if let newUrl = streamUrl {
61 |             streamObserverId = store.addObserver { entry in
62 |                 Task {
63 |                     var request = URLRequest(url: newUrl)
64 |                     request.httpBody = entry.formatted().data(using: .utf8)
65 |                     request.httpMethod = "POST"
66 |                     _ = try? await URLSession.shared.data(for: request)
67 |                 }
68 |             }
69 |         } else {
70 |             streamObserverId = nil
71 |         }
72 |     }
73 | 
74 |     func log(level: LogType = .default, _ message: String) {
75 |         if printLogs {
76 |             let prefix = level != .default ? "[\(level.toString())] " : ""
77 |             print("\(prefix)\(message)")
78 |         }
79 |         store.addEntry(level: level, message: message)
80 |     }
81 | 
82 |     func debug(_ message: String) {
83 |         log(level: .debug, message)
84 |     }
85 | 
86 |     func info(_ message: String) {
87 |         log(level: .info, message)
88 |     }
89 | 
90 |     func warn(_ message: String) {
91 |         log(level: .warning, message)
92 |     }
93 | 
94 |     func error(_ message: String) {
95 |         log(level: .error, message)
96 |     }
97 | }
98 | 


--------------------------------------------------------------------------------
/Shared/Managers/Manga/MangaUpdateManager.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MangaUpdateManager.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by axiel7 on 17/03/2024.
 6 | //
 7 | 
 8 | import CoreData
 9 | 
10 | class MangaUpdateManager {
11 | 
12 |     static let shared = MangaUpdateManager()
13 | }
14 | 
15 | extension MangaUpdateManager {
16 | 
17 |     func viewAllUpdates(of manga: Manga) async {
18 |         await CoreDataManager.shared.container.performBackgroundTask { context in
19 |             let updates = CoreDataManager.shared.setMangaUpdatesViewed(
20 |                 sourceId: manga.sourceId,
21 |                 mangaId: manga.id,
22 |                 context: context
23 |             )
24 |             if !updates.isEmpty {
25 |                 NotificationCenter.default.post(name: NSNotification.Name("mangaUpdatesViewed"), object: updates.map { $0.toItem() })
26 |             }
27 |         }
28 |     }
29 | }
30 | 


--------------------------------------------------------------------------------
/Shared/Models/ChapterFilterOption.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ChapterFilterOption.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/14/24.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | struct ChapterFilterOption: Hashable {
11 |     var type: ChapterFilterMethod
12 |     var exclude: Bool
13 | 
14 |     static func parseOptions(flags: Int) -> [ChapterFilterOption] {
15 |         let downloaded = flags & ChapterFlagMask.downloadFilterEnabled != 0
16 |         let unread = flags & ChapterFlagMask.unreadFilterEnabled != 0
17 |         let locked = flags & ChapterFlagMask.lockedFilterEnabled != 0
18 |         var result: [ChapterFilterOption] = []
19 |         if downloaded {
20 |             result.append(.init(type: .downloaded, exclude: flags & ChapterFlagMask.downloadFilterExcluded != 0))
21 |         }
22 |         if unread {
23 |             result.append(.init(type: .unread, exclude: flags & ChapterFlagMask.unreadFilterExcluded != 0))
24 |         }
25 |         if locked {
26 |             result.append(.init(type: .locked, exclude: flags & ChapterFlagMask.lockedFilterExcluded != 0))
27 |         }
28 |         return result
29 |     }
30 | }
31 | 
32 | enum ChapterFilterMethod: CaseIterable, Hashable {
33 |     case downloaded
34 |     case unread
35 |     case locked
36 | 
37 |     var stringValue: String {
38 |         switch self {
39 |             case .downloaded: NSLocalizedString("DOWNLOADED", comment: "")
40 |             case .unread: NSLocalizedString("UNREAD", comment: "")
41 |             case .locked: NSLocalizedString("LOCKED", comment: "")
42 |         }
43 |     }
44 | }
45 | 


--------------------------------------------------------------------------------
/Shared/Models/ChapterFlagMask.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ChapterFlagMask.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/14/24.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | // chapter flags are stored in an int16
11 | // ascending:     0b0000000000000001
12 | // sort:          0b0000000000001110 (only 3 options are used, but we have room for more in the future)
13 | // dwnld filter:  0b0000000000110000
14 | // unread filter: 0b0000000011000000
15 | // locked filter: 0b0000001100000000
16 | struct ChapterFlagMask {
17 |     static let sortAscending: Int = 1
18 |     static let sortMethod: Int = 0b1110
19 |     static let downloadFilterEnabled: Int = 1 << 4
20 |     static let downloadFilterExcluded: Int = 1 << 5
21 |     static let unreadFilterEnabled: Int = 1 << 6
22 |     static let unreadFilterExcluded: Int = 1 << 7
23 |     static let lockedFilterEnabled: Int = 1 << 8
24 |     static let lockedFilterExcluded: Int = 1 << 9
25 | }
26 | 


--------------------------------------------------------------------------------
/Shared/Models/ChapterSortOption.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ChapterSortOption.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/14/24.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | enum ChapterSortOption: Int, CaseIterable {
11 |     case sourceOrder = 0
12 |     case chapter
13 |     case uploadDate
14 | 
15 |     init(flags: Int) {
16 |         let option = (flags & ChapterFlagMask.sortMethod) >> 1
17 |         self = ChapterSortOption(rawValue: option) ?? .sourceOrder
18 |     }
19 | 
20 |     var stringValue: String {
21 |         switch self {
22 |         case .sourceOrder: NSLocalizedString("SOURCE_ORDER", comment: "")
23 |         case .chapter: NSLocalizedString("CHAPTER", comment: "")
24 |         case .uploadDate: NSLocalizedString("UPLOAD_DATE", comment: "")
25 |         }
26 |     }
27 | }
28 | 


--------------------------------------------------------------------------------
/Shared/Models/MangaInfo.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MangaInfo.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 8/7/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | struct MangaInfo: Hashable, Sendable {
11 |     let mangaId: String
12 |     let sourceId: String
13 | 
14 |     var coverUrl: URL?
15 |     var title: String?
16 |     var author: String?
17 | 
18 |     var url: URL?
19 | 
20 |     var unread: Int = 0
21 | 
22 |     func toManga() -> Manga {
23 |         Manga(
24 |             sourceId: sourceId,
25 |             id: mangaId,
26 |             title: title,
27 |             author: author,
28 |             coverUrl: coverUrl,
29 |             url: url
30 |         )
31 |     }
32 | }
33 | 


--------------------------------------------------------------------------------
/Shared/Models/SourceInfo.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SourceInfo.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 12/30/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | import AidokuRunner
10 | 
11 | struct SourceInfo2: Hashable {
12 |     let sourceId: String
13 | 
14 |     var iconUrl: URL?
15 |     var name: String
16 |     var altNames: [String] = []
17 |     var languages: [String]
18 |     var version: Int
19 | 
20 |     var contentRating: SourceContentRating
21 | 
22 |     var externalInfo: ExternalSourceInfo?
23 | 
24 |     var isMultiLanguage: Bool {
25 |         languages.isEmpty || languages.count > 1 || languages.first == "multi"
26 |     }
27 | }
28 | 


--------------------------------------------------------------------------------
/Shared/Models/SourceList.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SourceList.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/11/25.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | struct SourceList: Equatable {
11 |     let url: URL
12 |     let name: String
13 |     var feedbackURL: URL?
14 |     let sources: [ExternalSourceInfo]
15 | 
16 |     static func == (lhs: Self, rhs: Self) -> Bool {
17 |         lhs.url == rhs.url
18 |     }
19 | }
20 | 
21 | struct CodableSourceList: Codable {
22 |     let name: String
23 |     let feedbackURL: String?
24 |     let sources: [ExternalSourceInfo]
25 | 
26 |     func into(url: URL) -> SourceList {
27 |         .init(
28 |             url: url,
29 |             name: name,
30 |             feedbackURL: feedbackURL.flatMap { URL(string: $0) },
31 |             sources: sources.map {
32 |                 $0.with(sourceUrl: url)
33 |             }
34 |         )
35 |     }
36 | }
37 | 


--------------------------------------------------------------------------------
/Shared/Old Models/DeepLink.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  DeepLink.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 4/18/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | struct DeepLink {
11 |     var manga: Manga?
12 |     var chapter: Chapter?
13 | }
14 | 


--------------------------------------------------------------------------------
/Shared/Old Models/KVCObject.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  KVCObject.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 1/14/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | protocol KVCObject {
11 |     func valueByPropertyName(name: String) -> Any?
12 | }
13 | 


--------------------------------------------------------------------------------
/Shared/Old Models/Listing.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Listing.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 1/14/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | struct Listing: KVCObject, Hashable, Codable {
11 |     var name: String
12 |     var flags: Int32 = 0 // currently unused
13 | 
14 |     init(name: String) {
15 |         self.name = name
16 |     }
17 | 
18 |     init(from decoder: Decoder) throws {
19 |         let container = try decoder.container(keyedBy: CodingKeys.self)
20 |         self.name = try container.decode(String.self, forKey: .name)
21 |         if let flags = try container.decodeIfPresent(Int32.self, forKey: .flags) {
22 |             self.flags = flags
23 |         }
24 |     }
25 | 
26 |     func valueByPropertyName(name: String) -> Any? {
27 |         switch name {
28 |         case "name": return self.name
29 |         case "flags": return flags
30 |         default: return nil
31 |         }
32 |     }
33 | }
34 | 


--------------------------------------------------------------------------------
/Shared/Old Models/Page.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Page.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 12/22/21.
 6 | //
 7 | 
 8 | import Foundation
 9 | import AidokuRunner
10 | 
11 | struct Page: Hashable {
12 | 
13 |     enum PageType: Int {
14 |         case imagePage
15 |         case prevInfoPage
16 |         case nextInfoPage
17 |     }
18 | 
19 |     var type: PageType = .imagePage
20 |     var sourceId: String
21 |     var chapterId: String
22 |     var index: Int = 0
23 |     var imageURL: String?
24 |     var base64: String?
25 |     var text: String?
26 |     var image: PlatformImage?
27 |     var zipURL: String?
28 | 
29 |     var context: PageContext?
30 |     var hasDescription: Bool = false
31 |     var description: String?
32 | 
33 |     var key: String {
34 |         "\(chapterId)|\(index)"
35 |     }
36 | 
37 |     func hash(into hasher: inout Hasher) {
38 |         hasher.combine(chapterId)
39 |         hasher.combine(index)
40 |     }
41 | }
42 | 
43 | extension Page {
44 |     func toNew() -> AidokuRunner.Page {
45 |         let content: AidokuRunner.PageContent = if let imageURL, let url = URL(string: imageURL) {
46 |             .url(url: url, context: context)
47 |         } else if let text {
48 |             .text(text)
49 |         } else if let image {
50 | #if os(macOS)
51 |             .image(AidokuRunner.PlatformImage(image))
52 | #else
53 |             .image(image)
54 | #endif
55 |         } else if let zipURL, let url = URL(string: zipURL), let imageURL {
56 |             .zipFile(url: url, filePath: imageURL)
57 |         } else {
58 |             .text("Invalid URL")
59 |         }
60 |         return AidokuRunner.Page(
61 |             content: content,
62 |             hasDescription: hasDescription,
63 |             description: description
64 |         )
65 |     }
66 | }
67 | 


--------------------------------------------------------------------------------
/Shared/Sources/ExternalSourceInfo.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ExternalSourceInfo.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 1/16/22.
 6 | //
 7 | 
 8 | import AidokuRunner
 9 | import Foundation
10 | 
11 | struct ExternalSourceInfo: Codable, Hashable {
12 |     let id: String
13 |     let name: String
14 |     let version: Int
15 |     let iconURL: String?
16 |     let downloadURL: String?
17 |     let languages: [String]?
18 |     let contentRating: AidokuRunner.SourceContentRating?
19 |     let altNames: [String]?
20 |     let baseURL: String?
21 |     let minAppVersion: String?
22 |     let maxAppVersion: String?
23 | 
24 |     // deprecated
25 |     let lang: String?
26 |     let nsfw: Int?
27 |     let file: String?
28 |     let icon: String?
29 | 
30 |     var sourceUrl: URL?
31 | 
32 |     var fileURL: URL? {
33 |         sourceUrl.flatMap { sourceUrl in
34 |             if let downloadURL {
35 |                 URL(string: downloadURL, relativeTo: sourceUrl)
36 |             } else if let file {
37 |                 URL(string: "sources/\(file)", relativeTo: sourceUrl)
38 |             } else {
39 |                 nil
40 |             }
41 |         }
42 |     }
43 | 
44 |     var resolvedContentRating: AidokuRunner.SourceContentRating {
45 |         if let contentRating {
46 |             contentRating
47 |         } else if let nsfw, let rating = AidokuRunner.SourceContentRating(rawValue: nsfw) {
48 |             rating
49 |         } else {
50 |             .safe
51 |         }
52 |     }
53 | }
54 | 
55 | extension ExternalSourceInfo {
56 |     func with(sourceUrl: URL) -> ExternalSourceInfo {
57 |         var copy = self
58 |         copy.sourceUrl = sourceUrl
59 |         return copy
60 |     }
61 | 
62 |     func toInfo() -> SourceInfo2 {
63 |         let iconUrl: URL? = sourceUrl.flatMap { sourceUrl in
64 |             if let iconURL {
65 |                 URL(string: iconURL, relativeTo: sourceUrl)
66 |             } else if let icon {
67 |                 URL(string: "icons/\(icon)", relativeTo: sourceUrl)
68 |             } else {
69 |                 nil
70 |             }
71 |         }
72 |         return .init(
73 |             sourceId: id,
74 |             iconUrl: iconUrl,
75 |             name: name,
76 |             altNames: altNames ?? [],
77 |             languages: languages ?? lang.flatMap { [$0] } ?? [],
78 |             version: version,
79 |             contentRating: resolvedContentRating,
80 |             externalInfo: self
81 |         )
82 |     }
83 | }
84 | 


--------------------------------------------------------------------------------
/Shared/Sources/Local/LocalModels.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  LocalModels.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/10/25.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | enum LocalFileManagerError: Error {
11 |     case invalidFileType
12 |     case securityScopeDenied
13 |     case tempDirectoryUnavailable
14 |     case cannotReadArchive
15 |     case noImagesFound
16 |     case fileCopyFailed
17 | }
18 | 
19 | struct LocalSeriesInfo: Hashable {
20 |     let coverUrl: String
21 |     let name: String
22 |     let chapterCount: Int
23 | }
24 | 
25 | enum LocalFileType {
26 |     case cbz
27 |     case zip
28 | 
29 |     var localizedName: String {
30 |         switch self {
31 |             case .cbz: NSLocalizedString("CBZ_NAME")
32 |             case .zip: NSLocalizedString("ZIP_NAME")
33 |         }
34 |     }
35 | }
36 | 
37 | struct ImportFileInfo: Hashable {
38 |     let url: URL
39 |     let previewImages: [PlatformImage]
40 |     let name: String
41 |     let pageCount: Int
42 |     let fileType: LocalFileType
43 | }
44 | 


--------------------------------------------------------------------------------
/Shared/Sources/SettingItem.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SettingItem.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/17/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | // types: group, select, multi-select, switch, stepper, segment, text, page, button, link, login
11 | // TODO: slider
12 | struct SettingItem: Codable {
13 |     var type: String
14 | 
15 |     var key: String?
16 |     var action: String?
17 |     var title: String?
18 |     var subtitle: String?
19 |     var footer: String?
20 |     var placeholder: String?
21 |     var values: [String]?
22 |     var titles: [String]?
23 |     var defaultValue: JsonAnyValue?
24 |     var notification: String?
25 | 
26 |     var requires: String?
27 |     var requiresFalse: String?
28 | 
29 |     var authToEnable: Bool?
30 |     var authToDisable: Bool?
31 |     var authToOpen: Bool?
32 | 
33 |     // stepper
34 |     var minimumValue: Double?
35 |     var maximumValue: Double?
36 |     var stepValue: Double?
37 | 
38 |     var url: String?
39 |     var destructive: Bool? // button
40 |     var external: Bool? // link
41 | 
42 |     var items: [SettingItem]? // group, page
43 | 
44 |     // text
45 |     var autocapitalizationType: Int?
46 |     var autocorrectionType: Int?
47 |     var spellCheckingType: Int?
48 |     var keyboardType: Int?
49 |     var returnKeyType: Int?
50 | 
51 |     // login
52 |     var logoutTitle: String?
53 |     var urlKey: String?
54 |     var method: String? // "oauth"
55 | 
56 |     enum CodingKeys: String, CodingKey {
57 |         case type
58 | 
59 |         case key
60 |         case action
61 |         case title
62 |         case subtitle
63 |         case footer
64 |         case placeholder
65 |         case values
66 |         case titles
67 |         case defaultValue = "default"
68 |         case notification
69 | 
70 |         case requires
71 |         case requiresFalse
72 | 
73 |         case authToEnable
74 |         case authToDisable
75 |         case authToOpen
76 | 
77 |         case minimumValue
78 |         case maximumValue
79 |         case stepValue
80 | 
81 |         case url
82 |         case destructive
83 |         case external
84 | 
85 |         case items
86 | 
87 |         case autocapitalizationType
88 |         case autocorrectionType
89 |         case spellCheckingType
90 |         case keyboardType
91 |         case returnKeyType
92 | 
93 |         case logoutTitle
94 |         case urlKey
95 |         case method
96 |     }
97 | }
98 | 


--------------------------------------------------------------------------------
/Shared/Sources/UserAgentProvider.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UserAgentProvider.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 3/24/25.
 6 | //
 7 | 
 8 | import WebKit
 9 | 
10 | class UserAgentProvider {
11 |     static let shared = UserAgentProvider()
12 | 
13 |     private var task: Task<String?, Never>?
14 |     private var userAgent: String?
15 | 
16 |     private init() {
17 |         // start fetching user agent immediately
18 |         task = Task {
19 |             await fetchUserAgent()
20 |         }
21 |     }
22 | 
23 |     @MainActor
24 |     private func fetchUserAgent() async -> String? {
25 |         let webView = WKWebView()
26 |         do {
27 |             let userAgent = try await webView.evaluateJavaScript("navigator.userAgent") as? String
28 |             self.userAgent = userAgent
29 |             return userAgent
30 |         } catch {
31 |             LogManager.logger.error("Error getting user agent: \(error)")
32 |             return nil
33 |         }
34 |     }
35 | 
36 |     func getUserAgent() async -> String {
37 |         if let userAgent {
38 |             return userAgent
39 |         }
40 |         return await task?.value ?? ""
41 |     }
42 | 
43 |     func getUserAgentBlocking() -> String {
44 |         if let userAgent {
45 |             return userAgent
46 |         }
47 |         return BlockingTask {
48 |             await self.getUserAgent()
49 |         }.get()
50 |     }
51 | }
52 | 


--------------------------------------------------------------------------------
/Shared/Tracking/Auth/OAuthResponse.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  OAuthResponse.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 7/22/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | struct OAuthResponse {
11 |     var tokenType: String?
12 |     var refreshToken: String?
13 |     var accessToken: String?
14 |     var expiresIn: Int?
15 |     var createdAt: Date = Date()
16 | 
17 |     // indicates if we've alerted the user that they need to re-login
18 |     var askedForRefresh = false
19 | 
20 |     var expired: Bool {
21 |         Date() > createdAt + TimeInterval(expiresIn ?? 0)
22 |     }
23 | }
24 | 
25 | extension OAuthResponse: Codable {
26 |     init(from decoder: Decoder) throws {
27 |         let container = try decoder.container(keyedBy: CodingKeys.self)
28 |         tokenType = try container.decodeIfPresent(String.self, forKey: .tokenType)
29 |         refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken)
30 |         accessToken = try container.decodeIfPresent(String.self, forKey: .accessToken)
31 |         expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn)
32 |         askedForRefresh = try container.decodeIfPresent(Bool.self, forKey: .askedForRefresh) ?? false
33 |     }
34 | 
35 |     enum CodingKeys: String, CodingKey {
36 |         case tokenType = "token_type"
37 |         case refreshToken = "refresh_token"
38 |         case accessToken = "access_token"
39 |         case expiresIn = "expires_in"
40 | 
41 |         case askedForRefresh = "asked_for_refresh"
42 |     }
43 | }
44 | 


--------------------------------------------------------------------------------
/Shared/Tracking/Models/TrackItem.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackItem.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/16/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | /// A structure representing a tracked title and its state.
11 | struct TrackItem {
12 |     /// A unique identifier the tracker can use to identify an item.
13 |     let id: String
14 |     /// The tracker identifier for the item.
15 |     let trackerId: String
16 |     /// The source identifier for the item.
17 |     let sourceId: String
18 |     /// The identifier for the item.
19 |     let mangaId: String
20 |     /// The tracker's title for the item.
21 |     var title: String?
22 |     /// The paired tracking state of the item.
23 |     var state: TrackState?
24 | }
25 | 


--------------------------------------------------------------------------------
/Shared/Tracking/Models/TrackScoreType.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackScoreType.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/16/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | /// Available scoring types for a tracker.
11 | enum TrackScoreType {
12 |     /// A score type where an integer value between 1 and 10 can be selected.
13 |     case tenPoint
14 |     /// A score type where an integer value between 1 and 100 can be selected.
15 |     case hundredPoint
16 |     /// A score type where a float value between 1 and 10 can be selected.
17 |     /// Stored as an integer from 1 to 100.
18 |     case tenPointDecimal
19 |     /// A score type where a score value is selected from a list.
20 |     case optionList
21 | }
22 | 


--------------------------------------------------------------------------------
/Shared/Tracking/Models/TrackSearchItem.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackSearchItem.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/16/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | /// A structure containing the necessary data to be returned from a tracker search.
11 | struct TrackSearchItem {
12 |     /// A unique identifier of the tracker item.
13 |     let id: String
14 |     /// The identifier for the item's tracker.
15 |     let trackerId: String
16 |     /// The title of the tracker item.
17 |     var title: String?
18 |     /// The URL for the cover image of the tracker item.
19 |     var coverUrl: String?
20 |     /// The description or summary of the tracker item.
21 |     var description: String?
22 |     /// The publishing status of the tracker item.
23 |     var status: PublishingStatus?
24 |     /// The type or format of the tracker item.
25 |     var type: MediaType?
26 |     /// A boolean indicating if the item is currently being tracked by the user.
27 |     var tracked: Bool
28 | }
29 | 


--------------------------------------------------------------------------------
/Shared/Tracking/Models/TrackState.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackState.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/16/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | /// A structure containing tracking state data.
11 | struct TrackState {
12 |     /// An integer representing the rating score.
13 |     var score: Int?
14 |     /// The current reading status.
15 |     var status: TrackStatus?
16 |     /// The latest read chapter number.
17 |     var lastReadChapter: Float?
18 |     /// The latest read volume number.
19 |     var lastReadVolume: Int?
20 |     /// The total amount of chapters, if available.
21 |     var totalChapters: Int?
22 |     /// The total amount of volumes, if available.
23 |     var totalVolumes: Int?
24 |     /// The date that reading began.
25 |     var startReadDate: Date?
26 |     /// The date that reading completed.
27 |     var finishReadDate: Date?
28 | }
29 | 


--------------------------------------------------------------------------------
/Shared/Tracking/Models/TrackStatus.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackStatus.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/16/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | /// A class wrapping integer values that indicate a title's tracking status.
11 | class TrackStatus {
12 |     static let reading = TrackStatus(1)
13 |     static let planning = TrackStatus(2)
14 |     static let completed = TrackStatus(3)
15 |     static let paused = TrackStatus(4)
16 |     static let dropped = TrackStatus(5)
17 |     static let rereading = TrackStatus(6)
18 |     static let none = TrackStatus(7)
19 | 
20 |     /// An array of the built-in track statuses.
21 |     static let defaultStatuses = [
22 |         TrackStatus.reading, TrackStatus.planning, TrackStatus.completed, TrackStatus.rereading, TrackStatus.paused, TrackStatus.dropped
23 |     ]
24 | 
25 |     /// The wrapped raw value of the track status.
26 |     var rawValue: Int
27 | 
28 |     init(_ rawValue: Int) {
29 |         self.rawValue = rawValue
30 |     }
31 | 
32 |     func toString() -> String {
33 |         switch rawValue {
34 |         case 1: return NSLocalizedString("TRACK_READING", comment: "")
35 |         case 2: return NSLocalizedString("TRACK_PLANNING", comment: "")
36 |         case 3: return NSLocalizedString("TRACK_COMPLETED", comment: "")
37 |         case 4: return NSLocalizedString("TRACK_PAUSED", comment: "")
38 |         case 5: return NSLocalizedString("TRACK_DROPPED", comment: "")
39 |         case 6: return NSLocalizedString("TRACK_REREADING", comment: "")
40 |         default: return NSLocalizedString("UNKNOWN", comment: "")
41 |         }
42 |     }
43 | }
44 | 
45 | extension TrackStatus: Equatable {
46 |     static func == (lhs: TrackStatus, rhs: TrackStatus) -> Bool {
47 |         lhs.rawValue == rhs.rawValue
48 |     }
49 | }
50 | 


--------------------------------------------------------------------------------
/Shared/Tracking/Models/TrackUpdate.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackUpdate.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 7/21/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | /// A structure containing tracking state data to update.
11 | struct TrackUpdate {
12 |     /// An integer representing the rating score.
13 |     var score: Int?
14 |     /// The current reading status.
15 |     var status: TrackStatus?
16 |     /// The latest read chapter number.
17 |     var lastReadChapter: Float?
18 |     /// The latest read volume number.
19 |     var lastReadVolume: Int?
20 |     /// The date that reading began.
21 |     var startReadDate: Date?
22 |     /// The date that reading completed.
23 |     var finishReadDate: Date?
24 | }
25 | 


--------------------------------------------------------------------------------
/Shared/Tracking/OAuthTracker.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  OAuthTracker.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 7/22/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | /// A protocol for trackers that utilize OAuth authentication.
11 | protocol OAuthTracker: Tracker {
12 |     /// The host in the oauth callback url, e.g. `host` in `aidoku://host`.
13 |     var callbackHost: String { get }
14 |     /// The URL used to authenticate with the tracker service provider.
15 |     var authenticationUrl: String { get }
16 |     /// The OAuth access token for the tracker.
17 |     var token: String? { get set }
18 | 
19 |     var oauthClient: OAuthClient { get }
20 | 
21 |     /// A callback function called after authenticating.
22 |     func handleAuthenticationCallback(url: URL) async
23 | }
24 | 
25 | extension OAuthTracker {
26 |     var token: String? {
27 |         get {
28 |             UserDefaults.standard.string(forKey: "Tracker.\(id).token")
29 |         }
30 |         set {
31 |             UserDefaults.standard.set(newValue, forKey: "Tracker.\(id).token")
32 |         }
33 |     }
34 | 
35 |     var isLoggedIn: Bool {
36 |         token != nil
37 |     }
38 | 
39 |     func logout() {
40 |         token = nil
41 |         UserDefaults.standard.removeObject(forKey: "Tracker.\(id).oauth")
42 |         UserDefaults.standard.removeObject(forKey: "Tracker.\(id).token")
43 |     }
44 | }
45 | 


--------------------------------------------------------------------------------
/Shared/Tracking/Trackers/shikimori/ShikimoriQueries.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ShikimoriQueries.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Vova Lapskiy on 02.11.2024.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | // https://shikimori.one/api/doc
11 | 
12 | struct ShikimoriQueries {
13 |     static let searchQuery = """
14 |     query($search: String, $censored: Boolean) {
15 |       mangas(search: $search, limit: 25, censored: $censored) {
16 |         id
17 |         name
18 |         russian
19 |         status
20 |         kind
21 |         poster {
22 |           mini2xUrl
23 |         }
24 |       }
25 |     }
26 |     """
27 | }
28 | 
29 | struct ShikimoriSearchVars: Codable {
30 |     var search: String
31 |     var censored: Bool
32 | }
33 | 
34 | struct ShikimoriUser: Codable {
35 |     var userId: Int
36 | 
37 |     enum CodingKeys: String, CodingKey {
38 |         case userId = "id"
39 |     }
40 | }
41 | 
42 | struct ShikimoriPoster: Codable {
43 |     var mini2xUrl: String
44 | }
45 | 
46 | struct ShikimoriMangas: Codable {
47 |     var mangas: [ShikimoriManga]
48 | }
49 | 
50 | struct ShikimoriManga: Codable {
51 |     var id: String
52 |     var name: String
53 |     var russian: String?
54 |     var status: String
55 |     var kind: String
56 |     var poster: ShikimoriPoster
57 | }
58 | 
59 | struct ShikimoriUserRate: Codable {
60 |     var id: Int
61 |     var targetId: Int
62 |     var targetType: String
63 |     var status: String
64 |     var chapters: Int
65 |     var volumes: Int
66 |     var score: Int
67 |     var createdAt: String
68 |     var updatedAt: String
69 | 
70 |     enum CodingKeys: String, CodingKey {
71 |         case id, status, chapters, volumes, score
72 |         case targetId = "target_id"
73 |         case targetType = "target_type"
74 |         case createdAt = "created_at"
75 |         case updatedAt = "updated_at"
76 |     }
77 | }
78 | 


--------------------------------------------------------------------------------
/Shared/Utilities/BlockingTask.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BlockingTask.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/8/25.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | final class BlockingTask<T>: @unchecked Sendable {
11 |     let semaphore = DispatchSemaphore(value: 0)
12 |     private var result: T?
13 | 
14 |     init(block: @escaping @Sendable () async -> T) {
15 |         Task {
16 |             result = await block()
17 |             semaphore.signal()
18 |         }
19 |     }
20 | 
21 |     func get() -> T {
22 |         if let result { return result }
23 |         semaphore.wait()
24 |         return result!
25 |     }
26 | }
27 | 
28 | final class BlockingThrowingTask<T>: @unchecked Sendable {
29 |     let semaphore = DispatchSemaphore(value: 0)
30 |     private var result: T?
31 |     private var error: Error?
32 | 
33 |     init(block: @escaping @Sendable () async throws -> T) {
34 |         Task {
35 |             do {
36 |                 result = try await block()
37 |             } catch {
38 |                 self.error = error
39 |             }
40 |             semaphore.signal()
41 |         }
42 |     }
43 | 
44 |     func get() throws -> T {
45 |         if let result { return result }
46 |         semaphore.wait()
47 |         if let error {
48 |             throw error
49 |         }
50 |         return result!
51 |     }
52 | }
53 | 


--------------------------------------------------------------------------------
/Shared/Utilities/ObjectActorSerialExecutor.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ObjectActorSerialExecutor.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/10/25.
 6 | //
 7 | 
 8 | import CoreData
 9 | 
10 | final class ObjectActorSerialExecutor: @unchecked Sendable, SerialExecutor {
11 |     private let context: NSManagedObjectContext
12 | 
13 |     init(context: NSManagedObjectContext) {
14 |         self.context = context
15 |     }
16 | 
17 |     func enqueue(_ job: UnownedJob) {
18 |         self.context.perform {
19 |             job.runSynchronously(on: self.asUnownedSerialExecutor())
20 |         }
21 |     }
22 | 
23 |     func asUnownedSerialExecutor() -> UnownedSerialExecutor {
24 |         UnownedSerialExecutor(ordinary: self)
25 |     }
26 | }
27 | 


--------------------------------------------------------------------------------
/Shared/Utilities/PopupWebViewHandler.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  PopupWebViewHandler.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/16/25.
 6 | //
 7 | 
 8 | import WebKit
 9 | 
10 | @MainActor
11 | protocol PopupWebViewHandler {
12 |     func navigated(webView: WKWebView, for request: URLRequest)
13 |     func canceled(request: URLRequest)
14 | }
15 | 


--------------------------------------------------------------------------------
/Shared/Utilities/SemanticVersion.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SemanticVersion.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 12/31/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | class SemanticVersion {
11 | 
12 |     private var components: [String]
13 | 
14 |     init(_ string: String) {
15 |         components = string.components(separatedBy: ".")
16 |     }
17 | 
18 |     private func compare(to targetVersion: SemanticVersion) -> ComparisonResult {
19 |         var result: ComparisonResult = .orderedSame
20 |         var versionComponents = components
21 |         var targetComponents = targetVersion.components
22 | 
23 |         while versionComponents.count < targetComponents.count {
24 |             versionComponents.append("0")
25 |         }
26 | 
27 |         while targetComponents.count < versionComponents.count {
28 |             targetComponents.append("0")
29 |         }
30 | 
31 |         for (version, target) in zip(versionComponents, targetComponents) {
32 |             result = version.compare(target, options: .numeric)
33 |             if result != .orderedSame {
34 |                 break
35 |             }
36 |         }
37 | 
38 |         return result
39 |     }
40 | 
41 |     static func == (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { lhs.compare(to: rhs) == .orderedSame }
42 |     static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { lhs.compare(to: rhs) == .orderedAscending }
43 |     static func <= (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { lhs.compare(to: rhs) != .orderedDescending }
44 |     static func > (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { lhs.compare(to: rhs) == .orderedDescending }
45 |     static func >= (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { lhs.compare(to: rhs) != .orderedAscending }
46 | }
47 | 


--------------------------------------------------------------------------------
/Shared/Utilities/UIKitShim.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIKitShim.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/7/25.
 6 | //
 7 | 
 8 | #if canImport(UIKit)
 9 | 
10 | import UIKit
11 | typealias PlatformImage = UIImage
12 | typealias PlatformColor = UIColor
13 | 
14 | #else
15 | 
16 | import AppKit
17 | typealias PlatformImage = NSImage
18 | typealias PlatformColor = NSColor
19 | 
20 | extension NSImage {
21 |     func pngData() -> Data? {
22 |         guard
23 |             let data = tiffRepresentation,
24 |             let bitmap = NSBitmapImageRep(data: data)
25 |         else {
26 |             return nil
27 |         }
28 |         return bitmap.representation(using: .png, properties: [:])
29 |     }
30 | }
31 | 
32 | enum UITextAutocapitalizationType: Int {
33 |     case none = 0
34 |     case words = 1
35 |     case sentences = 2
36 |     case allCharacters = 3
37 | }
38 | 
39 | enum UIKeyboardType: Int {
40 |     case `default` = 0
41 |     case asciiCapable = 1
42 |     case numbersAndPunctuation = 2
43 |     case URL = 3
44 |     case numberPad = 4
45 |     case phonePad = 5
46 |     case namePhonePad = 6
47 |     case emailAddress = 7
48 |     case decimalPad = 8
49 |     case twitter = 9
50 |     case webSearch = 10
51 |     case asciiCapableNumberPad = 11
52 | }
53 | 
54 | enum UIReturnKeyType: Int {
55 |     case `default` = 0
56 |     case go = 1
57 |     case google = 2
58 |     case join = 3
59 |     case next = 4
60 |     case route = 5
61 |     case search = 6
62 |     case send = 7
63 |     case yahoo = 8
64 |     case done = 9
65 |     case emergencyCall = 10
66 |     case `continue` = 11
67 | }
68 | 
69 | #endif
70 | 


--------------------------------------------------------------------------------
/Shared/Wasm/Imports/WasmDefaults.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  WasmDefaults.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 4/14/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | class WasmDefaults {
11 | 
12 |     var globalStore: WasmGlobalStore
13 | 
14 |     init(globalStore: WasmGlobalStore) {
15 |         self.globalStore = globalStore
16 |     }
17 | 
18 |     func export(into namespace: String = "defaults") {
19 |         try? globalStore.vm.linkFunction(name: "get", namespace: namespace, function: self.get)
20 |         try? globalStore.vm.linkFunction(name: "set", namespace: namespace, function: self.set)
21 |     }
22 | 
23 |     var get: (Int32, Int32) -> Int32 {
24 |         { key, len in
25 |             guard len > 0 else { return -1 }
26 | 
27 |             if let keyString = self.globalStore.readString(offset: key, length: len),
28 |                let value = UserDefaults.standard.value(forKey: "\(self.globalStore.id).\(keyString)") {
29 |                 return self.globalStore.storeStdValue(value)
30 |             }
31 | 
32 |             return -1
33 |         }
34 |     }
35 | 
36 |     var set: (Int32, Int32, Int32) -> Void {
37 |         { key, len, value in
38 |             guard len > 0, value >= 0 else { return }
39 | 
40 |             if let keyString = self.globalStore.readString(offset: key, length: len) {
41 |                 UserDefaults.standard.set(self.globalStore.readStdValue(value), forKey: "\(self.globalStore.id).\(keyString)")
42 |             }
43 |         }
44 |     }
45 | }
46 | 


--------------------------------------------------------------------------------
/Shared/Wasm/Imports/WasmJson.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  WasmJson.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 1/6/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | class WasmJson {
11 | 
12 |     var globalStore: WasmGlobalStore
13 | 
14 |     init(globalStore: WasmGlobalStore) {
15 |         self.globalStore = globalStore
16 |     }
17 | 
18 |     func export(into namespace: String = "json") {
19 |         try? globalStore.vm.linkFunction(name: "parse", namespace: namespace, function: self.parse)
20 |     }
21 | 
22 |     var parse: (Int32, Int32) -> Int32 {
23 |         { data, size in
24 |             guard size > 0 else { return -1 }
25 | 
26 |             if let data = self.globalStore.readData(offset: data, length: size),
27 |                let json = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed),
28 |                json is [String: Any?] || json is [Any?] {
29 |                 return self.globalStore.storeStdValue(json)
30 |             }
31 | 
32 |             return -1
33 |         }
34 |     }
35 | }
36 | 


--------------------------------------------------------------------------------
/Shared/Wasm/Imports/WebView/WebViewViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  WebViewViewController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 2/12/23.
 6 | //
 7 | 
 8 | import UIKit
 9 | import WebKit
10 | 
11 | class WebViewViewController: BaseViewController, WKNavigationDelegate {
12 |     let request: URLRequest
13 |     var handler: PopupWebViewHandler?
14 | 
15 |     init(request: URLRequest, handler: PopupWebViewHandler? = nil) {
16 |         self.request = request
17 |         self.handler = handler
18 |         super.init()
19 |     }
20 | 
21 |     override func configure() {
22 |         view.backgroundColor = .systemBackground
23 |     }
24 | 
25 |     func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
26 |         handler?.navigated(webView: webView, for: request)
27 |     }
28 | 
29 |     override func viewDidDisappear(_ animated: Bool) {
30 |         super.viewDidDisappear(animated)
31 |         handler?.canceled(request: request)
32 |         handler = nil
33 |     }
34 | }
35 | 


--------------------------------------------------------------------------------
/Shared/Wasm/WasmGlobalStore.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Wasmswift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 3/30/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | import Wasm3
10 | 
11 | class WasmGlobalStore {
12 |     var id: String
13 |     var vm: Module
14 | 
15 |     var chapterCounter = 0
16 |     var currentManga = ""
17 | 
18 |     // std
19 |     var stdDescriptorPointer: Int32 = -1
20 |     var stdDescriptors: [Int32: Any?] = [:]
21 | //    var stdReferences: [Int32: [Int32]] = [:]
22 | 
23 |     // net
24 |     var requestsPointer: Int32 = -1
25 |     var requests: [Int32: WasmRequestObject] = [:]
26 | 
27 |     init(id: String, vm: Module) {
28 |         self.id = id
29 |         self.vm = vm
30 |     }
31 | 
32 |     func readStdValue(_ descriptor: Int32) -> Any? {
33 |         stdDescriptors[descriptor] as Any?
34 |     }
35 | 
36 |     func storeStdValue(_ data: Any?) -> Int32 {
37 |         stdDescriptorPointer += 1
38 |         stdDescriptors[stdDescriptorPointer] = data
39 |         return stdDescriptorPointer
40 |     }
41 | 
42 |     func removeStdValue(_ descriptor: Int32) {
43 |         stdDescriptors.removeValue(forKey: descriptor)
44 |     }
45 | }
46 | 
47 | // MARK: - Memory R/W
48 | extension WasmGlobalStore {
49 | 
50 | //    func readString(offset: Int, length: Int) -> String? {
51 | //        try? vm.runtime.memory().readString(offset: UInt32(offset), length: UInt32(length))
52 | //    }
53 | 
54 |     func readString(offset: Int32, length: Int32) -> String? {
55 |         try? vm.runtime.memory().readString(offset: UInt32(offset), length: UInt32(length))
56 |     }
57 | 
58 |     func readData(offset: Int32, length: Int32) -> Data? {
59 |         try? vm.runtime.memory().readData(offset: UInt32(offset), length: UInt32(length))
60 |     }
61 | 
62 |     func readValues<T: WasmType>(offset: Int32, length: Int32) -> [T]? {
63 |         try? vm.runtime.memory().readValues(offset: UInt32(offset), length: UInt32(length))
64 |     }
65 | 
66 |     func readBytes(offset: Int32, length: Int32) -> [UInt8]? {
67 |         try? vm.runtime.memory().readBytes(offset: UInt32(offset), length: UInt32(length))
68 |     }
69 | 
70 | //    func write<T: WasmType & FixedWidthInteger>(value: T, offset: Int32) {
71 | //        try? vm.runtime.memory().write(values: [value], offset: UInt32(offset))
72 | //    }
73 | 
74 |     func write(bytes: [UInt8], offset: Int32) {
75 |         try? vm.runtime.memory().write(bytes: bytes, offset: UInt32(offset))
76 |     }
77 | 
78 | //    func write(data: Data, offset: Int32) {
79 | //        try? vm.runtime.memory().write(data: data, offset: UInt32(offset))
80 | //    }
81 | }
82 | 


--------------------------------------------------------------------------------
/Shared/Wasm/WasmImports.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  WasmImports.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 3/29/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | protocol WasmImports {
11 |     var globalStore: WasmGlobalStore { get set }
12 | 
13 |     func export(into namespace: String)
14 | }
15 | 


--------------------------------------------------------------------------------
/iOS/Aidoku-IOS.xcconfig:
--------------------------------------------------------------------------------
1 | //
2 | //  Aidoku-IOS.xcconfig
3 | //  Aidoku
4 | //
5 | //  Created by Nikolai Schumacher on 17.05.25.
6 | //
7 | 
8 | #include "../Shared/Aidoku.xcconfig"
9 | 


--------------------------------------------------------------------------------
/iOS/Extensions/Dates.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Dates.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by axiel7 on 13/02/2024.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension Date {
11 |     static func makeRelativeDate(days: Int) -> String {
12 |         let now = Date()
13 |         let date = now.addingTimeInterval(-86400 * Double(days))
14 |         let difference = Calendar.autoupdatingCurrent.dateComponents(Set([Calendar.Component.day]), from: date, to: now)
15 | 
16 |         // today or yesterday
17 |         if days <= 1 {
18 |             let formatter = DateFormatter()
19 |             formatter.locale = Locale.autoupdatingCurrent
20 |             formatter.dateStyle = .medium
21 |             formatter.doesRelativeDateFormatting = true
22 |             return formatter.string(from: date)
23 |         } else if days <= 7 { // n days ago
24 |             let formatter = DateComponentsFormatter()
25 |             formatter.unitsStyle = .short
26 |             formatter.allowedUnits = .day
27 |             guard let timePhrase = formatter.string(from: difference) else { return "" }
28 |             return String(format: NSLocalizedString("%@_AGO", comment: ""), timePhrase)
29 |         } else { // mm/dd/yy
30 |             let formatter = DateFormatter()
31 |             formatter.locale = Locale.autoupdatingCurrent
32 |             formatter.dateStyle = .short
33 |             return formatter.string(from: date)
34 |         }
35 |     }
36 | }
37 | 
38 | extension Date {
39 |     static func endOfDay() -> Date {
40 |         let calendar = Calendar.autoupdatingCurrent
41 |         let start = calendar.startOfDay(for: Date())
42 |         return calendar.date(byAdding: .day, value: 1, to: start)!
43 |     }
44 | }
45 | 


--------------------------------------------------------------------------------
/iOS/Extensions/HostingController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  HostingController.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 7/20/22.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | final class HostingController<Content: View>: UIHostingController<Content> {
11 |     override func viewDidLayoutSubviews() {
12 |         super.viewDidLayoutSubviews()
13 |         self.view.invalidateIntrinsicContentSize()
14 |     }
15 | }
16 | 
17 | private protocol AnyUIHostingViewController: AnyObject {}
18 | extension UIHostingController: AnyUIHostingViewController {}
19 | 
20 | extension UIViewController {
21 |     /// Checks if this UIViewController is wrapped inside of SwiftUI. Must be used after viewDidLoad
22 |     var isWrapped: Bool { parent is AnyUIHostingViewController }
23 | }
24 | 


--------------------------------------------------------------------------------
/iOS/Extensions/UICollectionView+CellRegistration.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UICollectionView+CellRegistration.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/1/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | extension UICollectionView.CellRegistration {
11 |     var cellProvider: (UICollectionView, IndexPath, Item) -> Cell {
12 |         { collectionView, indexPath, item in
13 |             collectionView.dequeueConfiguredReusableCell(
14 |                 using: self,
15 |                 for: indexPath,
16 |                 item: item
17 |             )
18 |         }
19 |     }
20 | }
21 | 


--------------------------------------------------------------------------------
/iOS/Extensions/UINavigationItem.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UINavigationItem.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/15/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | extension UINavigationItem {
11 | 
12 |     func setTitle(upper: String?, lower: String) {
13 |         if let upper = upper {
14 |             let upperLabel = UILabel()
15 |             upperLabel.text = upper
16 |             upperLabel.font = UIFont.systemFont(ofSize: 11)
17 |             upperLabel.textColor = .secondaryLabel
18 | 
19 |             let lowerLabel = UILabel()
20 |             lowerLabel.text = lower
21 |             lowerLabel.font = UIFont.systemFont(ofSize: 13, weight: .medium)
22 |             lowerLabel.textAlignment = .center
23 | 
24 |             let stackView = UIStackView(arrangedSubviews: [upperLabel, lowerLabel])
25 |             stackView.distribution = .equalCentering
26 |             stackView.axis = .vertical
27 |             stackView.alignment = .center
28 | 
29 |             let width = max(upperLabel.frame.size.width, lowerLabel.frame.size.width)
30 |             stackView.frame = CGRect(x: 0, y: 0, width: width, height: 35)
31 | 
32 |             upperLabel.sizeToFit()
33 |             lowerLabel.sizeToFit()
34 | 
35 |             self.titleView = stackView
36 |         } else {
37 |             self.titleView = nil
38 |             self.title = lower
39 |         }
40 |     }
41 | }
42 | 


--------------------------------------------------------------------------------
/iOS/Extensions/UIStepper.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIStepper.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 4/21/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | extension UIStepper {
11 |     private static var _defaultsKey = [String: String?]()
12 |     private static var _handlers = [String: (Double) -> Void]()
13 | 
14 |     var defaultsKey: String? {
15 |         get {
16 |             let tmpAddress = String(format: "%p", unsafeBitCast(self, to: Int.self))
17 |             return Self._defaultsKey[tmpAddress] ?? nil
18 |         }
19 |         set {
20 |             let tmpAddress = String(format: "%p", unsafeBitCast(self, to: Int.self))
21 |             Self._defaultsKey[tmpAddress] = newValue
22 |             addTarget(self, action: #selector(toggleDefaultsSetting), for: .valueChanged)
23 |             if let key = newValue {
24 |                 value = UserDefaults.standard.double(forKey: key)
25 |             } else {
26 |                 value = 0
27 |             }
28 |             addTarget(self, action: #selector(notifyHandler), for: .valueChanged)
29 |         }
30 |     }
31 | 
32 |     @objc func handleChange(_ handler: @escaping (Double) -> Void) {
33 |         let tmpAddress = String(format: "%p", unsafeBitCast(self, to: Int.self))
34 |         Self._handlers[tmpAddress] = handler
35 |     }
36 | 
37 |     @objc func toggleDefaultsSetting() {
38 |         guard let key = defaultsKey else { return }
39 |         UserDefaults.standard.set(value, forKey: key)
40 |     }
41 | 
42 |     @objc func notifyHandler() {
43 |         let tmpAddress = String(format: "%p", unsafeBitCast(self, to: Int.self))
44 |         if let handler = Self._handlers[tmpAddress] {
45 |             handler(value)
46 |         }
47 |         if let key = defaultsKey {
48 |             NotificationCenter.default.post(name: NSNotification.Name(key), object: value)
49 |         }
50 |     }
51 | }
52 | 


--------------------------------------------------------------------------------
/iOS/Extensions/UISwitch.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UISwitch.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 2/12/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | extension UISwitch {
11 |     private static var _defaultsKey = [String: String?]()
12 |     private static var _handlers = [String: (Bool) -> Void]()
13 | 
14 |     var defaultsKey: String? {
15 |         get {
16 |             let tmpAddress = String(format: "%p", unsafeBitCast(self, to: Int.self))
17 |             return Self._defaultsKey[tmpAddress] ?? nil
18 |         }
19 |         set {
20 |             let tmpAddress = String(format: "%p", unsafeBitCast(self, to: Int.self))
21 |             Self._defaultsKey[tmpAddress] = newValue
22 |             addTarget(self, action: #selector(toggleDefaultsSetting), for: .valueChanged)
23 |             if let key = newValue {
24 |                 isOn = UserDefaults.standard.bool(forKey: key)
25 |             } else {
26 |                 isOn = false
27 |             }
28 |             addTarget(self, action: #selector(notifyHandler), for: .valueChanged)
29 |         }
30 |     }
31 | 
32 |     @objc func handleChange(_ handler: @escaping (Bool) -> Void) {
33 |         let tmpAddress = String(format: "%p", unsafeBitCast(self, to: Int.self))
34 |         Self._handlers[tmpAddress] = handler
35 |     }
36 | 
37 |     @objc func toggleDefaultsSetting() {
38 |         if let key = defaultsKey {
39 |             UserDefaults.standard.set(isOn, forKey: key)
40 |         }
41 |     }
42 | 
43 |     @objc func notifyHandler() {
44 |         let tmpAddress = String(format: "%p", unsafeBitCast(self, to: Int.self))
45 |         if let handler = Self._handlers[tmpAddress] {
46 |             handler(isOn)
47 |         }
48 |         if let key = defaultsKey {
49 |             NotificationCenter.default.post(name: NSNotification.Name(key), object: isOn)
50 |         }
51 |     }
52 | }
53 | 


--------------------------------------------------------------------------------
/iOS/Extensions/UIToolbar.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIToolbar.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/15/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | extension UIToolbar {
11 | 
12 |     var contentView: UIView? {
13 |         subviews.first { view in
14 |             let viewDescription = String(describing: type(of: view))
15 |             return viewDescription.contains("ContentView")
16 |         }
17 |     }
18 | 
19 |     var stackView: UIView? {
20 |         contentView?.subviews.first { view -> Bool in
21 |             let viewDescription = String(describing: type(of: view))
22 |             return viewDescription.contains("ButtonBarStackView")
23 |         }
24 |     }
25 | 
26 |    func fitContentViewToToolbar() {
27 |         guard let stackView = stackView, let contentView = contentView else { return }
28 |         stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
29 |         stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
30 |         stackView.widthAnchor.constraint(equalTo: contentView.widthAnchor).isActive = true
31 |     }
32 | }
33 | 


--------------------------------------------------------------------------------
/iOS/Extensions/UIView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 5/26/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | extension UIView {
11 | //    var parentViewController: UIViewController? {
12 | //        var parentResponder: UIResponder? = self.next
13 | //        while parentResponder != nil {
14 | //            if let viewController = parentResponder as? UIViewController {
15 | //                return viewController
16 | //            }
17 | //            parentResponder = parentResponder?.next
18 | //        }
19 | //        return nil
20 | //    }
21 | 
22 |     func addOverlay(color: UIColor) {
23 |         let overlay = UIView()
24 |         overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
25 |         overlay.frame = bounds
26 |         overlay.backgroundColor = color
27 |         overlay.alpha = 0
28 |         overlay.tag = color.hash
29 |         addSubview(overlay)
30 |     }
31 | 
32 |     func showOverlay(color: UIColor, alpha: CGFloat = 1) {
33 |         if let overlay = viewWithTag(color.hash) {
34 |             overlay.alpha = alpha
35 |         }
36 |     }
37 | 
38 |     func hideOverlay(color: UIColor) {
39 |         if let overlay = viewWithTag(color.hash) {
40 |             overlay.alpha = 0
41 |         }
42 |     }
43 | }
44 | 
45 | extension UIView {
46 |     func forceNoClip() {
47 |         let originalClass: AnyClass = object_getClass(self)!
48 |         let subclassName = "\(originalClass)_ClipsToBoundsSwizzled_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))"
49 |         guard let subclass = objc_allocateClassPair(originalClass, subclassName, 0) else { return }
50 | 
51 |         let setterSelector = #selector(setter: UIView.clipsToBounds)
52 |         let method = class_getInstanceMethod(UIView.self, setterSelector)!
53 |         let types = method_getTypeEncoding(method)
54 | 
55 |         let imp: @convention(c) (UIView, Selector, Bool) -> Void = { view, selector, _ in
56 |             let superClass: AnyClass = class_getSuperclass(object_getClass(view))!
57 |             if let superSetter = class_getInstanceMethod(superClass, selector) {
58 |                 let superIMP = method_getImplementation(superSetter)
59 |                 typealias SetterType = @convention(c) (UIView, Selector, Bool) -> Void
60 |                 let casted = unsafeBitCast(superIMP, to: SetterType.self)
61 |                 casted(view, selector, false)
62 |             }
63 |         }
64 | 
65 |         class_replaceMethod(subclass, setterSelector, unsafeBitCast(imp, to: IMP.self), types)
66 |         objc_registerClassPair(subclass)
67 |         object_setClass(self, subclass)
68 |     }
69 | }
70 | 


--------------------------------------------------------------------------------
/iOS/Extensions/UIViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIViewController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/15/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | extension UIViewController {
11 | 
12 |     func add(child: UIViewController, below: UIView? = nil) {
13 |         addChild(child)
14 |         if let below {
15 |             view.insertSubview(child.view, belowSubview: below)
16 |         } else {
17 |             view.addSubview(child.view)
18 |         }
19 |         child.didMove(toParent: self)
20 |     }
21 | 
22 |     func remove() {
23 |         guard parent != nil else {
24 |             return
25 |         }
26 |         willMove(toParent: nil)
27 |         view.removeFromSuperview()
28 |         removeFromParent()
29 |     }
30 | 
31 |     func presentAlert(title: String, message: String, actions: [UIAlertAction] = [], completion: (() -> Void)? = nil) {
32 |         let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
33 | 
34 |         // If no actions are provided, add a default 'OK' action
35 |         if actions.isEmpty {
36 |             let okAction = UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .cancel)
37 |             alertController.addAction(okAction)
38 |         } else {
39 |             for action in actions {
40 |                 alertController.addAction(action)
41 |             }
42 |         }
43 | 
44 |         self.present(alertController, animated: true, completion: completion)
45 |     }
46 | }
47 | 


--------------------------------------------------------------------------------
/iOS/New/Extensions/Array.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Array.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 4/28/25.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension Array {
11 |     func chunked(into size: Int) -> [[Element]] {
12 |         stride(from: 0, to: count, by: size).map {
13 |             Array(self[$0..<Swift.min($0 + size, count)])
14 |         }
15 |     }
16 | }
17 | 


--------------------------------------------------------------------------------
/iOS/New/Extensions/EdgeInsets.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  EdgeInsets.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 8/18/23.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | extension EdgeInsets {
11 |     static var zero: Self {
12 |         .init(
13 |             top: 0,
14 |             leading: 0,
15 |             bottom: 0,
16 |             trailing: 0
17 |         )
18 |     }
19 | }
20 | 


--------------------------------------------------------------------------------
/iOS/New/Extensions/UIApplication.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIApplication.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 8/18/23.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | extension UIApplication {
11 |     var firstKeyWindow: UIWindow? {
12 |         UIApplication.shared.connectedScenes
13 |             .compactMap { $0 as? UIWindowScene }
14 |             .first?
15 |             .keyWindow
16 |     }
17 | }
18 | 


--------------------------------------------------------------------------------
/iOS/New/Extensions/UIHostingController+SafeArea.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIHostingController+SafeArea.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/3/25.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | extension UIHostingController {
11 |     convenience init(rootView: Content, ignoreSafeArea: Bool) {
12 |         self.init(rootView: rootView)
13 | 
14 |         if ignoreSafeArea {
15 |             disableSafeArea()
16 |         }
17 |     }
18 | 
19 |     func disableSafeArea() {
20 |         if #available(iOS 16.4, *) {
21 |             self.safeAreaRegions = []
22 |         } else {
23 |             // https://defagos.github.io/swiftui_collection_part3/
24 |             guard let viewClass = object_getClass(view) else { return }
25 | 
26 |             let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea")
27 |             if let viewSubclass = NSClassFromString(viewSubclassName) {
28 |                 object_setClass(view, viewSubclass)
29 |             } else {
30 |                 guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return }
31 |                 guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return }
32 | 
33 |                 if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) {
34 |                     let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
35 |                         .zero
36 |                     }
37 |                     class_addMethod(
38 |                         viewSubclass,
39 |                         #selector(getter: UIView.safeAreaInsets),
40 |                         imp_implementationWithBlock(safeAreaInsets),
41 |                         method_getTypeEncoding(method)
42 |                     )
43 |                 }
44 | 
45 |                 objc_registerClassPair(viewSubclass)
46 |                 object_setClass(view, viewSubclass)
47 |             }
48 |         }
49 |     }
50 | }
51 | 


--------------------------------------------------------------------------------
/iOS/New/Extensions/WKWebView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  WKWebView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/21/25.
 6 | //
 7 | 
 8 | import WebKit
 9 | 
10 | extension WKWebView {
11 |     func getCookies(for domain: String? = nil) async -> [String: String]  {
12 |         await withCheckedContinuation { continuation in
13 |             configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
14 |                 var cookieDict = [String: String]()
15 |                 for cookie in cookies {
16 |                     if let domain {
17 |                         if cookie.domain.contains(domain) {
18 |                             cookieDict[cookie.name] = cookie.value
19 |                         }
20 |                     } else {
21 |                         cookieDict[cookie.name] = cookie.value
22 |                     }
23 |                 }
24 |                 continuation.resume(returning: cookieDict)
25 |             }
26 |         }
27 |     }
28 | }
29 | 


--------------------------------------------------------------------------------
/iOS/New/Utilities/ButtonStyles/BetterBorderedButtonStyle.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BetterBorderedButtonStyle.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/11/25.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | // same as BorderedButtonStyle, but with a different background color
11 | struct BetterBorderedButtonStyle: ButtonStyle {
12 |     @Environment(\.colorScheme) private var colorScheme
13 | 
14 |     func makeBody(configuration: Configuration) -> some View {
15 |         let backgroundOpacity = configuration.isPressed ? (colorScheme == .dark ? 1.4 : 0.65) : 1
16 |         let labelOpacity = configuration.isPressed && colorScheme == .light ? 0.75 : 1
17 |         let foregroundColor = configuration.isPressed && colorScheme == .dark
18 |             ? Color(uiColor: .accent).mix(with: .white, by: 0.1)
19 |             : Color.accentColor
20 | 
21 |         HStack {
22 |             configuration.label
23 |                 .opacity(labelOpacity)
24 |         }
25 |         .padding(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
26 |         .foregroundStyle(foregroundColor)
27 |         .background(Color(uiColor: .tertiarySystemFill).opacity(backgroundOpacity))
28 |         .clipShape(RoundedRectangle(cornerRadius: 8))
29 |     }
30 | }
31 | 
32 | private extension Color {
33 |     func mix(with color: Color, by percentage: Double) -> Color {
34 |         let clampedPercentage = min(max(percentage, 0), 1)
35 | 
36 |         let components1 = UIColor(self).cgColor.components!
37 |         let components2 = UIColor(color).cgColor.components!
38 | 
39 |         let red = (1 - clampedPercentage) * components1[0] + clampedPercentage * components2[0]
40 |         let green = (1 - clampedPercentage) * components1[1] + clampedPercentage * components2[1]
41 |         let blue = (1 - clampedPercentage) * components1[2] + clampedPercentage * components2[2]
42 |         let alpha = (1 - clampedPercentage) * components1[3] + clampedPercentage * components2[3]
43 | 
44 |         return Color(red: red, green: green, blue: blue, opacity: alpha)
45 |     }
46 | }
47 | 


--------------------------------------------------------------------------------
/iOS/New/Utilities/ButtonStyles/DarkOverlayButtonStyle.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  DarkOverlayButtonStyle.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 9/30/23.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | // shows a dark overlay when pressed
11 | struct DarkOverlayButtonStyle: ButtonStyle {
12 |     @Environment(\.colorScheme) private var colorScheme
13 | 
14 |     func makeBody(configuration: Configuration) -> some View {
15 |         configuration.label
16 |             .overlay {
17 |                 if configuration.isPressed {
18 |                     Rectangle()
19 |                         .fill(Color.black)
20 |                         .opacity(colorScheme == .dark ? 0.5 : 0.3)
21 |                 }
22 |             }
23 |     }
24 | }
25 | 


--------------------------------------------------------------------------------
/iOS/New/Utilities/ButtonStyles/ListButtonStyle.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ListButtonStyle.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 2/1/25.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct ListButtonStyle: ButtonStyle {
11 |     func makeBody(configuration: Configuration) -> some View {
12 |         configuration.label
13 |             .padding(.vertical, 12)
14 |             .padding(.horizontal)
15 |             .frame(maxWidth: .infinity, alignment: .leading)
16 |             .background(
17 |                 Group {
18 |                     if configuration.isPressed {
19 | #if !os(macOS)
20 |                         Color(UIColor.systemGray4)
21 |                             .animation(nil, value: configuration.isPressed)
22 | #else
23 |                         Color(UIColor.systemGray)
24 |                             .animation(nil, value: configuration.isPressed)
25 | #endif
26 |                     } else {
27 |                         Color(UIColor.systemBackground)
28 |                             .animation(.easeInOut(duration: 0.2), value: configuration.isPressed)
29 |                     }
30 |                 }
31 |             )
32 |             .contentShape(Rectangle())
33 |             .foregroundStyle(.tint)
34 |     }
35 | }
36 | 


--------------------------------------------------------------------------------
/iOS/New/Utilities/ButtonStyles/MangaGridButtonStyle.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MangaGridButtonStyle.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 10/13/23.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct MangaGridButtonStyle: ButtonStyle {
11 |     func makeBody(configuration: Configuration) -> some View {
12 |         configuration.label
13 |             .overlay(
14 |                 (configuration.isPressed ? Color.black.opacity(0.5) : Color.clear)
15 |                     .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
16 |             )
17 |     }
18 | }
19 | 


--------------------------------------------------------------------------------
/iOS/New/Utilities/ButtonStyles/SelectHighlightButtonStyle.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SelectHighlightButtonStyle.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 4/28/25.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct SelectHighlightButtonStyle: ButtonStyle {
11 |     func makeBody(configuration: Configuration) -> some View {
12 |         configuration.label
13 |             .background {
14 |                 if configuration.isPressed {
15 |                     Rectangle()
16 |                         .fill(Color(uiColor: .secondarySystemBackground))
17 |                 }
18 |             }
19 |     }
20 | }
21 | 


--------------------------------------------------------------------------------
/iOS/New/Utilities/NavigationCoordinator.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  NavigationCoordinator.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 4/28/25.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | @MainActor
11 | class NavigationCoordinator: ObservableObject {
12 |     weak var rootViewController: UIViewController?
13 | 
14 |     init(rootViewController: UIViewController?) {
15 |         self.rootViewController = rootViewController
16 |     }
17 | 
18 |     func push<V: View>(_ view: V, animated: Bool = true, title: String? = nil) {
19 |         let vc = UIHostingController(rootView: view.environmentObject(self))
20 |         vc.title = title
21 |         rootViewController?.navigationController?.pushViewController(vc, animated: animated)
22 |     }
23 | 
24 |     func push(_ viewController: UIViewController, animated: Bool = true) {
25 |         rootViewController?.navigationController?.pushViewController(viewController, animated: animated)
26 |     }
27 | 
28 | //    func present<V: View>(_ view: V, animated: Bool = true) {
29 | //        let vc = UIHostingController(rootView: view.environmentObject(self))
30 | //        rootViewController?.present(vc, animated: animated)
31 | //    }
32 | 
33 |     func present(_ viewController: UIViewController, animated: Bool = true) {
34 |         rootViewController?.present(viewController, animated: animated)
35 |     }
36 | 
37 | //    func pop(animated: Bool = true) {
38 | //        rootViewController?.navigationController?.popViewController(animated: animated)
39 | //    }
40 | 
41 |     func dismiss(animated: Bool = true) {
42 |         rootViewController?.dismiss(animated: animated)
43 |     }
44 | }
45 | 


--------------------------------------------------------------------------------
/iOS/New/Utilities/UserDefaultsObserver.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UserDefaultsObserver.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/5/25.
 6 | //
 7 | 
 8 | import Combine
 9 | import SwiftUI
10 | 
11 | class UserDefaultsObserver: ObservableObject {
12 |     @Published var observedValues: [String: Any?] = [:]
13 | 
14 |     private var cancellable: AnyCancellable?
15 | 
16 |     init(keys: [String]) {
17 |         var observedValues: [String: Any?] = [:]
18 |         for key in keys {
19 |             let value = UserDefaults.standard.object(forKey: key)
20 |             observedValues[key] = value
21 |         }
22 |         self.observedValues = observedValues
23 | 
24 |         cancellable = NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
25 |             .sink { [weak self] _ in
26 |                 guard let self else { return }
27 |                 Task { @MainActor in
28 |                     for key in keys where !key.isEmpty {
29 |                         let newValue = UserDefaults.standard.object(forKey: key)
30 |                         let oldValue = self.observedValues[key, default: nil]
31 |                         if !Self.isEqual(oldValue, newValue) {
32 |                             self.observedValues[key] = newValue
33 |                         }
34 |                     }
35 |                 }
36 |             }
37 |     }
38 | 
39 |     convenience init(key: String) {
40 |         self.init(keys: [key])
41 |     }
42 | 
43 |     deinit {
44 |         cancellable?.cancel()
45 |     }
46 | 
47 |     private static func isEqual(_ lhs: Any?, _ rhs: Any?) -> Bool {
48 |         if let lhs = lhs as? NSObject, let rhs = rhs as? NSObject {
49 |             return lhs == rhs
50 |         } else {
51 |             return lhs == nil && rhs == nil
52 |         }
53 |     }
54 | }
55 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Browse/GetButton.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  GetButton.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/23/25.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct GetButton: View {
11 |     var action: () async -> Bool
12 | 
13 |     enum ButtonState: Equatable {
14 |         case `default`
15 |         case loading
16 |         case error
17 |     }
18 | 
19 |     @State private var buttonState: ButtonState = .default
20 | 
21 |     var body: some View {
22 |         Button {
23 |             Task {
24 |                 buttonState = .loading
25 |                 let success = await action()
26 |                 buttonState = success ? .default : .error
27 |             }
28 |         } label: {
29 |             switch buttonState {
30 |                 case .default:
31 |                     Text(NSLocalizedString("BUTTON_GET"))
32 |                 case .loading:
33 |                     ProgressView()
34 |                         .progressViewStyle(.circular)
35 |                         .scaleEffect(0.7)
36 |                 case .error:
37 |                     Text(NSLocalizedString("BUTTON_ERROR"))
38 |             }
39 |         }
40 |         .buttonStyle(.borderless)
41 |         .font(.system(size: 15).weight(.bold))
42 |         .padding(.vertical, 4)
43 |         .padding(.horizontal, buttonState == .loading ? 4 : 14)
44 |         .background(Color(uiColor: .tertiarySystemFill))
45 |         .clipShape(RoundedRectangle(cornerRadius: 28))
46 | //        .animation(.default, value: buttonState)
47 |     }
48 | }
49 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Browse/IconView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  IconView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 3/23/24.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | import NukeUI
10 | 
11 | struct IconView: View {
12 |     let imageUrl: URL?
13 | 
14 |     static let iconSize: CGFloat = 48
15 | 
16 |     var body: some View {
17 |         SourceImageView(
18 |             imageUrl: imageUrl?.absoluteString ?? "",
19 |             width: Self.iconSize,
20 |             height: Self.iconSize
21 |         )
22 |         .clipShape(RoundedRectangle(cornerRadius: Self.iconSize * 0.225))
23 |         .overlay(
24 |             RoundedRectangle(cornerRadius: Self.iconSize * 0.225)
25 |                 .strokeBorder(Color(uiColor: UIColor.quaternarySystemFill), lineWidth: 1)
26 |         )
27 |     }
28 | }
29 | 
30 | struct SourceIconView: View {
31 |     let sourceId: String
32 |     let imageUrl: URL?
33 | 
34 |     var body: some View {
35 |         if let imageUrl {
36 |             IconView(imageUrl: imageUrl)
37 |         } else {
38 |             let imageName = switch sourceId {
39 |                 case "local": "local"
40 |                 case let x where x.hasPrefix("kavita"): "kavita"
41 |                 case let x where x.hasPrefix("komga"): "komga"
42 |                 default: "MangaPlaceholder"
43 |             }
44 |             Image(imageName)
45 |                 .resizable()
46 |                 .frame(width: IconView.iconSize, height: IconView.iconSize)
47 |                 .clipShape(RoundedRectangle(cornerRadius: IconView.iconSize * 0.225))
48 |                 .overlay(
49 |                     RoundedRectangle(cornerRadius: IconView.iconSize * 0.225)
50 |                         .strokeBorder(Color(uiColor: UIColor.quaternarySystemFill), lineWidth: 1)
51 |                 )
52 |         }
53 |     }
54 | }
55 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Browse/SourceTableCell.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SourceTableCell.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 8/18/23.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | import AidokuRunner
10 | 
11 | struct SourceTableCell: View {
12 |     let source: AidokuRunner.Source
13 | 
14 |     var body: some View {
15 |         HStack(spacing: 12) {
16 |             SourceIconView(sourceId: source.key, imageUrl: source.imageUrl)
17 | 
18 |             VStack(alignment: .leading, spacing: 1) {
19 |                 HStack(spacing: 5) {
20 |                     Text(source.name)
21 |                     Text("v\(source.version)")
22 |                         .foregroundStyle(.secondary)
23 | 
24 |                     if source.contentRating != .safe {
25 |                         let (text, background) = if source.contentRating == .containsNsfw {
26 |                             ("17+", Color.orange.opacity(0.3))
27 |                         } else {
28 |                             ("18+", Color.red.opacity(0.3))
29 |                         }
30 | 
31 |                         Text(text)
32 |                             .foregroundStyle(.secondary)
33 |                             .font(.system(size: 10))
34 |                             .padding(.vertical, 3)
35 |                             .padding(.horizontal, 5)
36 |                             .background(background)
37 |                             .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
38 |                             .padding(.leading, 3)
39 |                     }
40 |                 }
41 |                 Text(
42 |                     (source.languages.count > 1 || source.languages.first == "multi")
43 |                         ? NSLocalizedString("MULTI_LANGUAGE")
44 |                         : Locale.current.localizedString(forIdentifier: source.languages[0]) ?? ""
45 |                 )
46 |                 .foregroundStyle(.secondary)
47 |             }
48 |             .font(.system(size: 16))
49 |         }
50 |         .frame(maxWidth: .infinity, alignment: .leading)
51 |         .contentShape(Rectangle())
52 |     }
53 | }
54 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Common/CloseButton.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  CloseButton.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 12/31/24.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct CloseButton: View {
11 |     let action: () -> Void
12 | 
13 |     var body: some View {
14 |         if #available(iOS 26.0, *) {
15 |             // ios 26 doesn't have a special close button style
16 |             Button {
17 |                 action()
18 |             } label: {
19 |                 Image(systemName: "xmark")
20 |             }
21 |         } else {
22 |             CloseButtonUIKit(action: action)
23 |         }
24 |     }
25 | }
26 | 
27 | struct CloseButtonUIKit: UIViewRepresentable {
28 |     private let action: () -> Void
29 | 
30 |     init(action: @escaping () -> Void) { self.action = action }
31 | 
32 |     func makeUIView(context: Context) -> UIButton {
33 |         UIButton(type: .close, primaryAction: UIAction { _ in action() })
34 |     }
35 | 
36 |     func updateUIView(_ uiView: UIButton, context: Context) {}
37 | }
38 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Common/DocumentPickerView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  DocumentPickerView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 7/1/25.
 6 | //  Modified from https://github.com/khcrysalis/Feather/blob/main/NimbleKit/Sources/NimbleViews/UIKit/FileImporterRepresentableView.swift
 7 | //
 8 | 
 9 | import SwiftUI
10 | import UniformTypeIdentifiers
11 | 
12 | public struct DocumentPickerView: UIViewControllerRepresentable {
13 |     public var allowedContentTypes: [UTType]
14 |     public var allowsMultipleSelection: Bool = false
15 |     public var onDocumentsPicked: ([URL]) -> Void
16 | 
17 |     public init(
18 |         allowedContentTypes: [UTType],
19 |         allowsMultipleSelection: Bool = false,
20 |         onDocumentsPicked: @escaping ([URL]) -> Void
21 |     ) {
22 |         self.allowedContentTypes = allowedContentTypes
23 |         self.allowsMultipleSelection = allowsMultipleSelection
24 |         self.onDocumentsPicked = onDocumentsPicked
25 |     }
26 | 
27 |     public func makeCoordinator() -> Coordinator {
28 |         Coordinator(onDocumentsPicked: onDocumentsPicked)
29 |     }
30 | 
31 |     public func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
32 |         // setting asCopy to true fixes issues when sideloading the app
33 |         let picker = UIDocumentPickerViewController(forOpeningContentTypes: allowedContentTypes, asCopy: true)
34 |         picker.delegate = context.coordinator
35 |         picker.allowsMultipleSelection = allowsMultipleSelection
36 |         return picker
37 |     }
38 | 
39 |     public func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
40 | 
41 |     public class Coordinator: NSObject, UIDocumentPickerDelegate {
42 |         var onDocumentsPicked: ([URL]) -> Void
43 | 
44 |         init(onDocumentsPicked: @escaping ([URL]) -> Void) {
45 |             self.onDocumentsPicked = onDocumentsPicked
46 |         }
47 | 
48 |         public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
49 |             onDocumentsPicked(urls)
50 |         }
51 | 
52 |         public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
53 |             onDocumentsPicked([])
54 |         }
55 |     }
56 | }
57 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Common/GIFImage.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  GIFImage.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/23/25.
 6 | //
 7 | 
 8 | import Gifu
 9 | import SwiftUI
10 | 
11 | struct GIFImage: UIViewRepresentable {
12 |     var image: UIImage?
13 |     var data: Data
14 |     var contentMode: ContentMode = .fill
15 | 
16 |     func makeUIView(context: Context) -> GIFImageView {
17 |         let imageView = GIFImageView(frame: .zero)
18 |         imageView.isUserInteractionEnabled = true
19 |         imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
20 |         imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
21 |         return imageView
22 |     }
23 | 
24 |     func updateUIView(_ uiView: GIFImageView, context: Context) {
25 |         uiView.image = image
26 |         uiView.animate(withGIFData: data)
27 |         uiView.contentMode = switch contentMode {
28 |             case .fit: .scaleAspectFit
29 |             case .fill: .scaleAspectFill
30 |         }
31 |     }
32 | }
33 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Common/ImagePicker.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ImagePicker.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/7/25.
 6 | //
 7 | 
 8 | import PhotosUI
 9 | import SwiftUI
10 | 
11 | struct ImagePicker: UIViewControllerRepresentable {
12 |     @Binding var image: UIImage?
13 | 
14 |     func makeUIViewController(context: Context) -> PHPickerViewController {
15 |         var config = PHPickerConfiguration()
16 |         config.filter = .images
17 |         let picker = PHPickerViewController(configuration: config)
18 |         picker.delegate = context.coordinator
19 |         return picker
20 |     }
21 | 
22 |     func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
23 | 
24 |     }
25 | 
26 |     func makeCoordinator() -> Coordinator {
27 |         Coordinator(self)
28 |     }
29 | 
30 |     class Coordinator: NSObject, PHPickerViewControllerDelegate {
31 |         let parent: ImagePicker
32 | 
33 |         init(_ parent: ImagePicker) {
34 |             self.parent = parent
35 |         }
36 | 
37 |         func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
38 |             picker.dismiss(animated: true)
39 | 
40 |             guard let provider = results.first?.itemProvider else { return }
41 | 
42 |             if provider.canLoadObject(ofClass: UIImage.self) {
43 |                 provider.loadObject(ofClass: UIImage.self) { image, _ in
44 |                     self.parent.image = image as? UIImage
45 |                 }
46 |             }
47 |         }
48 |     }
49 | }
50 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Common/MangaCoverView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MangaCoverView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 9/8/23.
 6 | //
 7 | 
 8 | import AidokuRunner
 9 | import SwiftUI
10 | import Nuke
11 | import NukeUI
12 | 
13 | struct MangaCoverView: View {
14 |     var source: AidokuRunner.Source?
15 | 
16 |     let coverImage: String
17 |     var width: CGFloat?
18 |     var height: CGFloat?
19 |     var downsampleWidth: CGFloat?
20 |     var contentMode: ContentMode = .fill
21 |     var placeholder = "MangaPlaceholder"
22 |     var bookmarked: Bool = false
23 | 
24 |     var body: some View {
25 |         SourceImageView(
26 |             source: source,
27 |             imageUrl: coverImage,
28 |             width: width,
29 |             height: height,
30 |             downsampleWidth: downsampleWidth,
31 |             contentMode: contentMode,
32 |             placeholder: placeholder
33 |         )
34 |         .overlay(
35 |             bookmarkView,
36 |             alignment: .topTrailing
37 |         )
38 |         .clipShape(RoundedRectangle(cornerRadius: 5))
39 |         .overlay(
40 |             RoundedRectangle(cornerRadius: 5)
41 |                 .strokeBorder(Color(UIColor.quaternarySystemFill), lineWidth: 1)
42 |         )
43 |     }
44 | 
45 |     @ViewBuilder
46 |     var bookmarkView: some View {
47 |         if bookmarked {
48 |             Image("bookmark")
49 |                 .resizable()
50 |                 .aspectRatio(contentMode: .fit)
51 |                 .foregroundStyle(.tint)
52 |                 .frame(width: 17, height: 27, alignment: .topTrailing)
53 |                 .padding(.trailing, 8)
54 |         } else {
55 |             EmptyView()
56 |         }
57 |     }
58 | }
59 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Common/PlatformNavigationStack.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  PlatformNavigationStack.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 10/6/23.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | // uses navigationstack for newer ios, and uses a custom path for macos (in order to have animations)
11 | struct PlatformNavigationStack<Content: View>: View {
12 |     @ViewBuilder let content: Content
13 | 
14 | #if os(macOS)
15 |     @State private var path = NavigationPath()
16 | #endif
17 | 
18 |     var body: some View {
19 |         if #available(iOS 16.0, macOS 13.0, *) {
20 | #if os(macOS)
21 |             NavigationStack(path: $path.animation(.default)) {
22 |                 content
23 |             }
24 | #else
25 |             NavigationStack {
26 |                 content
27 |             }
28 | #endif
29 |         } else {
30 | #if !os(macOS)
31 |             NavigationView {
32 |                 content
33 |             }
34 |             .navigationViewStyle(.stack)
35 | #else
36 |             NavigationView {
37 |                 content
38 |             }
39 | #endif
40 |         }
41 |     }
42 | }
43 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Common/Settings/WebView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  WebView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/21/25.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | import WebKit
10 | 
11 | struct WebView: UIViewRepresentable {
12 |     let url: URL
13 | 
14 |     @Binding var cookies: [String: String]
15 |     @Binding var reloadToggle: Bool
16 | 
17 |     private let webView = WKWebView()
18 | 
19 |     init(
20 |         _ url: URL,
21 |         cookies: Binding<[String: String]> = .constant([:]),
22 |         reloadToggle: Binding<Bool> = .constant(false)
23 |     ) {
24 |         self.url = url
25 |         self._cookies = cookies
26 |         self._reloadToggle = reloadToggle
27 |     }
28 | 
29 |     func makeUIView(context: Context) -> WKWebView {
30 |         webView.load(URLRequest(url: url))
31 |         webView.navigationDelegate = context.coordinator
32 |         return webView
33 |     }
34 | 
35 |     func updateUIView(_ uiView: WKWebView, context: Context) {
36 |         if reloadToggle {
37 |             reloadToggle = false
38 |             uiView.reload()
39 |         }
40 |     }
41 | 
42 |     func makeCoordinator() -> Coordinator {
43 |         .init(parent: self)
44 |     }
45 | 
46 |     class Coordinator: NSObject, WKNavigationDelegate, WKHTTPCookieStoreObserver {
47 |         var parent: WebView
48 | 
49 |         init(parent: WebView) {
50 |             self.parent = parent
51 |             super.init()
52 |             WKWebsiteDataStore.default().httpCookieStore.add(self)
53 |         }
54 | 
55 |         deinit {
56 |             WKWebsiteDataStore.default().httpCookieStore.remove(self)
57 |         }
58 | 
59 |         func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
60 |             Task {
61 |                 let cookies = await webView.getCookies(for: parent.url.host)
62 |                 parent.cookies = cookies
63 |             }
64 |         }
65 | 
66 |         func cookiesDidChange(in cookieStore: WKHTTPCookieStore) {
67 |             Task {
68 |                 let cookies = await parent.webView.getCookies(for: parent.url.host)
69 |                 parent.cookies = cookies
70 |             }
71 |         }
72 |     }
73 | }
74 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Common/SourceImageView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SourceImageView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 4/26/25.
 6 | //
 7 | 
 8 | import AidokuRunner
 9 | import NukeUI
10 | import SwiftUI
11 | 
12 | struct SourceImageView: View {
13 |     var source: AidokuRunner.Source?
14 | 
15 |     let imageUrl: String
16 |     var width: CGFloat?
17 |     var height: CGFloat?
18 |     var downsampleWidth: CGFloat?
19 |     var contentMode: ContentMode = .fill
20 |     var placeholder = "MangaPlaceholder"
21 | 
22 |     @State private var imageRequest: ImageRequest?
23 | 
24 |     var body: some View {
25 |         LazyImage(
26 |             request: imageRequest,
27 |             transaction: .init(animation: .default)
28 |         ) { state in
29 |             if state.imageContainer?.type == .gif, let data = state.imageContainer?.data {
30 |                 GIFImage(
31 |                     data: data,
32 |                     contentMode: contentMode
33 |                 )
34 |                     .frame(width: width, height: height)
35 |                     .id(state.image != nil ? imageUrl : "placeholder") // ensures only opacity is animated
36 |             } else {
37 |                 let result = if let image = state.image {
38 |                     image
39 |                 } else {
40 |                     Image(placeholder)
41 |                 }
42 |                 result
43 |                     .resizable()
44 |                     .aspectRatio(contentMode: contentMode)
45 |                     .frame(width: width, height: height)
46 |                     .id(state.image != nil ? imageUrl : "placeholder") // ensures only opacity is animated
47 |             }
48 |         }
49 |         .processors({
50 |             if let downsampleWidth {
51 |                 [DownsampleProcessor(width: downsampleWidth)]
52 |             } else {
53 |                 []
54 |             }
55 |         }())
56 |         .onAppear {
57 |             guard imageRequest == nil else { return }
58 |             Task {
59 |                 await loadImageRequest(url: imageUrl)
60 |             }
61 |         }
62 |         .onChange(of: imageUrl) { newValue in
63 |             imageRequest = nil
64 |             Task {
65 |                 await loadImageRequest(url: newValue)
66 |             }
67 |         }
68 |     }
69 | 
70 |     func loadImageRequest(url: String) async {
71 |         guard
72 |             let source,
73 |             let url = URL(string: url)
74 |         else {
75 |             imageRequest = ImageRequest(url: URL(string: url))
76 |             return
77 |         }
78 |         imageRequest = ImageRequest(urlRequest: await source.getModifiedImageRequest(url: url, context: nil))
79 |     }
80 | }
81 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Source/Filter/FilterBadgeView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  FilterBadgeView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 10/16/23.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct FilterBadgeView: View {
11 |     let count: Int
12 | 
13 |     static let size: CGFloat = 18
14 | 
15 |     @Environment(\.colorScheme) private var colorScheme
16 | 
17 |     var body: some View {
18 |         Text(String(count))
19 |             .padding(.horizontal, 5)
20 |             .frame(minWidth: Self.size)
21 |             .frame(height: Self.size)
22 |             .foregroundColor(colorScheme == .light ? .white : .accentColor)
23 |             .background {
24 |                 RoundedRectangle(cornerRadius: Self.size / 2)
25 |                     .foregroundColor(colorScheme == .light ? .accentColor : .white)
26 |             }
27 |     }
28 | }
29 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Source/Filter/FilterLabelView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  FilterLabelView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 10/16/23.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct FilterLabelView: View {
11 |     let name: String
12 |     var badgeCount: Int?
13 |     var active = false
14 |     var chevron = true
15 |     var icon: String?
16 | 
17 |     @Environment(\.colorScheme) private var colorScheme
18 | 
19 |     private var highlighted: Bool {
20 |         active || badgeCount ?? 0 > 0
21 |     }
22 | 
23 |     // show badge if count (number of subfilters enabled) is greater than 1
24 |     private var hasBadge: Bool {
25 |         badgeCount ?? 1 > 1
26 |     }
27 | 
28 |     var body: some View {
29 |         let label = HStack(spacing: 4) {
30 |             if let badgeCount, hasBadge {
31 |                 FilterBadgeView(count: badgeCount)
32 |             }
33 | 
34 |             Text(name)
35 |                 .opacity(highlighted ? 1 : 0.6)
36 |                 .foregroundColor(highlighted && colorScheme == .light ? .accentColor : .primary)
37 | 
38 |             Group {
39 |                 if let icon {
40 |                     Image(systemName: icon)
41 |                 } else if chevron {
42 |                     Image(systemName: "chevron.down")
43 |                 }
44 |             }
45 |             .foregroundColor(
46 |                 highlighted
47 |                     ? (colorScheme == .light ? .accentColor : .primary)
48 |                     : .init(uiColor: .tertiaryLabel)
49 |             )
50 |         }
51 |         .lineLimit(1)
52 |         .padding(.horizontal, 9)
53 |         .padding(.vertical, hasBadge ? 6 : 8)
54 |         .font(.caption.weight(.medium))
55 | 
56 |         if #available(iOS 26.0, *) {
57 |             label
58 |                 .glassEffect(
59 |                     highlighted ? .regular.tint(.accentColor.opacity(highlighted && colorScheme == .light ? 0.1 : 1)) : .regular,
60 |                     in: .capsule
61 |                 )
62 |         } else {
63 |             label
64 |                 .background(
65 |                     RoundedRectangle(cornerRadius: 100) // enough to make it fully rounded
66 |                         .foregroundColor(
67 |                             highlighted ? .accentColor : .init(uiColor: .secondarySystemFill)
68 |                         )
69 |                         .overlay(
70 |                             RoundedRectangle(cornerRadius: 100)
71 |                                 .stroke(Color(uiColor: .tertiarySystemFill), style: .init(lineWidth: 1))
72 |                         )
73 |                         .opacity(highlighted && colorScheme == .light ? 0.1 : 1)
74 |                 )
75 |         }
76 |     }
77 | }
78 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Source/HomeComponents/TitleView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TitleView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 12/30/24.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct TitleView: View {
11 |     let title: String
12 |     var subtitle: String?
13 |     var onTitleClick: (() -> Void)?
14 | 
15 |     var body: some View {
16 |         if let onTitleClick {
17 |             Button {
18 |                 onTitleClick()
19 |             } label: {
20 |                 HStack {
21 |                     titleView
22 |                     Spacer()
23 |                     Image(systemName: "chevron.forward")
24 |                         .font(.subheadline.weight(.semibold))
25 |                         .foregroundStyle(.tertiary)
26 |                 }
27 |                 .contentShape(Rectangle())
28 |             }
29 |             .padding(.horizontal)
30 |             .buttonStyle(.plain)
31 |         } else {
32 |             HStack {
33 |                 titleView
34 |                 Spacer()
35 |             }
36 |             .padding(.horizontal)
37 |         }
38 |     }
39 | 
40 |     @ViewBuilder
41 |     var titleView: some View {
42 |         if let subtitle {
43 |             VStack(alignment: .leading) {
44 |                 Text(title)
45 |                     .font(.title3)
46 |                     .fontWeight(.semibold)
47 |                 Text(subtitle)
48 |                     .font(.subheadline)
49 |                     .foregroundStyle(.secondary)
50 |             }
51 |         } else {
52 |             Text(title)
53 |                 .font(.title3)
54 |                 .fontWeight(.semibold)
55 |         }
56 |     }
57 | }
58 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Source/SourceHomeSkeletonView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SourceHomeSkeletonView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 4/1/25.
 6 | //
 7 | 
 8 | import AidokuRunner
 9 | import SwiftUI
10 | 
11 | struct SourceHomeSkeletonView: View {
12 |     let source: AidokuRunner.Source
13 | 
14 |     @State private var components: [[Int]]
15 | 
16 |     init(source: AidokuRunner.Source) {
17 |         self.source = source
18 |         let components = UserDefaults.standard.array(forKey: "\(source.key).homeComponents") as? [Int]
19 |         if let components {
20 |             self._components = .init(initialValue: components.chunked(into: 2))
21 |         } else {
22 |             // fall back to default skeleton layout
23 |             self._components = .init(initialValue: [[1, 0], [3, 5], [2, 0]])
24 |         }
25 |     }
26 | 
27 |     var body: some View {
28 |         VStack(spacing: 24) {
29 |             ForEach(components.indices, id: \.self) { idx in
30 |                 switch components[idx][0] {
31 |                     case 0:
32 |                         PlaceholderHomeImageScrollerView()
33 |                     case 1:
34 |                         PlaceholderMangaHomeBigScroller()
35 |                     case 2:
36 |                         PlaceholderMangaScroller()
37 |                     case 3, 4:
38 |                         PlaceholderMangaHomeList(itemCount: components[idx][1])
39 |                     case 5:
40 |                         PlaceholderHomeFiltersView()
41 |                     case 6:
42 |                         PlaceholderHomeLinksView()
43 |                     default:
44 |                         EmptyView()
45 |                 }
46 |             }
47 |         }
48 |     }
49 | }
50 | 
51 | #Preview {
52 |     SourceHomeSkeletonView(source: .demo())
53 |         .padding()
54 | }
55 | 


--------------------------------------------------------------------------------
/iOS/New/Views/Source/SourceListingViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SourceListingViewController.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 12/27/24.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | import AidokuRunner
10 | 
11 | class SourceListingViewController: UIViewController {
12 |     let source: AidokuRunner.Source
13 |     let listing: AidokuRunner.Listing
14 | 
15 |     init(source: AidokuRunner.Source, listing: AidokuRunner.Listing) {
16 |         self.source = source
17 |         self.listing = listing
18 | 
19 |         super.init(nibName: nil, bundle: nil)
20 |     }
21 | 
22 |     required init?(coder aDecoder: NSCoder) {
23 |         fatalError("init(coder:) has not been implemented")
24 |     }
25 | 
26 |     override func viewDidLoad() {
27 |         super.viewDidLoad()
28 | 
29 |         title = listing.name
30 |         navigationItem.largeTitleDisplayMode = .never
31 | 
32 |         let path = NavigationCoordinator(rootViewController: self)
33 |         let rootView = SourceListingView(source: source, listing: listing)
34 |             .environmentObject(path)
35 |         let hostingController = UIHostingController(rootView: rootView)
36 |         addChild(hostingController)
37 |         hostingController.didMove(toParent: self)
38 |         view.addSubview(hostingController.view)
39 | 
40 |         hostingController.view.translatesAutoresizingMaskIntoConstraints = false
41 | 
42 |         NSLayoutConstraint.activate([
43 |             hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
44 |             hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
45 |             hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
46 |             hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
47 |         ])
48 |     }
49 | }
50 | 
51 | private struct SourceListingView: View {
52 |     let source: AidokuRunner.Source
53 |     let listing: AidokuRunner.Listing
54 | 
55 |     var body: some View {
56 |         MangaListView(source: source, title: listing.name, listingKind: listing.kind) { page in
57 |             try await source.getMangaList(listing: listing, page: page)
58 |         }
59 |     }
60 | }
61 | 


--------------------------------------------------------------------------------
/iOS/Old UI/Browse/Filters/FilterStackView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  FilterStackView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 2/14/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class FilterStackView: UIStackView {
11 | 
12 |     let filters: [FilterBase]
13 |     let parent: FilterCell?
14 |     var selectedFilters: SelectedFilters
15 | 
16 |     var cells: [FilterCell] = []
17 | 
18 |     let cellHeight: CGFloat = 40
19 | 
20 |     init(filters: [FilterBase], parent: FilterCell? = nil, selectedFilters: SelectedFilters) {
21 |         self.filters = filters
22 |         self.parent = parent
23 |         self.selectedFilters = selectedFilters
24 |         super.init(frame: .zero)
25 |         layoutViews()
26 |     }
27 | 
28 |     required init(coder: NSCoder) {
29 |         fatalError("init(coder:) has not been implemented")
30 |     }
31 | 
32 |     func layoutViews() {
33 |         axis = .vertical
34 |         distribution = .equalSpacing
35 |         spacing = 4
36 | 
37 |         cells = []
38 |         for filter in filters {
39 |             let cell = FilterCell(filter: filter, parent: parent, selectedFilters: selectedFilters)
40 |             cell.translatesAutoresizingMaskIntoConstraints = false
41 |             addArrangedSubview(cell)
42 | 
43 |             if let detailView = cell.detailView {
44 |                 detailView.translatesAutoresizingMaskIntoConstraints = false
45 |                 addArrangedSubview(detailView)
46 | 
47 |                 detailView.widthAnchor.constraint(equalTo: cell.widthAnchor).isActive = true
48 |             }
49 | 
50 |             cell.heightAnchor.constraint(equalToConstant: cellHeight).isActive = true
51 |             cell.widthAnchor.constraint(equalTo: widthAnchor).isActive = true
52 | 
53 |             cells.append(cell)
54 |         }
55 |     }
56 | 
57 |     func updateCellImages() {
58 |         for cell in cells {
59 |             cell.updateImage()
60 |         }
61 |     }
62 | }
63 | 


--------------------------------------------------------------------------------
/iOS/Old UI/Browse/LanguageSelectViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  LanguageSelectViewController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 5/23/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class LanguageSelectViewController: SettingSelectViewController {
11 |     init() {
12 |         var languageCodes = Array(SourceManager.shared.sourceListLanguages)
13 | 
14 |         // sort alphabetically
15 |         languageCodes.sort(by: {
16 |             let lhs = Locale.current.localizedString(forIdentifier: $0)
17 |             let rhs = Locale.current.localizedString(forIdentifier: $1)
18 |             return lhs ?? $0 < rhs ?? $1
19 |         })
20 | 
21 |         // bring local language to top
22 |         languageCodes.removeAll { $0 == Locale.current.languageCode }
23 |         if let code = Locale.current.languageCode {
24 |             languageCodes.insert(code, at: 0)
25 |         }
26 | 
27 |         var titles = languageCodes.map { Locale.current.localizedString(forIdentifier: $0) ?? $0 }
28 | 
29 |         languageCodes.insert("multi", at: 0)
30 |         titles.insert(NSLocalizedString("MULTI_LANGUAGE", comment: ""), at: 0)
31 | 
32 |         super.init(item: SettingItem(
33 |             type: "multi-select",
34 |             key: "Browse.languages",
35 |             title: NSLocalizedString("LANGUAGES", comment: ""),
36 |             footer: "The external source list will be filtered to display only sources for the selected language(s).",
37 |             values: languageCodes,
38 |             titles: titles
39 |         ))
40 |     }
41 | 
42 |     required init?(coder: NSCoder) {
43 |         fatalError("init(coder:) has not been implemented")
44 |     }
45 | 
46 |     override func viewDidLoad() {
47 |         super.viewDidLoad()
48 | 
49 |         navigationController?.navigationBar.prefersLargeTitles = true
50 | 
51 |         navigationItem.rightBarButtonItem = UIBarButtonItem(
52 |             barButtonSystemItem: .done,
53 |             target: self,
54 |             action: #selector(close)
55 |         )
56 |     }
57 | 
58 |     @objc func close() {
59 |         dismiss(animated: true)
60 |     }
61 | }
62 | 


--------------------------------------------------------------------------------
/iOS/Old UI/Browse/SourceSectionHeaderView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SourceSectionHeaderView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 6/8/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class SourceSectionHeaderView: UITableViewHeaderFooterView {
11 | 
12 |     let title = UILabel()
13 | 
14 |     override init(reuseIdentifier: String?) {
15 |         super.init(reuseIdentifier: reuseIdentifier)
16 |         configureContents()
17 |     }
18 | 
19 |     required init?(coder: NSCoder) {
20 |         fatalError("init(coder:) has not been implemented")
21 |     }
22 | 
23 |     func configureContents() {
24 |         textLabel?.isHidden = true
25 | 
26 |         // TODO: use contentConfiguration instead
27 |         title.font = .systemFont(ofSize: 16, weight: .medium)
28 |         title.translatesAutoresizingMaskIntoConstraints = false
29 |         contentView.addSubview(title)
30 | 
31 |         NSLayoutConstraint.activate([
32 |             title.heightAnchor.constraint(equalToConstant: 20),
33 |             title.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
34 |             title.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
35 |         ])
36 |     }
37 | }
38 | 
39 | // class SmallSectionHeaderContentView: UIView, UIContentView {
40 | //    var configuration: UIContentConfiguration
41 | //
42 | //    init(_ configuration: UIContentConfiguration) {
43 | //        self.configuration = configuration
44 | //        super.init(frame:.zero)
45 | //    }
46 | // }
47 | 


--------------------------------------------------------------------------------
/iOS/Old UI/Manga/Tracking/TrackerAddView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackerAddView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 7/20/22.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct TrackerAddView: View {
11 | 
12 |     let tracker: Tracker
13 |     let manga: Manga
14 |     @Binding var refresh: Bool
15 | 
16 |     @State var isLoading = false
17 |     @State var showSearchController = false
18 | 
19 |     var body: some View {
20 |         HStack {
21 |             ZStack {
22 |                 HStack {
23 |                 Image(uiImage: tracker.icon ?? UIImage(named: "MangaPlaceholder")!)
24 |                     .resizable()
25 |                     .frame(width: 44, height: 44, alignment: .leading)
26 |                     .cornerRadius(10)
27 |                     Spacer()
28 |                 }
29 |                 if isLoading {
30 |                     ProgressView()
31 |                         .progressViewStyle(CircularProgressViewStyle())
32 |                 } else {
33 |                     Button(NSLocalizedString("START_TRACKING", comment: "")) {
34 |                         showSearchController.toggle()
35 |                     }
36 |                 }
37 |             }
38 |         }
39 |         .padding([.top, .horizontal])
40 |         .sheet(isPresented: $showSearchController, content: {
41 |             TrackerSearchNavigationController(tracker: tracker, manga: manga)
42 |                 .ignoresSafeArea()
43 |         })
44 |     }
45 | }
46 | 


--------------------------------------------------------------------------------
/iOS/Old UI/Manga/Tracking/TrackerListView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackerListView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 7/19/22.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct TrackerListView: View {
11 | 
12 |     let manga: Manga
13 | 
14 |     @State var refresh = false
15 |     let refreshPublisher = NotificationCenter.default.publisher(for: Notification.Name("updateTrackers"))
16 | 
17 |     var body: some View {
18 |         VStack {
19 |             ForEach(TrackerManager.shared.trackers, id: \.id) { tracker in
20 |                 if tracker.isLoggedIn {
21 |                     if let item = CoreDataManager.shared.getTrack(
22 |                         trackerId: tracker.id,
23 |                         sourceId: manga.sourceId,
24 |                         mangaId: manga.id
25 |                     )?.toItem() {
26 |                         TrackerView(tracker: tracker, item: item, refresh: $refresh)
27 |                             .transition(.opacity)
28 |                     } else {
29 |                         TrackerAddView(tracker: tracker, manga: manga, refresh: $refresh)
30 |                             .transition(.opacity)
31 |                     }
32 |                 }
33 |             }
34 |         }
35 |         .padding([.bottom])
36 |         .onChange(of: refresh) { _ in } // in order to trigger a refresh
37 |         .onReceive(refreshPublisher) { _ in
38 |             withAnimation {
39 |                 refresh.toggle()
40 |             }
41 |         }
42 |     }
43 | }
44 | 


--------------------------------------------------------------------------------
/iOS/Old UI/Manga/Tracking/TrackerModalViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackerModalViewController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 6/26/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class TrackerModalViewController: MiniModalViewController {
11 | 
12 |     let manga: Manga
13 |     var swiftuiViewController: HostingController<TrackerListView>
14 | 
15 |     init(manga: Manga) {
16 |         self.manga = manga
17 |         swiftuiViewController = HostingController(rootView: TrackerListView(manga: manga))
18 |         swiftuiViewController.view.backgroundColor = .clear
19 |         super.init(nibName: nil, bundle: nil)
20 |     }
21 | 
22 |     required init?(coder: NSCoder) {
23 |         fatalError("init(coder:) has not been implemented")
24 |     }
25 | 
26 |     override func viewDidLoad() {
27 |         super.viewDidLoad()
28 | 
29 |         containerView.clipsToBounds = true
30 | 
31 |         addChild(swiftuiViewController)
32 |         swiftuiViewController.view.translatesAutoresizingMaskIntoConstraints = false
33 |         scrollView.addSubview(swiftuiViewController.view)
34 |         swiftuiViewController.didMove(toParent: self)
35 | 
36 |         swiftuiViewController.view.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
37 |         swiftuiViewController.view.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
38 |         swiftuiViewController.view.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor).isActive = true
39 | 
40 |         scrollView.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
41 | 
42 |         let screenHeightConstraint = scrollView.heightAnchor.constraint(lessThanOrEqualToConstant: UIScreen.main.bounds.height - 64)
43 |         screenHeightConstraint.priority = .defaultHigh
44 |         screenHeightConstraint.isActive = true
45 | 
46 |         let hostingHeightConstraint = scrollView.heightAnchor.constraint(equalTo: swiftuiViewController.view.heightAnchor, constant: 20)
47 |         hostingHeightConstraint.priority = .defaultLow
48 |         hostingHeightConstraint.isActive = true
49 | 
50 |         scrollView.widthAnchor.constraint(equalTo: containerView.widthAnchor).isActive = true
51 |     }
52 | }
53 | 


--------------------------------------------------------------------------------
/iOS/Old UI/Manga/Tracking/TrackerSettingOptionViewCoordinator.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TrackerSettingOptionViewCoordinator.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 7/19/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class TrackerSettingOptionViewCoordinator: NSObject, UIPickerViewDelegate, UIPickerViewDataSource {
11 | 
12 |     var total: Int
13 |     var numberType: NumberType
14 | 
15 |     let pickerView = UIPickerView(frame: CGRect(x: 10, y: 40, width: 250, height: 150))
16 | 
17 |     init(total: Int = 0, numberType: NumberType = .int) {
18 |         self.total = total
19 |         self.numberType = numberType
20 |         super.init()
21 |         pickerView.delegate = self
22 |         pickerView.dataSource = self
23 |     }
24 | 
25 |     func numberOfComponents(in pickerView: UIPickerView) -> Int {
26 |         1
27 |     }
28 | 
29 |     func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
30 |         numberType == .int ? total + 1 : total * 10 + 1
31 |     }
32 | 
33 |     func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
34 |         row == 0 ? "-" : numberType == .int ? String(row) : String(format: "%g", Float(row) / 10)
35 |     }
36 | }
37 | 


--------------------------------------------------------------------------------
/iOS/Old UI/Reader/ReaderNavigationController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ReaderNavigationController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 12/23/21.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class ReaderNavigationController: UINavigationController {
11 | 
12 |     override var childForStatusBarHidden: UIViewController? {
13 |         topViewController
14 |     }
15 | 
16 |     override var childForStatusBarStyle: UIViewController? {
17 |         topViewController
18 |     }
19 | 
20 |     override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
21 |         switch UserDefaults.standard.string(forKey: "Reader.orientation") {
22 |         case "device": .all
23 |         case "portrait": .portrait
24 |         case "landscape": .landscape
25 |         default: .all
26 |         }
27 |     }
28 | }
29 | 


--------------------------------------------------------------------------------
/iOS/Old UI/Settings/SettingsAboutViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SettingsAboutViewController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 2/12/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class SettingsAboutViewController: UITableViewController {
11 | 
12 |     init() {
13 |         super.init(style: .insetGrouped)
14 |     }
15 | 
16 |     required init?(coder: NSCoder) {
17 |         fatalError("init(coder:) has not been implemented")
18 |     }
19 | 
20 |     override func viewDidLoad() {
21 |         super.viewDidLoad()
22 | 
23 |         title = NSLocalizedString("ABOUT", comment: "")
24 | 
25 |         if #available(iOS 15.0, *) {
26 |             tableView.sectionHeaderTopPadding = 0
27 |         }
28 |     }
29 | }
30 | 
31 | // MARK: - Table View Data Source
32 | extension SettingsAboutViewController {
33 | 
34 |     override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
35 |         2
36 |     }
37 | 
38 |     override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
39 |         var cell: UITableViewCell? = tableView.dequeueReusableCell(withIdentifier: "AboutCell")
40 |         if cell == nil {
41 |             cell = UITableViewCell(style: .value1, reuseIdentifier: "AboutCell")
42 |         }
43 | 
44 |         switch indexPath.row {
45 |         case 0:
46 |             cell?.textLabel?.text = NSLocalizedString("VERSION", comment: "")
47 |             cell?.detailTextLabel?.text = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
48 |                                             ?? NSLocalizedString("UNKNOWN", comment: "")
49 |         case 1:
50 |             cell?.textLabel?.text = NSLocalizedString("BUILD", comment: "")
51 |             cell?.detailTextLabel?.text = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
52 |                                             ?? NSLocalizedString("UNKNOWN", comment: "")
53 |         default: break
54 |         }
55 |         cell?.detailTextLabel?.textColor = .secondaryLabel
56 |         cell?.selectionStyle = .none
57 | 
58 |         return cell ?? UITableViewCell()
59 |     }
60 | 
61 |     override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
62 |         tableView.deselectRow(at: indexPath, animated: true)
63 |     }
64 | }
65 | 


--------------------------------------------------------------------------------
/iOS/UI/Browse/SmallSectionHeaderConfiguration.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SmallSectionHeaderConfiguration.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 12/31/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | struct SmallSectionHeaderConfiguration: UIContentConfiguration {
11 | 
12 |     var title: String?
13 | 
14 |     func makeContentView() -> UIView & UIContentView {
15 |         SmallSectionHeaderContentView(self)
16 |     }
17 | 
18 |     func updated(for state: UIConfigurationState) -> Self {
19 |         self
20 |     }
21 | }
22 | 
23 | class SmallSectionHeaderContentView: UIView, UIContentView {
24 | 
25 |     var configuration: UIContentConfiguration {
26 |         didSet {
27 |             configure()
28 |         }
29 |     }
30 | 
31 |     let titleLabel = UILabel()
32 | 
33 |     init(_ configuration: UIContentConfiguration) {
34 |         self.configuration = configuration
35 |         super.init(frame: .zero)
36 | 
37 |         titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
38 |         titleLabel.translatesAutoresizingMaskIntoConstraints = false
39 |         addSubview(titleLabel)
40 | 
41 |         NSLayoutConstraint.activate([
42 |             titleLabel.topAnchor.constraint(equalTo: topAnchor),
43 |             titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
44 |             titleLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor, constant: 12),
45 |             titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.trailingAnchor, constant: -12)
46 |         ])
47 | 
48 |         configure()
49 |     }
50 | 
51 |     required init?(coder: NSCoder) {
52 |         fatalError("init(coder:) has not been implemented")
53 |     }
54 | 
55 |     func configure() {
56 |         guard let configuration = configuration as? SmallSectionHeaderConfiguration else { return }
57 |         titleLabel.text = configuration.title
58 |     }
59 | }
60 | 


--------------------------------------------------------------------------------
/iOS/UI/Common/BaseCollectionViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BaseCollectionViewController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 9/27/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class BaseCollectionViewController: BaseObservingViewController, UICollectionViewDelegate {
11 | 
12 |     lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeCollectionViewLayout())
13 | 
14 |     override func configure() {
15 |         collectionView.delegate = self
16 |         collectionView.delaysContentTouches = false
17 |         collectionView.alwaysBounceVertical = true
18 |         collectionView.showsHorizontalScrollIndicator = false
19 |         collectionView.translatesAutoresizingMaskIntoConstraints = false
20 |         view.addSubview(collectionView)
21 |     }
22 | 
23 |     override func constrain() {
24 |         NSLayoutConstraint.activate([
25 |             collectionView.topAnchor.constraint(equalTo: view.topAnchor),
26 |             collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
27 |             collectionView.leftAnchor.constraint(equalTo: view.leftAnchor),
28 |             collectionView.rightAnchor.constraint(equalTo: view.rightAnchor)
29 |         ])
30 |     }
31 | 
32 |     func makeCollectionViewLayout() -> UICollectionViewLayout {
33 |         UICollectionViewCompositionalLayout { _, _ in
34 |             nil
35 |         }
36 |     }
37 | }
38 | 


--------------------------------------------------------------------------------
/iOS/UI/Common/BaseObservingCellNode.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BaseObservingCellNode.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 3/24/23.
 6 | //
 7 | 
 8 | import AsyncDisplayKit
 9 | 
10 | class BaseObservingCellNode: ASCellNode {
11 | 
12 |     private var observers: [NSObjectProtocol] = []
13 | 
14 |     deinit {
15 |         for observer in observers {
16 |             NotificationCenter.default.removeObserver(observer)
17 |         }
18 |     }
19 | 
20 |     func addObserver(forName name: String, object: Any? = nil, using block: @escaping (Notification) -> Void) {
21 |         observers.append(NotificationCenter.default.addObserver(
22 |             forName: NSNotification.Name(name), object: object, queue: nil, using: block
23 |         ))
24 |     }
25 | }
26 | 


--------------------------------------------------------------------------------
/iOS/UI/Common/BaseObservingViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BaseObservingViewController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/2/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class BaseObservingViewController: BaseViewController {
11 | 
12 |     private var observers: [NSObjectProtocol] = []
13 | 
14 |     deinit {
15 |         for observer in observers {
16 |             NotificationCenter.default.removeObserver(observer)
17 |         }
18 |     }
19 | 
20 |     func addObserver(forName name: NSNotification.Name, object: Any? = nil, using block: @escaping (Notification) -> Void) {
21 |         observers.append(NotificationCenter.default.addObserver(
22 |             forName: name, object: object, queue: nil, using: block
23 |         ))
24 |     }
25 | 
26 |     func addObserver(forName name: String, object: Any? = nil, using block: @escaping (Notification) -> Void) {
27 |         addObserver(forName: NSNotification.Name(name), object: object, using: block)
28 |     }
29 | 
30 |     override func viewDidLoad() {
31 |         super.viewDidLoad()
32 |         observe()
33 |     }
34 | 
35 |     func observe() {}
36 | }
37 | 


--------------------------------------------------------------------------------
/iOS/UI/Common/BaseTableViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BaseTableViewController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 12/30/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class BaseTableViewController: BaseObservingViewController, UITableViewDelegate {
11 | 
12 |     lazy var tableView = UITableView(frame: .zero, style: tableViewStyle)
13 | 
14 |     var tableViewStyle: UITableView.Style {
15 |         .insetGrouped
16 |     }
17 | 
18 |     override func configure() {
19 |         tableView.delegate = self
20 |         tableView.delaysContentTouches = false
21 |         tableView.showsHorizontalScrollIndicator = false
22 |         tableView.translatesAutoresizingMaskIntoConstraints = false
23 |         view.addSubview(tableView)
24 |     }
25 | 
26 |     override func constrain() {
27 |         NSLayoutConstraint.activate([
28 |             tableView.topAnchor.constraint(equalTo: view.topAnchor),
29 |             tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
30 |             tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
31 |             tableView.rightAnchor.constraint(equalTo: view.rightAnchor)
32 |         ])
33 |     }
34 | }
35 | 


--------------------------------------------------------------------------------
/iOS/UI/Common/EmptyPageStackView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  EmptyPageStackView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/12/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class EmptyPageStackView: UIStackView {
11 | 
12 |     var title: String? {
13 |         get { titleLabel.text }
14 |         set { titleLabel.text = newValue }
15 |     }
16 | 
17 |     var text: String? {
18 |         get { textLabel.text }
19 |         set { textLabel.text = newValue }
20 |     }
21 | 
22 |     var buttonText: String? {
23 |         get { button.title(for: .normal) }
24 |         set { button.setTitle(newValue, for: .normal) }
25 |     }
26 | 
27 |     var showsButton: Bool {
28 |         get { !button.isHidden }
29 |         set { button.isHidden = !newValue }
30 |     }
31 | 
32 |     private let titleLabel = UILabel()
33 |     private let textLabel = UILabel()
34 |     private let button = UIButton(type: .roundedRect)
35 | 
36 |     override init(frame: CGRect) {
37 |         super.init(frame: frame)
38 |         configure()
39 |     }
40 | 
41 |     required init(coder: NSCoder) {
42 |         fatalError("init(coder:) has not been implemented")
43 |     }
44 | 
45 |     func configure() {
46 |         axis = .vertical
47 |         alignment = .center
48 |         distribution = .equalSpacing
49 |         spacing = 5
50 | 
51 |         titleLabel.font = .systemFont(ofSize: 25, weight: .semibold)
52 |         titleLabel.textColor = .secondaryLabel
53 |         addArrangedSubview(titleLabel)
54 | 
55 |         textLabel.font = .systemFont(ofSize: 15)
56 |         textLabel.textColor = .secondaryLabel
57 |         textLabel.numberOfLines = 0
58 |         textLabel.textAlignment = .center
59 |         addArrangedSubview(textLabel)
60 | 
61 |         button.isHidden = true
62 |         addArrangedSubview(button)
63 |     }
64 | 
65 |     func addButtonTarget(_ target: Any?, action: Selector, for event: UIControl.Event = .touchUpInside) {
66 |         button.addTarget(target, action: action, for: event)
67 |     }
68 | }
69 | 


--------------------------------------------------------------------------------
/iOS/UI/Common/LockedPageStackView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  LockedPageStackView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/12/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class LockedPageStackView: UIStackView {
11 | 
12 |     var text: String? {
13 |         get { textLabel.text }
14 |         set { textLabel.text = newValue }
15 |     }
16 | 
17 |     var buttonText: String? {
18 |         get { button.title(for: .normal) }
19 |         set { button.setTitle(newValue, for: .normal) }
20 |     }
21 | 
22 |     private let imageView = UIImageView()
23 |     private let textLabel = UILabel()
24 |     let button = UIButton(type: .roundedRect)
25 | 
26 |     override init(frame: CGRect) {
27 |         super.init(frame: frame)
28 |         configure()
29 |         constrain()
30 |     }
31 | 
32 |     required init(coder: NSCoder) {
33 |         fatalError("init(coder:) has not been implemented")
34 |     }
35 | 
36 |     func configure() {
37 |         axis = .vertical
38 |         alignment = .center
39 |         distribution = .fill
40 |         spacing = 2
41 | 
42 |         imageView.image = UIImage(systemName: "lock.fill")
43 |         imageView.contentMode = .scaleAspectFit
44 |         imageView.tintColor = .secondaryLabel
45 |         imageView.translatesAutoresizingMaskIntoConstraints = false
46 |         addArrangedSubview(imageView)
47 |         setCustomSpacing(12, after: imageView)
48 | 
49 |         textLabel.font = .systemFont(ofSize: 16, weight: .medium)
50 |         addArrangedSubview(textLabel)
51 | 
52 |         addArrangedSubview(button)
53 |     }
54 | 
55 |     func constrain() {
56 |         NSLayoutConstraint.activate([
57 |             imageView.heightAnchor.constraint(equalToConstant: 66),
58 |             imageView.widthAnchor.constraint(equalToConstant: 66)
59 |         ])
60 |     }
61 | }
62 | 


--------------------------------------------------------------------------------
/iOS/UI/Common/SwiftUINavigationController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SwiftUINavigationController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by axiel7 on 11/02/2024.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | // This is a workaround to fix an iOS bug that occurs when mixing UIKit with SwiftUI,
11 | // that causes the current TabBarItem title to be lost when navigating inside SwiftUI views.
12 | // See: https://stackoverflow.com/questions/62662313/uitabbar-containing-swiftui-view
13 | class SwiftUINavigationController: UINavigationController {
14 |     private var storedTabBarItem: UITabBarItem?
15 |     override var tabBarItem: UITabBarItem! {
16 |         get { storedTabBarItem ?? super.tabBarItem }
17 |         set { storedTabBarItem = newValue }
18 |     }
19 | }
20 | 


--------------------------------------------------------------------------------
/iOS/UI/Common/SwiftUINavigationView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SwiftUINavigationView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 1/6/23.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct SwiftUINavigationView<Content: View>: View {
11 | 
12 |     @Environment(\.presentationMode) var presentationMode
13 | 
14 |     var rootView: Content
15 | 
16 |     var closeButtonTitle: String = NSLocalizedString("CANCEL", comment: "")
17 | 
18 |     var body: some View {
19 |         NavigationView {
20 |             rootView
21 |                 .toolbar {
22 |                     ToolbarItem(placement: .cancellationAction) {
23 |                         Button(closeButtonTitle) {
24 |                             dismiss()
25 |                         }
26 |                     }
27 |                 }
28 |         }
29 |         .navigationViewStyle(StackNavigationViewStyle())
30 |     }
31 | 
32 |     func dismiss() {
33 |         if #available(iOS 15.0, *) {
34 |             presentationMode.wrappedValue.dismiss()
35 |         } else {
36 |             // for ios 14
37 |             if var topController = UIApplication.shared.windows.first!.rootViewController {
38 |                 while let presentedViewController = topController.presentedViewController {
39 |                     topController = presentedViewController
40 |                 }
41 |                 topController.dismiss(animated: true)
42 |             }
43 |         }
44 |     }
45 | }
46 | 


--------------------------------------------------------------------------------
/iOS/UI/Common/Zooming/ZoomableCollectionViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ZoomableCollectionViewController.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 12/21/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | import AsyncDisplayKit
10 | 
11 | class ZoomableCollectionViewController: BaseObservingViewController {
12 | 
13 |     let zoomView: ZoomableCollectionView
14 | 
15 |     var scrollNode: ASScrollNode {
16 |         zoomView.scrollNode
17 |     }
18 |     var scrollView: UIScrollView {
19 |         zoomView.scrollNode.view
20 |     }
21 |     var collectionNode: ASCollectionNode {
22 |         zoomView.collectionNode
23 |     }
24 | 
25 |     convenience override init() {
26 |         self.init(layout: UICollectionViewLayout())
27 |     }
28 | 
29 |     init(layout: UICollectionViewLayout) {
30 |         let zoomView = ZoomableCollectionView(layout: layout)
31 |         self.zoomView = zoomView
32 |         super.init(node: zoomView)
33 |         configure()
34 |     }
35 | 
36 |     @available(*, unavailable)
37 |     required init?(coder: NSCoder) {
38 |         fatalError("init(coder:) has not been implemented")
39 |     }
40 | 
41 |     override func configure() {
42 |         scrollView.delegate = self
43 |         scrollView.delaysContentTouches = false
44 |         scrollView.alwaysBounceVertical = true
45 |         scrollView.showsHorizontalScrollIndicator = false
46 |         scrollNode.isUserInteractionEnabled = true
47 |         scrollNode.automaticallyManagesContentSize = false
48 |     }
49 | }
50 | 
51 | // MARK: - Scroll View Delegate
52 | extension ZoomableCollectionViewController: UIScrollViewDelegate {
53 | 
54 |     func scrollViewDidScroll(_ scrollView: UIScrollView) {
55 |         zoomView.scrollViewDidScroll(scrollView)
56 |     }
57 | 
58 |     func scrollViewDidZoom(_ scrollView: UIScrollView) {
59 |         zoomView.scrollViewDidZoom(scrollView)
60 |     }
61 | 
62 |     func viewForZooming(in scrollView: UIScrollView) -> UIView? {
63 |         zoomView.viewForZooming(in: scrollView)
64 |     }
65 | 
66 |     func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
67 |         zoomView.scrollViewWillBeginDragging(scrollView)
68 |     }
69 | }
70 | 


--------------------------------------------------------------------------------
/iOS/UI/Common/Zooming/ZoomableLayoutProtocol.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ZoomableLayoutProtocol.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 12/21/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | protocol ZoomableLayoutProtocol {
11 |     func getScale() -> CGFloat
12 |     func setScale(_ scale: CGFloat)
13 | }
14 | 


--------------------------------------------------------------------------------
/iOS/UI/Manga/MangaLabelView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MangaLabelView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 1/2/23.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class MangaLabelView: UIView {
11 | 
12 |     private let padding: CGFloat = 8
13 | 
14 |     var text: String? {
15 |         get { label.text }
16 |         set { label.text = newValue }
17 |     }
18 | 
19 |     lazy var label: UILabel = {
20 |         let label = UILabel()
21 |         label.textColor = .secondaryLabel
22 |         label.font = .systemFont(ofSize: 10)
23 |         label.textAlignment = .center
24 |         label.translatesAutoresizingMaskIntoConstraints = false
25 |         return label
26 |     }()
27 | 
28 |     init() {
29 |         super.init(frame: .zero)
30 |         configure()
31 |         constrain()
32 |     }
33 | 
34 |     required init?(coder: NSCoder) {
35 |         fatalError("init(coder:) has not been implemented")
36 |     }
37 | 
38 |     func configure() {
39 |         backgroundColor = .tertiarySystemFill
40 |         layer.cornerRadius = 6
41 |         layer.cornerCurve = .continuous
42 | 
43 |         addSubview(label)
44 |     }
45 | 
46 |     func constrain() {
47 |         NSLayoutConstraint.activate([
48 |             label.centerXAnchor.constraint(equalTo: centerXAnchor),
49 |             label.centerYAnchor.constraint(equalTo: centerYAnchor),
50 | 
51 |             widthAnchor.constraint(equalTo: label.widthAnchor, constant: padding * 2),
52 |             heightAnchor.constraint(equalTo: label.heightAnchor, constant: padding)
53 |         ])
54 |     }
55 | }
56 | 


--------------------------------------------------------------------------------
/iOS/UI/Manga/MangaViewOld.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MangaViewOld.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by axiel7 on 09/02/2024.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct MangaViewOld: UIViewControllerRepresentable {
11 | 
12 |     let manga: Manga
13 |     var chapterList: [Chapter] = []
14 |     var scrollTo: Chapter?
15 | 
16 |     func makeUIViewController(context: Context) -> MangaViewController {
17 |         MangaViewController(manga: manga, chapterList: chapterList, scrollTo: scrollTo)
18 |     }
19 | 
20 |     func updateUIViewController(_ uiViewController: MangaViewController, context: Context) {
21 | 
22 |     }
23 | }
24 | 


--------------------------------------------------------------------------------
/iOS/UI/Manga/SizeChangeListenerDelegate.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SizeChangeListenerDelegate.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 1/2/23.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | // generic delegate for parents to listen to child view size changes
11 | 
12 | protocol SizeChangeListenerDelegate: AnyObject {
13 |     func sizeChanged(_ newSize: CGSize)
14 | }
15 | 


--------------------------------------------------------------------------------
/iOS/UI/Manga/TouchDownGestureRecognizer.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TouchDownGestureRecognizer.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/26/23.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | class TouchDownGestureRecognizer: UIGestureRecognizer {
11 |     private var startPoint: CGPoint?
12 | 
13 |     override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
14 |         false
15 |     }
16 | 
17 |     override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool {
18 |         false
19 |     }
20 | 
21 |     override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
22 |         super.touchesBegan(touches, with: event)
23 |         state = .began
24 |         startPoint = location(in: view)
25 |     }
26 | 
27 |     override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
28 |         super.touchesMoved(touches, with: event)
29 |         state = .changed
30 | 
31 |         // cancel gesture if we start scrolling
32 |         guard let startPoint else { return }
33 |         let position = location(in: view)
34 |         if abs(position.y - startPoint.y) >= 10 {
35 |             state = .cancelled
36 |         }
37 |     }
38 | 
39 |     override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
40 |         super.touchesCancelled(touches, with: event)
41 |         state = .cancelled
42 |     }
43 | 
44 |     override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
45 |         super.touchesEnded(touches, with: event)
46 |         state = .ended
47 |     }
48 | }
49 | 


--------------------------------------------------------------------------------
/iOS/UI/Migration/MangaGridView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MangaGridView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 1/5/23.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | import NukeUI
10 | 
11 | struct MangaGridView: View {
12 |     var title: String?
13 |     var coverUrl: URL?
14 | 
15 |     var body: some View {
16 |         LazyImage(url: coverUrl) { state in
17 |             if let image = state.image {
18 |                 image
19 |                     .resizable()
20 |                     .aspectRatio(2/3, contentMode: .fill)
21 |             } else {
22 |                 Image("MangaPlaceholder")
23 |                     .resizable()
24 |                     .aspectRatio(2/3, contentMode: .fill)
25 |             }
26 |         }
27 |         .animation(.default, value: coverUrl)
28 |         .cornerRadius(5)
29 |         .foregroundColor(Color(UIColor.red))
30 |         .overlay(
31 |             LinearGradient(gradient: Gradient(colors: [
32 |                 Color.black.opacity(0.01),
33 |                 Color.black.opacity(0.7)
34 |             ]), startPoint: .top, endPoint: .bottom)
35 |             .cornerRadius(5)
36 |         )
37 |         .overlay(
38 |             RoundedRectangle(cornerRadius: 5)
39 |                 .stroke(Color(UIColor.quaternarySystemFill), lineWidth: 1)
40 |         )
41 |         .overlay(
42 |             Text(title ?? "")
43 |                 .foregroundColor(.white)
44 |                 .font(.system(size: 15, weight: .medium))
45 |                 .multilineTextAlignment(.leading)
46 |                 .lineLimit(2)
47 |                 .padding(8),
48 |             alignment: .bottomLeading
49 |         )
50 |     }
51 | }
52 | 
53 | struct PlaceholderMangaGridView: View {
54 |     var body: some View {
55 |         Image("MangaPlaceholder")
56 |             .resizable()
57 |             .aspectRatio(2/3, contentMode: .fill)
58 |             .cornerRadius(5)
59 |             .foregroundColor(Color(UIColor.systemFill))
60 |             .overlay(
61 |                 RoundedRectangle(cornerRadius: 5)
62 |                     .stroke(Color(UIColor.quaternarySystemFill), lineWidth: 1)
63 |             )
64 |     }
65 | }
66 | 


--------------------------------------------------------------------------------
/iOS/UI/Migration/Models/MigrationState.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MigrationState.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 1/5/23.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | enum MigrationState {
11 |     case idle
12 |     case running
13 |     case failed
14 |     case done
15 | }
16 | 


--------------------------------------------------------------------------------
/iOS/UI/Migration/Models/MigrationStrategy.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MigrationStrategy.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 1/5/23.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | // enum MigrationStrategory: CaseIterable {
11 | //    case firstAlternative
12 | //    case mostChapters
13 | //
14 | //    func toString() -> String {
15 | //        switch self {
16 | //        case .firstAlternative: return NSLocalizedString("FIRST_ALTERNATIVE", comment: "")
17 | //        case .mostChapters: return NSLocalizedString("MOST_CHAPTERS_SLOWER", comment: "")
18 | //        }
19 | //    }
20 | // }
21 | 


--------------------------------------------------------------------------------
/iOS/UI/Reader/Page/MarkdownView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MarkdownView.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/20/25.
 6 | //
 7 | 
 8 | import MarkdownUI
 9 | import SwiftUI
10 | 
11 | struct MarkdownView: View {
12 |     @State private var markdownString: String
13 |     @State private var safariUrl: URL?
14 |     @State private var showSafari = false
15 | 
16 |     init(_ markdownString: String) {
17 |         self.markdownString = markdownString
18 |     }
19 | 
20 |     var body: some View {
21 |         Markdown {
22 |             markdownString
23 |         }
24 |         .environment(
25 |             \.openURL,
26 |             OpenURLAction { url in
27 |                 if url.scheme == "http" || url.scheme == "https" {
28 |                     safariUrl = url
29 |                     showSafari = true
30 |                 }
31 |                 return .handled
32 |             }
33 |         )
34 |         .padding()
35 |         .fullScreenCover(isPresented: $showSafari) {
36 |             SafariView(url: $safariUrl)
37 |                 .ignoresSafeArea()
38 |         }
39 |     }
40 | }
41 | 


--------------------------------------------------------------------------------
/iOS/UI/Reader/ReaderHoldingDelegate.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ReaderHoldingDelegate.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/16/22.
 6 | //
 7 | 
 8 | import Foundation
 9 | import AidokuRunner
10 | 
11 | protocol ReaderHoldingDelegate: AnyObject {
12 | 
13 |     func getNextChapter() -> AidokuRunner.Chapter?
14 |     func getPreviousChapter() -> AidokuRunner.Chapter?
15 |     func setChapter(_ chapter: AidokuRunner.Chapter)
16 | 
17 |     func setCurrentPage(_ page: Int)
18 |     func setCurrentPages(_ pages: ClosedRange<Int>)
19 |     func setPages(_ pages: [Page])
20 |     func displayPage(_ page: Int) // show page on toolbar but don't set it as current page
21 |     func setSliderOffset(_ offset: CGFloat)
22 |     func setCompleted()
23 | }
24 | 


--------------------------------------------------------------------------------
/iOS/UI/Reader/Readers/ReaderReaderDelegate.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ReaderReaderDelegate.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/16/22.
 6 | //
 7 | 
 8 | import UIKit
 9 | import AidokuRunner
10 | 
11 | @MainActor
12 | // swiftlint:disable:next class_delegate_protocol
13 | protocol ReaderReaderDelegate: UIViewController {
14 | 
15 |     var readingMode: ReadingMode { get set }
16 |     var delegate: ReaderHoldingDelegate? { get set }
17 | 
18 |     func sliderMoved(value: CGFloat)
19 |     func sliderStopped(value: CGFloat)
20 |     func setChapter(_ chapter: AidokuRunner.Chapter, startPage: Int)
21 | }
22 | 


--------------------------------------------------------------------------------
/iOS/UI/Reader/Readers/Text/ReaderTextViewModel.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ReaderTextViewModel.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/13/25.
 6 | //
 7 | 
 8 | import AidokuRunner
 9 | 
10 | @MainActor
11 | class ReaderTextViewModel: ReaderPagedViewModel {
12 | //    var page: AidokuRunner.Page?
13 | }
14 | 


--------------------------------------------------------------------------------
/iOS/UI/Reader/Readers/Webtoon/GIFImageNode.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  GIFImageNode.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 6/23/25.
 6 | //
 7 | 
 8 | import AsyncDisplayKit
 9 | import Gifu
10 | 
11 | class GIFImageNode: ASControlNode {
12 |     var imageView: GIFImageView?
13 |     var animatedData: Data?
14 |     var storedInteraction: UIInteraction?
15 | 
16 |     override var contentMode: UIView.ContentMode {
17 |         didSet {
18 |             imageView?.contentMode = contentMode
19 |         }
20 |     }
21 | 
22 |     var image: UIImage? {
23 |         didSet {
24 |             Task { @MainActor in
25 |                 imageView?.image = image
26 |             }
27 |         }
28 |     }
29 | 
30 |     override var isUserInteractionEnabled: Bool {
31 |         didSet {
32 |             imageView?.isUserInteractionEnabled = isUserInteractionEnabled
33 |         }
34 |     }
35 | 
36 |     override init() {
37 |         super.init()
38 | 
39 |         setViewBlock { [weak self] in
40 |             let gifView = GIFImageView()
41 |             gifView.image = self?.image
42 |             gifView.isUserInteractionEnabled = true
43 |             if let contentMode = self?.contentMode {
44 |                 gifView.contentMode = contentMode
45 |             }
46 |             if let data = self?.animatedData {
47 |                 gifView.animate(withGIFData: data)
48 |                 self?.animatedData = nil
49 |             }
50 |             if let interaction = self?.storedInteraction {
51 |                 gifView.addInteraction(interaction)
52 |                 self?.storedInteraction = nil
53 |             }
54 |             self?.imageView = gifView
55 |             return gifView
56 |         }
57 |     }
58 | 
59 |     func animate(withGIFData data: Data) {
60 |         if let imageView {
61 |             Task { @MainActor in
62 |                 imageView.animate(withGIFData: data)
63 |             }
64 |         } else {
65 |             animatedData = data
66 |         }
67 |     }
68 | 
69 |     func addInteraction(_ interaction: UIInteraction) {
70 |         if let imageView {
71 |             imageView.addInteraction(interaction)
72 |         } else {
73 |             storedInteraction = interaction
74 |         }
75 |     }
76 | }
77 | 


--------------------------------------------------------------------------------
/iOS/UI/Reader/Readers/Webtoon/HeightQueryable.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  HeightQueryable.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 3/23/23.
 6 | //
 7 | 
 8 | import UIKit
 9 | 
10 | protocol HeightQueryable {
11 |     func getHeight() -> CGFloat
12 | }
13 | 
14 | extension ReaderWebtoonPageNode: HeightQueryable {
15 |     func getHeight() -> CGFloat {
16 |         if pillarbox && isPillarboxOrientation() {
17 |             let percent = (100 - pillarboxAmount) / 100
18 |             let ratio = percent * (ratio ?? Self.defaultRatio)
19 |             return UIScreen.main.bounds.width * ratio
20 |         } else {
21 |             let ratio = ratio ?? Self.defaultRatio
22 |             return UIScreen.main.bounds.width * ratio
23 |         }
24 |     }
25 | }
26 | 
27 | extension ReaderWebtoonTransitionNode: HeightQueryable {
28 |     func getHeight() -> CGFloat {
29 |         if pillarbox && isPillarboxOrientation() {
30 |             return UIScreen.main.bounds.width * (100 - pillarboxAmount) / 100
31 |         } else {
32 |             return UIScreen.main.bounds.width
33 |         }
34 |     }
35 | }
36 | 


--------------------------------------------------------------------------------
/iOS/UI/Reader/Readers/Webtoon/HostingNode.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  HostingNode.swift
 3 | //  Aidoku
 4 | //
 5 | //  Created by Skitty on 5/20/25.
 6 | //
 7 | 
 8 | import AsyncDisplayKit
 9 | import SwiftUI
10 | 
11 | class HostingNode<Content: View>: ASDisplayNode {
12 |     weak var parentViewController: UIViewController?
13 |     var content: Content
14 | 
15 |     private var viewController: UIViewController?
16 | 
17 |     init(
18 |         parentViewController: UIViewController? = nil,
19 |         content: Content
20 |     ) {
21 |         self.parentViewController = parentViewController
22 |         self.content = content
23 |         super.init()
24 | 
25 |         setViewBlock { [weak self] in
26 |             guard let self else { return UIView() }
27 |             let hostingController = self.makeHostingController()
28 |             self.viewController = hostingController
29 |             parentViewController?.addChild(hostingController)
30 |             return hostingController.view
31 |         }
32 |         isUserInteractionEnabled = true
33 |     }
34 | 
35 |     private func makeHostingController() -> UIViewController {
36 |         let controller = UIHostingController(rootView: content)
37 |         if #available(iOS 16.4, *) {
38 |             controller.safeAreaRegions = []
39 |         }
40 |         controller.view.backgroundColor = .systemBackground // text page should have a background color
41 |         controller.view.isUserInteractionEnabled = true
42 |         return controller
43 |     }
44 | }
45 | 


--------------------------------------------------------------------------------
/iOS/UI/Reader/Readers/Webtoon/ReaderWebtoonTransitionNode.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ReaderWebtoonTransitionNode.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 3/22/23.
 6 | //
 7 | 
 8 | import AsyncDisplayKit
 9 | 
10 | class ReaderWebtoonTransitionNode: BaseObservingCellNode {
11 | 
12 |     let transition: Transition
13 | 
14 |     var pillarbox = UserDefaults.standard.bool(forKey: "Reader.pillarbox")
15 |     var pillarboxAmount: CGFloat = CGFloat(UserDefaults.standard.double(forKey: "Reader.pillarboxAmount"))
16 |     var pillarboxOrientation = UserDefaults.standard.string(forKey: "Reader.pillarboxOrientation")
17 | 
18 |     lazy var transitionNode = ReaderTransitionNode(transition: transition)
19 | 
20 |     init(transition: Transition) {
21 |         self.transition = transition
22 |         super.init()
23 |         automaticallyManagesSubnodes = true
24 |         addObserver(forName: "Reader.pillarbox") { [weak self] notification in
25 |             self?.pillarbox = notification.object as? Bool ?? false
26 |         }
27 |         addObserver(forName: "Reader.pillarboxAmount") { [weak self] notification in
28 |             guard let doubleValue = notification.object as? Double else { return }
29 |             self?.pillarboxAmount = CGFloat(doubleValue)
30 |         }
31 |         addObserver(forName: "Reader.pillarboxOrientation") { [weak self] notification in
32 |             self?.pillarboxOrientation = notification.object as? String ?? "both"
33 |         }
34 |     }
35 | 
36 |     func isPillarboxOrientation() -> Bool {
37 |         pillarboxOrientation == "both" ||
38 |             (pillarboxOrientation == "portrait" && UIDevice.current.orientation.isPortrait) ||
39 |             (pillarboxOrientation == "landscape" && UIDevice.current.orientation.isLandscape)
40 |     }
41 | 
42 |     override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
43 |         if pillarbox && isPillarboxOrientation() {
44 |             let percent = (100 - pillarboxAmount) / 100
45 |             let height = constrainedSize.max.width * percent
46 | 
47 |             transitionNode.style.width = ASDimensionMakeWithFraction(percent)
48 |             transitionNode.style.height = ASDimensionMakeWithPoints(height)
49 |             transitionNode.style.alignSelf = .center
50 | 
51 |             return ASCenterLayoutSpec(
52 |                 horizontalPosition: .center,
53 |                 verticalPosition: .center,
54 |                 sizingOption: [],
55 |                 child: transitionNode
56 |             )
57 |         } else {
58 |             return ASRatioLayoutSpec(ratio: 1, child: transitionNode)
59 |         }
60 |     }
61 | }
62 | 


--------------------------------------------------------------------------------
/iOS/UI/Reader/Readers/Webtoon/ReaderWebtoonViewModel.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ReaderWebtoonViewModel.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 9/27/22.
 6 | //
 7 | 
 8 | import AidokuRunner
 9 | import Foundation
10 | 
11 | @MainActor
12 | class ReaderWebtoonViewModel: ReaderPagedViewModel {
13 | 
14 |     override class var settings: SettingItem {
15 |         SettingItem(
16 |             type: "group",
17 |             title: NSLocalizedString("WEBTOON", comment: ""),
18 |             footer: NSLocalizedString("PILLARBOX_ORIENTATION_INFO", comment: ""),
19 |             items: [
20 |                 SettingItem(
21 |                     type: "switch",
22 |                     key: "Reader.verticalInfiniteScroll",
23 |                     title: NSLocalizedString("INFINITE_VERTICAL_SCROLL", comment: "")
24 |                 ),
25 |                 SettingItem(
26 |                     type: "switch",
27 |                     key: "Reader.pillarbox",
28 |                     title: NSLocalizedString("PILLARBOX", comment: "")
29 |                 ),
30 |                 SettingItem(
31 |                     type: "stepper",
32 |                     key: "Reader.pillarboxAmount",
33 |                     title: NSLocalizedString("PILLARBOX_AMOUNT", comment: ""),
34 |                     requires: "Reader.pillarbox",
35 |                     minimumValue: 0,
36 |                     maximumValue: 100,
37 |                     stepValue: 5
38 |                 ),
39 |                 SettingItem(
40 |                     type: "select",
41 |                     key: "Reader.pillarboxOrientation",
42 |                     title: NSLocalizedString("PILLARBOX_ORIENTATION", comment: ""),
43 |                     values: ["both", "portrait", "landscape"],
44 |                     titles: [
45 |                         NSLocalizedString("BOTH", comment: ""),
46 |                         NSLocalizedString("PORTRAIT", comment: ""),
47 |                         NSLocalizedString("LANDSCAPE", comment: "")
48 |                     ],
49 |                     requires: "Reader.pillarbox"
50 |                 )
51 |             ]
52 |         )
53 |     }
54 | 
55 |     func setPages(chapter: AidokuRunner.Chapter, pages: [Page]) {
56 |         self.chapter = chapter
57 |         self.pages = pages
58 |         if preloadedChapter == chapter {
59 |             preloadedPages = []
60 |         }
61 |     }
62 | }
63 | 


--------------------------------------------------------------------------------
/iOS/UI/Reader/ReadingMode.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ReadingMode.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by Skitty on 8/19/22.
 6 | //
 7 | 
 8 | enum ReadingMode: Int {
 9 |     case rtl = 1
10 |     case ltr = 2
11 |     case vertical = 3
12 |     case webtoon = 4
13 |     case continuous = 5
14 | }
15 | 


--------------------------------------------------------------------------------
/iOS/UI/Updates/MangaUpdateItemView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  MangaUpdateItemView.swift
 3 | //  Aidoku (iOS)
 4 | //
 5 | //  Created by axiel7 on 10/02/2024.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | import NukeUI
10 | 
11 | struct MangaUpdateItemView: View {
12 | 
13 |     private let coverWidth: CGFloat = 56
14 |     private let coverHeight: CGFloat = 56
15 |     private let cornerRadius: CGFloat = 5
16 |     private let chaptersLimit = 5
17 | 
18 |     var manga: Manga?
19 |     let updates: [MangaUpdatesView.MangaUpdateInfo]
20 |     let count: Int
21 |     let viewed: Bool
22 | 
23 |     init(updates: [MangaUpdatesView.MangaUpdateInfo]) {
24 |         self.updates = updates
25 |         self.count = updates.count
26 |         self.manga = updates.first?.manga
27 |         self.viewed = updates.first?.viewed == true
28 |     }
29 | 
30 |     var body: some View {
31 |         HStack(alignment: count == 1 ? .center : .top) {
32 |             LazyImage(url: manga?.coverUrl) { state in
33 |                 if let image = state.image {
34 |                     image
35 |                         .resizable()
36 |                         .aspectRatio(contentMode: .fill)
37 |                 } else {
38 |                     Image("MangaPlaceholder")
39 |                 }
40 |             }
41 |             .frame(width: coverWidth, height: coverHeight)
42 |             .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
43 |             .overlay(
44 |                 RoundedRectangle(cornerRadius: cornerRadius)
45 |                     .stroke(Color(UIColor.quaternarySystemFill), lineWidth: 1)
46 |             )
47 |             .padding(.trailing, 6)
48 | 
49 |             VStack(alignment: .leading) {
50 |                 Text(manga?.title ?? "")
51 |                     .foregroundColor(viewed ? .secondary : .primary)
52 |                     .lineLimit(2)
53 | 
54 |                 ForEach(updates.prefix(chaptersLimit)) { item in
55 |                     if let chapterTitle = item.chapter?.makeTitle() {
56 |                         Text(chapterTitle)
57 |                             .font(.footnote)
58 |                             .foregroundColor(.secondary)
59 |                             .lineLimit(1)
60 |                     }
61 |                 }
62 |                 if count > chaptersLimit {
63 |                     Text("\(count - chaptersLimit)_PLUS_MORE")
64 |                         .font(.footnote)
65 |                         .foregroundColor(.secondary)
66 |                         .lineLimit(1)
67 |                 }
68 |             }
69 |         }
70 |     }
71 | }
72 | 


--------------------------------------------------------------------------------
/iOS/iOS.entitlements:
--------------------------------------------------------------------------------
 1 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 3 | <plist version="1.0">
 4 | <dict>
 5 | 	<key>aps-environment</key>
 6 | 	<string>development</string>
 7 | 	<key>com.apple.developer.icloud-container-identifiers</key>
 8 | 	<array>
 9 | 		<string>$(ICLOUD_CONTAINER_ID)</string>
10 | 	</array>
11 | 	<key>com.apple.developer.icloud-services</key>
12 | 	<array>
13 | 		<string>CloudKit</string>
14 | 	</array>
15 | 	<key>com.apple.developer.ubiquity-kvstore-identifier</key>
16 | 	<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
17 | </dict>
18 | </plist>
19 | 


--------------------------------------------------------------------------------
/macOS/Aidoku-MACOS.xcconfig:
--------------------------------------------------------------------------------
1 | //
2 | //  Aidoku-MACOS.xcconfig
3 | //  Aidoku
4 | //
5 | //  Created by Nikolai Schumacher on 17.05.25.
6 | //
7 | 
8 | #include "../Shared/Aidoku.xcconfig"
9 | 


--------------------------------------------------------------------------------
/macOS/AidokuApp.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  AidokuApp.swift
 3 | //  Aidoku (macOS)
 4 | //
 5 | //  Created by Skitty on 12/29/21.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | @main
11 | struct AidokuApp: App {
12 |     var body: some Scene {
13 |         WindowGroup {
14 |             LibraryView()
15 |         }
16 |     }
17 | }
18 | 


--------------------------------------------------------------------------------
/macOS/Info.plist:
--------------------------------------------------------------------------------
1 | <?xml version="1.0" encoding="UTF-8"?>
2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 | <plist version="1.0">
4 | <dict>
5 | 	<key>ICLOUD_CONTAINER_ID</key>
6 | 	<string>$(ICLOUD_CONTAINER_ID)</string>
7 | </dict>
8 | </plist>
9 | 


--------------------------------------------------------------------------------
/macOS/Views/LibraryView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  LibraryView.swift
 3 | //  Aidoku (macOS)
 4 | //
 5 | //  Created by Skitty on 1/2/22.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct LibraryView: View {
11 |     var body: some View {
12 |         Spacer()
13 |     }
14 | }
15 | 


--------------------------------------------------------------------------------
/macOS/macOS.entitlements:
--------------------------------------------------------------------------------
 1 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 3 | <plist version="1.0">
 4 | <dict>
 5 | 	<key>com.apple.security.app-sandbox</key>
 6 | 	<true/>
 7 | 	<key>com.apple.security.files.user-selected.read-only</key>
 8 | 	<true/>
 9 | 	<key>com.apple.security.network.client</key>
10 | 	<true/>
11 | </dict>
12 | </plist>
13 | 


--------------------------------------------------------------------------------