├── .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 | --------------------------------------------------------------------------------