Compare commits
62 Commits
3d9d8ab365
...
ios-v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d7293d37 | ||
|
|
410af8f236 | ||
|
|
264c00c765 | ||
|
|
e4c72011eb | ||
|
|
6365b14ece | ||
|
|
7da5582075 | ||
|
|
dae841e317 | ||
|
|
16b2bfffa6 | ||
|
|
57be674f44 | ||
|
|
93390fab64 | ||
|
|
072517135f | ||
|
|
fe7c7acbb7 | ||
|
|
d4cce915d9 | ||
|
|
ac24e86f7d | ||
|
|
e9bb387f71 | ||
|
|
d7319b3f7c | ||
|
|
f380c85815 | ||
|
|
9d1b340b83 | ||
|
|
a307ddc9f5 | ||
|
|
004d1b6d9d | ||
|
|
7f20411f50 | ||
|
|
6e6c581904 | ||
|
|
cecedc8687 | ||
|
|
a88e98a436 | ||
|
|
d3ae86d55b | ||
|
|
5ad5c2dbce | ||
|
|
0de91dcc0c | ||
|
|
8e3e9ef31d | ||
|
|
3c5edd5742 | ||
|
|
2142e82fe4 | ||
|
|
88cde88f69 | ||
|
|
ffcc3981f2 | ||
|
|
a7b4694e60 | ||
|
|
8c895c6ba1 | ||
|
|
83059c8a9d | ||
|
|
b54ebf60b5 | ||
|
|
e027afe89d | ||
|
|
9fc2054e36 | ||
|
|
9a43b2190e | ||
|
|
5a7d7ce3b9 | ||
|
|
ce3eef1298 | ||
|
|
5d9b41bcf2 | ||
|
|
47268dea67 | ||
|
|
57591766f2 | ||
|
|
fa8fb96631 | ||
|
|
5ba84f7945 | ||
|
|
2793ad8cfa | ||
|
|
e43699747d | ||
|
|
1e85f1c0bc | ||
|
|
0c2349f259 | ||
|
|
c9252b5953 | ||
|
|
7efeee3fc2 | ||
|
|
9a05708019 | ||
|
|
24cb18e0fe | ||
|
|
71ba882858 | ||
|
|
c35f099f50 | ||
|
|
4df287ace4 | ||
|
|
0df45de2b6 | ||
|
|
825fb04c0d | ||
|
|
fc5cd30c93 | ||
|
|
37bd73651a | ||
|
|
466e289b68 |
@@ -2,18 +2,20 @@ name: CI / Scraper
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ci-scraper.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ci-scraper.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── lint & vet ───────────────────────────────────────────────────────────────
|
||||
lint:
|
||||
@@ -27,11 +29,11 @@ jobs:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@v2
|
||||
|
||||
- name: go vet
|
||||
run: just lint
|
||||
working-directory: scraper
|
||||
run: |
|
||||
go vet ./...
|
||||
go vet -tags integration ./...
|
||||
|
||||
# ── tests ────────────────────────────────────────────────────────────────────
|
||||
test:
|
||||
@@ -45,36 +47,30 @@ jobs:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@v2
|
||||
|
||||
- name: Run tests
|
||||
run: just test
|
||||
working-directory: scraper
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── build binary ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
# ── push to Docker Hub ───────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@v2
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
cd scraper && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o bin/scraper ./cmd/scraper
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
name: scraper-linux-amd64
|
||||
path: scraper/bin/scraper
|
||||
retention-days: 7
|
||||
context: scraper
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }}
|
||||
|
||||
@@ -2,23 +2,28 @@ name: CI / UI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ci-ui.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ci-ui.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── type-check & build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -28,14 +33,35 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: just ui-install
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: just ui-check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: just ui-build
|
||||
run: npm run build
|
||||
|
||||
# ── push to Docker Hub ───────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }}
|
||||
|
||||
@@ -19,14 +19,18 @@ on:
|
||||
# Header: x-api-key: <token>
|
||||
# Response on success: HTTP 200, body: [{"result":{"data":{"json":{...}}}}]
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── production deploy (main/master only) ─────────────────────────────────────
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
|
||||
gitea.event_name == 'push' &&
|
||||
(gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master')
|
||||
steps:
|
||||
- name: Redeploy production stack
|
||||
run: |
|
||||
@@ -46,15 +50,15 @@ jobs:
|
||||
name: Deploy Preview
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'push' &&
|
||||
github.ref != 'refs/heads/main' &&
|
||||
github.ref != 'refs/heads/master'
|
||||
gitea.event_name == 'push' &&
|
||||
gitea.ref != 'refs/heads/main' &&
|
||||
gitea.ref != 'refs/heads/master'
|
||||
steps:
|
||||
- name: Sanitize branch name
|
||||
id: branch
|
||||
run: |
|
||||
# Lowercase, replace non-alphanumeric with dashes, strip trailing dashes, max 20 chars
|
||||
SUFFIX=$(echo "${{ github.ref_name }}" \
|
||||
SUFFIX=$(echo "${{ gitea.ref_name }}" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed 's/[^a-z0-9]/-/g' \
|
||||
| cut -c1-20 \
|
||||
@@ -81,12 +85,12 @@ jobs:
|
||||
cleanup-preview:
|
||||
name: Cleanup Preview
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
if: gitea.event_name == 'pull_request' && gitea.event.action == 'closed'
|
||||
steps:
|
||||
- name: Sanitize branch name
|
||||
id: branch
|
||||
run: |
|
||||
SUFFIX=$(echo "${{ github.head_ref }}" \
|
||||
SUFFIX=$(echo "${{ gitea.head_ref }}" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed 's/[^a-z0-9]/-/g' \
|
||||
| cut -c1-20 \
|
||||
103
.gitea/workflows/ios-release.yaml
Normal file
103
.gitea/workflows/ios-release.yaml
Normal file
@@ -0,0 +1,103 @@
|
||||
name: iOS Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "ios-v*"
|
||||
|
||||
concurrency:
|
||||
group: ios-macos-runner
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── archive & release to TestFlight ──────────────────────────────────────
|
||||
# Triggered only on ios-v* tags (e.g. ios-v1.0.0).
|
||||
# Required secrets:
|
||||
# APPLE_CERTIFICATE_BASE64 - Distribution certificate (.p12) base64-encoded
|
||||
# APPLE_CERTIFICATE_PASSWORD - Password for the .p12 file
|
||||
# APPLE_PROVISIONING_PROFILE_BASE64 - App Store distribution profile base64-encoded
|
||||
# KEYCHAIN_PASSWORD - Temporary keychain password (any random string)
|
||||
# ASC_KEY_ID - App Store Connect API key ID
|
||||
# ASC_ISSUER_ID - App Store Connect issuer ID
|
||||
# ASC_PRIVATE_KEY - Contents of the .p8 private key file
|
||||
# APPLE_TEAM_ID - 10-character Apple Developer team ID (GHZXC6FVMU)
|
||||
release:
|
||||
name: Release to TestFlight
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby with rbenv
|
||||
run: |
|
||||
# Install rbenv and ruby-build if not already installed
|
||||
brew install rbenv ruby-build || true
|
||||
|
||||
# Install Ruby 3.2.2
|
||||
rbenv install 3.2.2 --skip-existing
|
||||
rbenv global 3.2.2
|
||||
|
||||
# Add rbenv to PATH for subsequent steps
|
||||
echo "$(rbenv root)/shims" >> $GITHUB_PATH
|
||||
|
||||
# Verify Ruby version
|
||||
eval "$(rbenv init -)"
|
||||
ruby --version
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ios/LibNovel
|
||||
run: |
|
||||
eval "$(rbenv init -)"
|
||||
gem install bundler
|
||||
bundle install
|
||||
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
echo "$CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
|
||||
security import $RUNNER_TEMP/cert.p12 \
|
||||
-P "$CERTIFICATE_PASSWORD" \
|
||||
-A -t cert -f pkcs12 \
|
||||
-k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH $(security list-keychains -d user | tr -d '"' | xargs)
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
- name: Import provisioning profile
|
||||
env:
|
||||
PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
PP_PATH=$RUNNER_TEMP/profile.mobileprovision
|
||||
echo "$PROFILE_BASE64" | base64 --decode > $PP_PATH
|
||||
UUID=$(security cms -D -i "$PP_PATH" | plutil -extract UUID raw -)
|
||||
PROFILE_NAME=$(security cms -D -i "$PP_PATH" | plutil -extract Name raw -)
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||
echo "Installed profile: $PROFILE_NAME (UUID: $UUID)"
|
||||
echo "PROFILE_NAME=$PROFILE_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and upload to TestFlight
|
||||
env:
|
||||
USER: runner
|
||||
LC_ALL: en_US.UTF-8
|
||||
LANG: en_US.UTF-8
|
||||
APPLE_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
APPLE_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
APPLE_KEY_CONTENT: ${{ secrets.ASC_PRIVATE_KEY }}
|
||||
BUILD_NUMBER: ${{ gitea.run_number }}
|
||||
working-directory: ios/LibNovel
|
||||
run: |
|
||||
eval "$(rbenv init -)"
|
||||
echo "Using provisioning profile: $PROFILE_NAME"
|
||||
export PROVISIONING_PROFILE_NAME="$PROFILE_NAME"
|
||||
bundle exec fastlane beta
|
||||
|
||||
- name: Cleanup keychain
|
||||
if: always()
|
||||
run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -7,15 +7,21 @@ on:
|
||||
- "ios/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ios.yaml"
|
||||
- ".gitea/workflows/ios-release.yaml"
|
||||
pull_request:
|
||||
branches: ["v2", "main"]
|
||||
paths:
|
||||
- "ios/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ios.yaml"
|
||||
- ".gitea/workflows/ios-release.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ios-macos-runner
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── build ─────────────────────────────────────────────────────────────────
|
||||
# ── build (simulator) ─────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: macos-latest
|
||||
@@ -24,15 +30,14 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install just
|
||||
run: brew install just
|
||||
|
||||
- name: Install xcodegen
|
||||
run: brew install xcodegen
|
||||
run: command -v just || brew install just
|
||||
|
||||
- name: Build (simulator)
|
||||
env:
|
||||
USER: runner
|
||||
run: just ios-build
|
||||
|
||||
# ── test ──────────────────────────────────────────────────────────────────
|
||||
# ── unit tests ────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: macos-latest
|
||||
@@ -42,12 +47,11 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install just
|
||||
run: brew install just
|
||||
|
||||
- name: Install xcodegen
|
||||
run: brew install xcodegen
|
||||
run: command -v just || brew install just
|
||||
|
||||
- name: Run unit tests
|
||||
env:
|
||||
USER: runner
|
||||
run: just ios-test
|
||||
|
||||
- name: Upload test results
|
||||
@@ -57,53 +61,3 @@ jobs:
|
||||
name: test-results
|
||||
path: ios/LibNovel/test-results.xml
|
||||
retention-days: 7
|
||||
|
||||
# ── archive (release IPA) ─────────────────────────────────────────────────
|
||||
# Runs only on pushes to v2/main (not PRs).
|
||||
# Requires secrets: APPLE_CERTIFICATE_BASE64, APPLE_CERTIFICATE_PASSWORD,
|
||||
# APPLE_PROVISIONING_PROFILE_BASE64, KEYCHAIN_PASSWORD
|
||||
# Also requires ios/LibNovel/ExportOptions.plist to exist.
|
||||
#
|
||||
# archive:
|
||||
# name: Archive
|
||||
# runs-on: macos-latest
|
||||
# needs: [build, test]
|
||||
# if: gitea.event_name == 'push'
|
||||
#
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
#
|
||||
# - name: Install just
|
||||
# run: brew install just
|
||||
#
|
||||
# - name: Install xcodegen
|
||||
# run: brew install xcodegen
|
||||
#
|
||||
# - name: Import signing certificate
|
||||
# env:
|
||||
# CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
# CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
# KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
# run: |
|
||||
# KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
# security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
# security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# echo "$CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
|
||||
# security import $RUNNER_TEMP/cert.p12 \
|
||||
# -P "$CERTIFICATE_PASSWORD" \
|
||||
# -A -t cert -f pkcs12 \
|
||||
# -k $KEYCHAIN_PATH
|
||||
# security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
#
|
||||
# - name: Import provisioning profile
|
||||
# env:
|
||||
# PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}
|
||||
# run: |
|
||||
# PP_PATH=$RUNNER_TEMP/profile.mobileprovision
|
||||
# echo "$PROFILE_BASE64" | base64 --decode > $PP_PATH
|
||||
# mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
# cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
#
|
||||
# - name: Archive & export IPA
|
||||
# run: just ios-archive && just ios-export
|
||||
|
||||
@@ -5,15 +5,18 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── type-check & build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -37,33 +40,29 @@ jobs:
|
||||
name: Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.kalekber.cc
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: gitea.kalekber.cc/kamil/libnovel-ui
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./ui
|
||||
context: ui
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
10
ios/LibNovel/.gitignore
vendored
Normal file
10
ios/LibNovel/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/README.md
|
||||
|
||||
# Bundler
|
||||
.bundle
|
||||
vendor/bundle
|
||||
21
ios/LibNovel/ExportOptions.plist
Normal file
21
ios/LibNovel/ExportOptions.plist
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>teamID</key>
|
||||
<string>$(DEVELOPMENT_TEAM)</string>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.kalekber.LibNovel</key>
|
||||
<string>LibNovel Distribution</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
3
ios/LibNovel/Gemfile
Normal file
3
ios/LibNovel/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
@@ -24,7 +24,6 @@
|
||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
||||
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6313AB4B3A5464F647791174 /* MarkdownUI */; };
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
||||
@@ -86,7 +85,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
|
||||
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -327,7 +325,6 @@
|
||||
name = LibNovel;
|
||||
packageProductDependencies = (
|
||||
09584EAB68A07B47F876A062 /* Kingfisher */,
|
||||
6313AB4B3A5464F647791174 /* MarkdownUI */,
|
||||
);
|
||||
productName = LibNovel;
|
||||
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
|
||||
@@ -353,7 +350,6 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
|
||||
@@ -670,14 +666,6 @@
|
||||
minimumVersion = 8.0.0;
|
||||
};
|
||||
};
|
||||
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.4.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -686,11 +674,6 @@
|
||||
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||
productName = Kingfisher;
|
||||
};
|
||||
6313AB4B3A5464F647791174 /* MarkdownUI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
||||
productName = MarkdownUI;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;
|
||||
|
||||
@@ -9,33 +9,6 @@
|
||||
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
|
||||
"version" : "8.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "networkimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||
"state" : {
|
||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||
"state" : {
|
||||
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
||||
"version" : "0.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-ui",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||
"state" : {
|
||||
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -27,15 +27,15 @@ struct RootTabView: View {
|
||||
.tag(Tab.home)
|
||||
|
||||
LibraryView()
|
||||
.tabItem { Label("Library", systemImage: "books.vertical.fill") }
|
||||
.tabItem { Label("Library", systemImage: "book.pages.fill") }
|
||||
.tag(Tab.library)
|
||||
|
||||
BrowseView()
|
||||
.tabItem { Label("Discover", systemImage: "globe") }
|
||||
.tabItem { Label("Discover", systemImage: "sparkles") }
|
||||
.tag(Tab.browse)
|
||||
|
||||
ProfileView()
|
||||
.tabItem { Label("Profile", systemImage: "person.fill") }
|
||||
.tabItem { Label("Profile", systemImage: "gear") }
|
||||
.tag(Tab.profile)
|
||||
}
|
||||
// Reserve space for the mini-player above the tab bar so scroll content
|
||||
|
||||
@@ -3,7 +3,6 @@ import SwiftUI
|
||||
// MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b)
|
||||
extension Color {
|
||||
static let amber = Color(red: 0.96, green: 0.62, blue: 0.04)
|
||||
static let amberLight = Color(red: 1.0, green: 0.84, blue: 0.40)
|
||||
}
|
||||
|
||||
extension ShapeStyle where Self == Color {
|
||||
|
||||
@@ -6,3 +6,31 @@ enum NavDestination: Hashable {
|
||||
case book(String) // slug
|
||||
case chapter(String, Int) // slug + chapter number
|
||||
}
|
||||
|
||||
// MARK: - View extensions for shared navigation + error alert patterns
|
||||
|
||||
extension View {
|
||||
/// Registers the app-wide navigation destinations for NavDestination values.
|
||||
/// Apply once per NavigationStack instead of repeating the switch in every tab.
|
||||
func appNavigationDestination() -> some View {
|
||||
navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Presents a standard "Error" alert driven by an optional String binding.
|
||||
/// Dismissing the alert sets the binding back to nil.
|
||||
func errorAlert(_ error: Binding<String?>) -> some View {
|
||||
alert("Error", isPresented: Binding(
|
||||
get: { error.wrappedValue != nil },
|
||||
set: { if !$0 { error.wrappedValue = nil } }
|
||||
)) {
|
||||
Button("OK") { error.wrappedValue = nil }
|
||||
} message: {
|
||||
Text(error.wrappedValue ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,28 @@ import Foundation
|
||||
// MARK: - String helpers for display purposes
|
||||
|
||||
extension String {
|
||||
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago")
|
||||
/// that may be embedded in chapter titles coming from older scraped records.
|
||||
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago",
|
||||
/// or "(One)4 years ago" where the number is attached without a preceding space).
|
||||
func strippingTrailingDate() -> String {
|
||||
let units = ["second", "minute", "hour", "day", "week", "month", "year"]
|
||||
let lower = self.lowercased()
|
||||
for unit in units {
|
||||
for suffix in [unit + "s ago", unit + " ago"] {
|
||||
guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue }
|
||||
// Walk backwards past whitespace to find the numeric token
|
||||
// Everything before the suffix
|
||||
let before = String(self[self.startIndex ..< suffixRange.lowerBound])
|
||||
let trimmed = before.trimmingCharacters(in: .whitespaces)
|
||||
// Find start of last word (should be a number)
|
||||
// Strip trailing digits (the numeric count, which may be attached without a space)
|
||||
var result = trimmed
|
||||
while let last = result.last, last.isNumber {
|
||||
result.removeLast()
|
||||
}
|
||||
result = result.trimmingCharacters(in: .whitespaces)
|
||||
if result != trimmed {
|
||||
// We actually stripped some digits — return cleaned result
|
||||
return result
|
||||
}
|
||||
// Fallback: number preceded by space
|
||||
if let spaceIdx = trimmed.lastIndex(of: " ") {
|
||||
let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...])
|
||||
if Int(potentialNum) != nil {
|
||||
@@ -22,7 +32,6 @@ extension String {
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
} else if Int(trimmed) != nil {
|
||||
// The entire string is just a number + "ago"
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -35,6 +44,6 @@ extension String {
|
||||
|
||||
enum AppLayout {
|
||||
/// Height of the persistent mini-player bar:
|
||||
/// 2pt progress line + 20pt vertical padding + ~44pt content row.
|
||||
static let miniPlayerBarHeight: CGFloat = 66
|
||||
/// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
|
||||
static let miniPlayerBarHeight: CGFloat = 92
|
||||
}
|
||||
|
||||
@@ -126,22 +126,6 @@ struct AppUser: Codable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Browse / Novel Listing
|
||||
|
||||
struct NovelListing: Identifiable, Hashable {
|
||||
var id: String { slug.isEmpty ? url : slug }
|
||||
let slug: String
|
||||
let title: String
|
||||
let cover: String
|
||||
let rank: String
|
||||
let rating: String
|
||||
let chapters: String
|
||||
let url: String
|
||||
let author: String
|
||||
let status: String
|
||||
let genres: [String]
|
||||
}
|
||||
|
||||
// MARK: - Ranking
|
||||
|
||||
struct RankingItem: Codable, Identifiable {
|
||||
@@ -201,17 +185,6 @@ struct UserSession: Codable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book Detail
|
||||
|
||||
struct BookDetailData {
|
||||
let book: Book
|
||||
let chapters: [ChapterIndex]
|
||||
let previewChapters: [PreviewChapter]?
|
||||
let inLib: Bool
|
||||
let saved: Bool
|
||||
let lastChapter: Int?
|
||||
}
|
||||
|
||||
struct PreviewChapter: Codable, Identifiable {
|
||||
var id: Int { number }
|
||||
let number: Int
|
||||
@@ -219,19 +192,6 @@ struct PreviewChapter: Codable, Identifiable {
|
||||
let url: String
|
||||
}
|
||||
|
||||
// MARK: - Chapter Content
|
||||
|
||||
struct ChapterContent {
|
||||
let book: BookBrief
|
||||
let chapter: ChapterIndex
|
||||
let html: String
|
||||
let voices: [String]
|
||||
let prev: Int?
|
||||
let next: Int?
|
||||
let chapters: [ChapterIndexBrief]
|
||||
let isPreview: Bool
|
||||
}
|
||||
|
||||
struct BookBrief: Codable {
|
||||
let slug: String
|
||||
let title: String
|
||||
|
||||
@@ -63,9 +63,7 @@ actor APIClient {
|
||||
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
|
||||
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
print("[APIClient] ▶ \(method) \(urlString)")
|
||||
guard let url = URL(string: urlString) else {
|
||||
print("[APIClient] ✗ Could not construct URL from: \(urlString)")
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
@@ -75,12 +73,6 @@ actor APIClient {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
}
|
||||
// Log cookies being sent
|
||||
if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty {
|
||||
print("[APIClient] cookies: \(cookies.map { "\($0.name)=\($0.value.prefix(20))…" })")
|
||||
} else {
|
||||
print("[APIClient] cookies: (none)")
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -90,40 +82,15 @@ actor APIClient {
|
||||
let req = try makeRequest(path, method: method, body: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
print("[APIClient] ✗ \(path) — not an HTTP response")
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
||||
print("[APIClient] ◀ \(http.statusCode) \(path) — \(data.count) bytes")
|
||||
if data.count < 2000 {
|
||||
print("[APIClient] body: \(rawBody)")
|
||||
} else {
|
||||
print("[APIClient] body (first 2000 chars): \(rawBody.prefix(2000))")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder.iso8601.decode(T.self, from: data)
|
||||
print("[APIClient] ✓ decoded \(T.self) from \(path)")
|
||||
return decoded
|
||||
return try JSONDecoder.iso8601.decode(T.self, from: data)
|
||||
} catch {
|
||||
print("[APIClient] ✗ decode error for \(T.self) from \(path)")
|
||||
print("[APIClient] decode error: \(error)")
|
||||
if let decodingError = error as? DecodingError {
|
||||
switch decodingError {
|
||||
case .typeMismatch(let type, let ctx):
|
||||
print("[APIClient] typeMismatch: expected \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
case .valueNotFound(let type, let ctx):
|
||||
print("[APIClient] valueNotFound: \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
case .keyNotFound(let key, let ctx):
|
||||
print("[APIClient] keyNotFound: '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
case .dataCorrupted(let ctx):
|
||||
print("[APIClient] dataCorrupted: \(ctx.debugDescription) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
@unknown default:
|
||||
print("[APIClient] unknown decoding error: \(error)")
|
||||
}
|
||||
}
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": { "alpha": "1.000", "blue": "0.588", "green": "0.467", "red": "1.000" }
|
||||
"components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" }
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -3,6 +3,19 @@ import AVFoundation
|
||||
import MediaPlayer
|
||||
import Combine
|
||||
|
||||
// MARK: - PlaybackProgress
|
||||
// Isolated ObservableObject for high-frequency playback state (currentTime,
|
||||
// duration, isPlaying). Keeping these separate from AudioPlayerService means
|
||||
// the 0.5-second time-observer ticks only invalidate views that explicitly
|
||||
// observe PlaybackProgress — menus and other stable UI are unaffected.
|
||||
|
||||
@MainActor
|
||||
final class PlaybackProgress: ObservableObject {
|
||||
@Published var currentTime: Double = 0
|
||||
@Published var duration: Double = 0
|
||||
@Published var isPlaying: Bool = false
|
||||
}
|
||||
|
||||
// MARK: - AudioPlayerService
|
||||
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
|
||||
// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the
|
||||
@@ -27,12 +40,30 @@ final class AudioPlayerService: ObservableObject {
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var generationProgress: Double = 0
|
||||
|
||||
@Published var currentTime: Double = 0
|
||||
@Published var duration: Double = 0
|
||||
@Published var isPlaying: Bool = false
|
||||
/// High-frequency playback state (currentTime / duration / isPlaying).
|
||||
/// Views that only need the seek bar or play-pause button should observe
|
||||
/// this directly so they don't trigger re-renders of menu-bearing parents.
|
||||
let progress = PlaybackProgress()
|
||||
|
||||
// Convenience forwarders so non-view call sites keep compiling unchanged.
|
||||
var currentTime: Double {
|
||||
get { progress.currentTime }
|
||||
set { progress.currentTime = newValue }
|
||||
}
|
||||
var duration: Double {
|
||||
get { progress.duration }
|
||||
set { progress.duration = newValue }
|
||||
}
|
||||
var isPlaying: Bool {
|
||||
get { progress.isPlaying }
|
||||
set { progress.isPlaying = newValue }
|
||||
}
|
||||
|
||||
@Published var autoNext: Bool = false
|
||||
@Published var nextChapter: Int? = nil
|
||||
@Published var prevChapter: Int? = nil
|
||||
|
||||
@Published var sleepTimer: SleepTimerOption? = nil
|
||||
|
||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||
@Published var nextAudioURL: String = ""
|
||||
@@ -44,6 +75,20 @@ final class AudioPlayerService: ObservableObject {
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute previous chapter number (current - 1), or nil if at first chapter
|
||||
var absolutePrevChapter: Int? {
|
||||
guard chapter > 1 else { return nil }
|
||||
return chapter - 1
|
||||
}
|
||||
|
||||
/// Absolute next chapter number (current + 1), or nil if at last chapter
|
||||
var absoluteNextChapter: Int? {
|
||||
guard !chapters.isEmpty else { return nil }
|
||||
let maxChapter = chapters.map(\.number).max() ?? chapter
|
||||
guard chapter < maxChapter else { return nil }
|
||||
return chapter + 1
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@@ -60,6 +105,10 @@ final class AudioPlayerService: ObservableObject {
|
||||
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
|
||||
private var cachedCoverArtwork: MPMediaItemArtwork?
|
||||
private var cachedCoverURL: String = ""
|
||||
|
||||
// Sleep timer tracking
|
||||
private var sleepTimerTask: Task<Void, Never>?
|
||||
private var sleepTimerStartChapter: Int = 0
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
@@ -73,7 +122,7 @@ final class AudioPlayerService: ObservableObject {
|
||||
/// Load audio for a specific chapter. Triggers TTS generation if not cached.
|
||||
func load(slug: String, chapter: Int, chapterTitle: String,
|
||||
bookTitle: String, coverURL: String, voice: String, speed: Double,
|
||||
chapters: [ChapterIndexBrief], nextChapter: Int?) {
|
||||
chapters: [ChapterIndexBrief], nextChapter: Int?, prevChapter: Int?) {
|
||||
generationTask?.cancel()
|
||||
prefetchTask?.cancel()
|
||||
stop()
|
||||
@@ -87,9 +136,15 @@ final class AudioPlayerService: ObservableObject {
|
||||
self.speed = speed
|
||||
self.chapters = chapters
|
||||
self.nextChapter = nextChapter
|
||||
self.prevChapter = prevChapter
|
||||
self.nextPrefetchStatus = .none
|
||||
self.nextAudioURL = ""
|
||||
self.nextPrefetchedChapter = nil
|
||||
|
||||
// Reset sleep timer start chapter if it's a chapter-based timer
|
||||
if case .chapters = sleepTimer {
|
||||
sleepTimerStartChapter = chapter
|
||||
}
|
||||
|
||||
status = .generating
|
||||
generationProgress = 0
|
||||
@@ -139,6 +194,33 @@ final class AudioPlayerService: ObservableObject {
|
||||
if isPlaying { player?.rate = Float(newSpeed) }
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func setSleepTimer(_ option: SleepTimerOption?) {
|
||||
// Cancel existing timer
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
|
||||
sleepTimer = option
|
||||
|
||||
guard let option else { return }
|
||||
|
||||
// Start timer based on option
|
||||
switch option {
|
||||
case .chapters(let count):
|
||||
sleepTimerStartChapter = chapter
|
||||
// Monitor chapter changes in handlePlaybackFinished
|
||||
|
||||
case .minutes(let minutes):
|
||||
sleepTimerTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
self.stop()
|
||||
self.sleepTimer = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.pause()
|
||||
@@ -148,6 +230,11 @@ final class AudioPlayerService: ObservableObject {
|
||||
duration = 0
|
||||
audioURL = ""
|
||||
status = .idle
|
||||
|
||||
// Cancel sleep timer
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
sleepTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Audio generation
|
||||
@@ -313,6 +400,15 @@ final class AudioPlayerService: ObservableObject {
|
||||
isPlaying = false
|
||||
|
||||
guard let next = nextChapter else { return }
|
||||
|
||||
// Check chapter-based sleep timer
|
||||
if case .chapters(let count) = sleepTimer {
|
||||
let chaptersPlayed = chapter - sleepTimerStartChapter + 1
|
||||
if chaptersPlayed >= count {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Always notify the view that the chapter finished (it may update UI).
|
||||
NotificationCenter.default.post(
|
||||
@@ -328,6 +424,7 @@ final class AudioPlayerService: ObservableObject {
|
||||
|
||||
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
|
||||
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
|
||||
let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one
|
||||
|
||||
// If we already prefetched a URL for the next chapter, skip straight to
|
||||
// playback and kick off generation in the background for the one after.
|
||||
@@ -338,12 +435,18 @@ final class AudioPlayerService: ObservableObject {
|
||||
chapter = next
|
||||
chapterTitle = nextTitle
|
||||
nextChapter = nextNextChapter
|
||||
prevChapter = nextPrevChapter
|
||||
nextPrefetchStatus = .none
|
||||
nextAudioURL = ""
|
||||
nextPrefetchedChapter = nil
|
||||
audioURL = url
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
|
||||
// Update sleep timer start chapter if using chapter-based timer
|
||||
if case .chapters = sleepTimer {
|
||||
sleepTimerStartChapter = next
|
||||
}
|
||||
|
||||
generationTask = Task {
|
||||
await playURL(url)
|
||||
@@ -360,7 +463,8 @@ final class AudioPlayerService: ObservableObject {
|
||||
voice: voice,
|
||||
speed: speed,
|
||||
chapters: chapters,
|
||||
nextChapter: nextNextChapter
|
||||
nextChapter: nextNextChapter,
|
||||
prevChapter: nextPrevChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -406,9 +510,9 @@ final class AudioPlayerService: ObservableObject {
|
||||
self?.togglePlayPause()
|
||||
return .success
|
||||
}
|
||||
center.skipForwardCommand.preferredIntervals = [30]
|
||||
center.skipForwardCommand.preferredIntervals = [15]
|
||||
center.skipForwardCommand.addTarget { [weak self] _ in
|
||||
self?.skip(by: 30)
|
||||
self?.skip(by: 15)
|
||||
return .success
|
||||
}
|
||||
center.skipBackwardCommand.preferredIntervals = [15]
|
||||
@@ -460,6 +564,13 @@ enum AudioPlayerStatus: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum SleepTimerOption: Equatable {
|
||||
case chapters(Int) // Stop after N chapters
|
||||
case minutes(Int) // Stop after N minutes
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
||||
static let skipToNextChapter = Notification.Name("skipToNextChapter")
|
||||
static let skipToPrevChapter = Notification.Name("skipToPrevChapter")
|
||||
}
|
||||
|
||||
@@ -50,14 +50,12 @@ final class BrowseViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func loadPage(_ page: Int) async {
|
||||
print("[Browse] loadPage(\(page)) genre=\(genre) sort=\(sort) status=\(status)")
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let result = try await APIClient.shared.browse(
|
||||
page: page, genre: genre, sort: sort, status: status
|
||||
)
|
||||
print("[Browse] ✓ page \(page) — \(result.novels.count) novels, hasNext=\(result.hasNext)")
|
||||
if page == 1 {
|
||||
novels = result.novels
|
||||
} else {
|
||||
@@ -67,7 +65,6 @@ final class BrowseViewModel: ObservableObject {
|
||||
currentPage = page
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
print("[Browse] ✗ error on page \(page): \(error)")
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,34 +3,38 @@ import Foundation
|
||||
@MainActor
|
||||
final class ChapterReaderViewModel: ObservableObject {
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
private(set) var chapter: Int
|
||||
|
||||
@Published var content: ChapterResponse?
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
@Published var navigateTo: Int? // set to trigger navigation to next chapter
|
||||
|
||||
init(slug: String, chapter: Int) {
|
||||
self.slug = slug
|
||||
self.chapter = chapter
|
||||
}
|
||||
|
||||
/// Switch to a different chapter in-place: resets state and updates `chapter`
|
||||
/// so that `.task(id: currentChapter)` in the View re-fires `load()`.
|
||||
func switchChapter(to newChapter: Int) {
|
||||
guard newChapter != chapter else { return }
|
||||
chapter = newChapter
|
||||
content = nil
|
||||
error = nil
|
||||
}
|
||||
|
||||
func load() async {
|
||||
print("[ChapterReader] load() slug=\(slug) chapter=\(chapter)")
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
|
||||
print("[ChapterReader] ✓ loaded chapter \(chapter), html length=\(content?.html.count ?? 0)")
|
||||
// Record reading progress
|
||||
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
print("[ChapterReader] ✗ error: \(error)")
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
print("[ChapterReader] done — isLoading=false, content=\(content != nil), error=\(String(describing: self.error))")
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ final class ChapterReaderViewModel: ObservableObject {
|
||||
audioPlayer.togglePlayPause()
|
||||
} else {
|
||||
let nextChapter: Int? = content.next
|
||||
let prevChapter: Int? = content.prev
|
||||
audioPlayer.load(
|
||||
slug: slug,
|
||||
chapter: chapter,
|
||||
@@ -56,7 +61,8 @@ final class ChapterReaderViewModel: ObservableObject {
|
||||
voice: settings.voice,
|
||||
speed: settings.speed,
|
||||
chapters: content.chapters,
|
||||
nextChapter: nextChapter
|
||||
nextChapter: nextChapter,
|
||||
prevChapter: prevChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,7 @@ struct BookDetailView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { bookmarkButton }
|
||||
.task { await vm.load() }
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: { Text(vm.error ?? "") }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
|
||||
// MARK: - Hero
|
||||
@@ -64,7 +62,7 @@ struct BookDetailView: View {
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
HStack {
|
||||
TagChip(label: book.status).colorScheme(.dark)
|
||||
// TagChip(label: book.status).colorScheme(.dark)
|
||||
ForEach(book.genres.prefix(2), id: \.self) {
|
||||
TagChip(label: $0).colorScheme(.dark)
|
||||
}
|
||||
@@ -86,7 +84,7 @@ struct BookDetailView: View {
|
||||
Button(summaryExpanded ? "Less" : "More") {
|
||||
withAnimation { summaryExpanded.toggle() }
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,12 +90,7 @@ struct BrowseView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Discover")
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
}
|
||||
}
|
||||
.appNavigationDestination()
|
||||
.sheet(isPresented: $showFilters) {
|
||||
BrowseFiltersView(vm: vm)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ struct ChapterReaderView: View {
|
||||
let slug: String
|
||||
let chapterNumber: Int
|
||||
|
||||
/// Tracks the currently displayed chapter — updated in-place by skip/auto-next
|
||||
/// so we never accumulate stale listeners on the navigation stack.
|
||||
@State private var currentChapter: Int
|
||||
@StateObject private var vm: ChapterReaderViewModel
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@@ -14,20 +17,17 @@ struct ChapterReaderView: View {
|
||||
init(slug: String, chapterNumber: Int) {
|
||||
self.slug = slug
|
||||
self.chapterNumber = chapterNumber
|
||||
_currentChapter = State(initialValue: chapterNumber)
|
||||
_vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let _ = print("[ChapterReaderView] body eval — slug=\(slug) ch=\(chapterNumber) isLoading=\(vm.isLoading) hasContent=\(vm.content != nil) error=\(vm.error ?? "nil")")
|
||||
Group {
|
||||
if vm.isLoading {
|
||||
let _ = print("[ChapterReaderView] branch: LOADING")
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let content = vm.content {
|
||||
let _ = print("[ChapterReaderView] branch: CONTENT html=\(content.html.count)chars title='\(content.chapter.title)'")
|
||||
readerContent(content)
|
||||
} else if let errMsg = vm.error {
|
||||
let _ = print("[ChapterReaderView] branch: ERROR '\(errMsg)'")
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
@@ -42,33 +42,45 @@ struct ChapterReaderView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
let _ = print("[ChapterReaderView] branch: BLANK (no loading, no content, no error)")
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
.navigationTitle(vm.content.map { "Ch.\($0.chapter.number)" } ?? "")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { audioToolbarButton }
|
||||
.task {
|
||||
print("[ChapterReaderView] .task fired — calling vm.load()")
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
// Floating audio button when player is not active
|
||||
if !audioPlayer.isActive {
|
||||
floatingAudioButton
|
||||
}
|
||||
}
|
||||
.task(id: currentChapter) {
|
||||
await vm.load()
|
||||
print("[ChapterReaderView] .task completed")
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in
|
||||
guard let next = note.userInfo?["next"] as? Int else { return }
|
||||
let shouldAutoNavigate = note.userInfo?["autoNext"] as? Bool ?? false
|
||||
if shouldAutoNavigate {
|
||||
vm.navigateTo = next
|
||||
}
|
||||
// Only handle if this is the top-most (currently active) chapter view
|
||||
guard shouldAutoNavigate, currentChapter == audioPlayer.chapter else { return }
|
||||
navigateToChapter(next)
|
||||
}
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { vm.navigateTo != nil },
|
||||
set: { if !$0 { vm.navigateTo = nil } }
|
||||
)) {
|
||||
if let next = vm.navigateTo {
|
||||
ChapterReaderView(slug: slug, chapterNumber: next)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in
|
||||
guard let next = note.userInfo?["next"] as? Int else { return }
|
||||
// Only the view whose chapter matches the currently playing chapter should handle this
|
||||
guard currentChapter == audioPlayer.chapter else { return }
|
||||
navigateToChapter(next)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in
|
||||
guard let prev = note.userInfo?["prev"] as? Int else { return }
|
||||
guard currentChapter == audioPlayer.chapter else { return }
|
||||
navigateToChapter(prev)
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to a chapter in-place: reloads content without pushing to the navigation stack.
|
||||
/// Back button always returns to BookDetailView regardless of how many chapters were visited.
|
||||
private func navigateToChapter(_ chapter: Int) {
|
||||
vm.switchChapter(to: chapter)
|
||||
currentChapter = chapter
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
@@ -77,7 +89,6 @@ struct ChapterReaderView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func readerContent(_ content: ChapterResponse) -> some View {
|
||||
let _ = print("[ChapterReaderView] readerContent — html=\(content.html.count)chars webHeight=\(webHeight)")
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
@@ -101,17 +112,21 @@ struct ChapterReaderView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
// Prev / Next navigation
|
||||
// Prev / Next navigation — in-place swap so back button always returns to book
|
||||
HStack(spacing: 12) {
|
||||
if let prev = content.prev {
|
||||
NavigationLink(value: NavDestination.chapter(slug, prev)) {
|
||||
Button {
|
||||
navigateToChapter(prev)
|
||||
} label: {
|
||||
Label("Ch.\(prev)", systemImage: "chevron.left")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
if let next = content.next {
|
||||
NavigationLink(value: NavDestination.chapter(slug, next)) {
|
||||
Button {
|
||||
navigateToChapter(next)
|
||||
} label: {
|
||||
Label("Ch.\(next)", systemImage: "chevron.right")
|
||||
.labelStyle(ReverseLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -132,21 +147,31 @@ struct ChapterReaderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio toolbar button
|
||||
// MARK: - Floating audio button
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var audioToolbarButton: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
|
||||
} label: {
|
||||
Image(systemName: audioPlayer.isActive &&
|
||||
audioPlayer.slug == slug &&
|
||||
audioPlayer.chapter == chapterNumber
|
||||
? "speaker.wave.2.fill" : "speaker.wave.2")
|
||||
.foregroundStyle(.amber)
|
||||
@ViewBuilder
|
||||
private var floatingAudioButton: some View {
|
||||
Button {
|
||||
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
Text("Listen")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.amber)
|
||||
.shadow(color: .black.opacity(0.25), radius: 8, y: 4)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +209,6 @@ struct HTMLContentView: UIViewRepresentable {
|
||||
p { margin: 0 0 1em 0; }
|
||||
"""
|
||||
let wrapped = "<html><head><style>\(css)</style><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>\(html)</body></html>"
|
||||
print("[HTMLContentView] updateUIView — html=\(html.count)chars loading into WKWebView")
|
||||
uiView.loadHTMLString(wrapped, baseURL: nil)
|
||||
}
|
||||
|
||||
@@ -193,18 +217,12 @@ struct HTMLContentView: UIViewRepresentable {
|
||||
init(_ parent: HTMLContentView) { self.parent = parent }
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
print("[HTMLContentView] didFinish — evaluating scrollHeight")
|
||||
webView.evaluateJavaScript("document.body.scrollHeight") { result, error in
|
||||
DispatchQueue.main.async {
|
||||
print("[HTMLContentView] scrollHeight result=\(String(describing: result)) error=\(String(describing: error))")
|
||||
if let h = result as? CGFloat, h > 0 {
|
||||
print("[HTMLContentView] height set to CGFloat \(h)")
|
||||
self.parent.height = h
|
||||
} else if let h = result as? Double, h > 0 {
|
||||
print("[HTMLContentView] height set to Double \(h)")
|
||||
self.parent.height = CGFloat(h)
|
||||
} else {
|
||||
print("[HTMLContentView] ⚠️ could not read height — keeping \(self.parent.height)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,19 +75,10 @@ struct HomeView: View {
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
}
|
||||
}
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: {
|
||||
Text(vm.error ?? "")
|
||||
}
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,17 +34,10 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Library")
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
}
|
||||
}
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: { Text(vm.error ?? "") }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,9 +68,7 @@ struct ProfileView: View {
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
}
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: { Text(vm.error ?? "") }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +119,7 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
),
|
||||
in: 0.5...3.0, step: 0.25
|
||||
in: 0.5...2.0, step: 0.25
|
||||
)
|
||||
.tint(.amber)
|
||||
}
|
||||
@@ -208,7 +206,8 @@ struct ChangePasswordView: View {
|
||||
body: Body(currentPassword: current, newPassword: newPwd)
|
||||
)
|
||||
success = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { dismiss() }
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
46
ios/LibNovel/fastlane/Fastfile
Normal file
46
ios/LibNovel/fastlane/Fastfile
Normal file
@@ -0,0 +1,46 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Build and upload to TestFlight"
|
||||
lane :beta do
|
||||
# Generate Xcode project from project.yml (one level up from fastlane/)
|
||||
sh("cd .. && xcodegen generate --spec project.yml --project .")
|
||||
|
||||
# Set build number from CI run number (passed as env var)
|
||||
increment_build_number(
|
||||
build_number: ENV["BUILD_NUMBER"] || "1",
|
||||
xcodeproj: "LibNovel.xcodeproj"
|
||||
)
|
||||
|
||||
# Enable manual code signing with specific provisioning profile
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
team_id: "GHZXC6FVMU",
|
||||
targets: ["LibNovel"],
|
||||
path: "LibNovel.xcodeproj",
|
||||
profile_name: ENV["PROVISIONING_PROFILE_NAME"] || "LibNovel Distribution",
|
||||
code_sign_identity: "Apple Distribution"
|
||||
)
|
||||
|
||||
# Build the app with manual signing
|
||||
build_app(
|
||||
scheme: "LibNovel",
|
||||
export_method: "app-store",
|
||||
clean: true,
|
||||
configuration: "Release",
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
teamID: "GHZXC6FVMU",
|
||||
provisioningProfiles: {
|
||||
"com.kalekber.LibNovel" => ENV["PROVISIONING_PROFILE_NAME"] || "LibNovel Distribution"
|
||||
},
|
||||
signingStyle: "manual"
|
||||
}
|
||||
)
|
||||
|
||||
# Upload to TestFlight
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
name: LibNovel
|
||||
options:
|
||||
bundleIdPrefix: cc.kalekber
|
||||
bundleIdPrefix: com.kalekber
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
@@ -23,10 +23,6 @@ settings:
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
|
||||
|
||||
packages:
|
||||
# Markdown rendering — renders chapter HTML from the server
|
||||
MarkdownUI:
|
||||
url: https://github.com/gonzalezreal/swift-markdown-ui
|
||||
from: "2.4.0"
|
||||
# Async image loading with caching
|
||||
Kingfisher:
|
||||
url: https://github.com/onevcat/Kingfisher
|
||||
@@ -46,14 +42,17 @@ targets:
|
||||
- path: LibNovel/Resources/Assets.xcassets
|
||||
dependencies:
|
||||
- package: Kingfisher
|
||||
- package: MarkdownUI
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
INFOPLIST_FILE: LibNovel/Resources/Info.plist
|
||||
configs:
|
||||
Release:
|
||||
CODE_SIGN_STYLE: Manual
|
||||
DEVELOPMENT_TEAM: GHZXC6FVMU
|
||||
|
||||
LibNovelTests:
|
||||
type: bundle.unit-test
|
||||
@@ -65,7 +64,7 @@ targets:
|
||||
- target: LibNovel
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel.tests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel.tests
|
||||
|
||||
schemes:
|
||||
LibNovel:
|
||||
|
||||
32
ios/LibNovel/test-build.sh
Executable file
32
ios/LibNovel/test-build.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Test script for local iOS build iteration
|
||||
# Run from ios/LibNovel directory
|
||||
|
||||
echo "=== Generating Xcode project ==="
|
||||
xcodegen generate --spec project.yml --project .
|
||||
|
||||
echo ""
|
||||
echo "=== Listing available provisioning profiles ==="
|
||||
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/ || echo "No profiles found"
|
||||
|
||||
echo ""
|
||||
echo "=== Listing available signing identities ==="
|
||||
security find-identity -v -p codesigning
|
||||
|
||||
echo ""
|
||||
echo "=== Attempting archive build ==="
|
||||
xcodebuild archive \
|
||||
-project LibNovel.xcodeproj \
|
||||
-scheme LibNovel \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath ./build/LibNovel.xcarchive \
|
||||
-allowProvisioningUpdates \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
DEVELOPMENT_TEAM="GHZXC6FVMU"
|
||||
|
||||
echo ""
|
||||
echo "=== Build succeeded! ==="
|
||||
26
justfile
26
justfile
@@ -130,13 +130,18 @@ ios-test: ios-gen ios-resolve
|
||||
# Archive a signed Release build (requires valid signing identity in keychain).
|
||||
# Output: {{runner_temp}}/LibNovel.xcarchive
|
||||
# Typically called from CI after importing certificate + provisioning profile.
|
||||
ios-archive: ios-gen ios-resolve
|
||||
# Usage: just ios-archive <team-id> <profile-uuid>
|
||||
ios-archive team_id profile_uuid: ios-gen ios-resolve
|
||||
cd {{ios_dir}} && xcodebuild archive \
|
||||
-project {{ios_scheme}}.xcodeproj \
|
||||
-scheme {{ios_scheme}} \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-clonedSourcePackagesDirPath {{ios_spm}} \
|
||||
-archivePath {{runner_temp}}/LibNovel.xcarchive
|
||||
-archivePath {{runner_temp}}/LibNovel.xcarchive \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
"PROVISIONING_PROFILE[sdk=iphoneos*]={{profile_uuid}}" \
|
||||
DEVELOPMENT_TEAM="{{team_id}}"
|
||||
|
||||
# Export an IPA from the archive produced by ios-archive.
|
||||
# Requires ios/LibNovel/ExportOptions.plist.
|
||||
@@ -147,6 +152,23 @@ ios-export:
|
||||
-exportPath {{runner_temp}}/ipa \
|
||||
-exportOptionsPlist ExportOptions.plist
|
||||
|
||||
# Set the build number (CFBundleVersion) in project.yml before archiving.
|
||||
# Usage: just ios-set-build-number 42
|
||||
ios-set-build-number number:
|
||||
cd {{ios_dir}} && sed -i '' \
|
||||
's/CURRENT_PROJECT_VERSION: .*/CURRENT_PROJECT_VERSION: {{number}}/' \
|
||||
project.yml
|
||||
|
||||
# Upload the exported IPA to TestFlight via App Store Connect API.
|
||||
# Requires env vars: ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY_PATH
|
||||
# The private key (.p8 file) must be present at ASC_PRIVATE_KEY_PATH.
|
||||
ios-upload:
|
||||
xcrun altool --upload-app \
|
||||
--type ios \
|
||||
--file {{runner_temp}}/ipa/LibNovel.ipa \
|
||||
--apiKey "$ASC_KEY_ID" \
|
||||
--apiIssuer "$ASC_ISSUER_ID"
|
||||
|
||||
# ─── Docker Compose ───────────────────────────────────────────────────────────
|
||||
|
||||
# Start all services (browserless, kokoro, scraper, minio, pocketbase)
|
||||
|
||||
@@ -101,7 +101,7 @@ func newE2EFixture(t *testing.T) *e2eFixture {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
|
||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewHybridStore: %v", err)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func newTestStore(t *testing.T) *storage.HybridStore {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
|
||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewHybridStore: %v", err)
|
||||
}
|
||||
|
||||
39
scripts/runner-config-mac.yaml
Normal file
39
scripts/runner-config-mac.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
file: .runner
|
||||
capacity: 1
|
||||
envs: {}
|
||||
env_file: .env
|
||||
timeout: 3h
|
||||
shutdown_timeout: 0s
|
||||
insecure: false
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
github_mirror: ''
|
||||
labels:
|
||||
- "macos-latest:host"
|
||||
- "macos-14:host"
|
||||
|
||||
cache:
|
||||
enabled: true
|
||||
dir: ""
|
||||
host: "__HOST_IP__"
|
||||
port: 8088
|
||||
external_server: ""
|
||||
|
||||
container:
|
||||
network: ""
|
||||
privileged: false
|
||||
options: ""
|
||||
workdir_parent: ""
|
||||
valid_volumes: []
|
||||
docker_host: ""
|
||||
force_pull: false
|
||||
force_rebuild: false
|
||||
require_docker: false
|
||||
docker_timeout: 0s
|
||||
|
||||
host:
|
||||
workdir_parent: ""
|
||||
123
scripts/setup_runner_mac.sh
Executable file
123
scripts/setup_runner_mac.sh
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── setup_runner_mac.sh ───────────────────────────────────────────────────────
|
||||
# Sets up act_runner as a host-mode runner on macOS for iOS CI/CD.
|
||||
# Installs the binary, generates a config, registers against Gitea,
|
||||
# and installs a LaunchDaemon so the runner starts at boot.
|
||||
#
|
||||
# Usage: sudo ./setup_runner_mac.sh <runner-name>
|
||||
# Example: sudo ./setup_runner_mac.sh mac-runner-1
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
echo "Usage: sudo $0 <runner-name>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -ne 1 ]] && usage
|
||||
[[ "$EUID" -ne 0 ]] && { echo "ERROR: run with sudo"; exit 1; }
|
||||
|
||||
RUNNER_NAME="$1"
|
||||
GITEA_URL="https://gitea.kalekber.cc/"
|
||||
REGISTRATION_TOKEN="AboxpDKWx7gizwJ9xeheHVqKjj9J9N9BgyX96wvu"
|
||||
CACHE_PORT=8088
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
WORK_DIR="/var/lib/act_runner"
|
||||
CONFIG_PATH="/etc/act_runner/config.yaml"
|
||||
LAUNCHDAEMON_PLIST="/Library/LaunchDaemons/com.gitea.act_runner.plist"
|
||||
|
||||
# ── detect Mac LAN IP ─────────────────────────────────────────────────────────
|
||||
HOST_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "")
|
||||
if [[ -z "$HOST_IP" ]]; then
|
||||
echo "ERROR: could not detect LAN IP via en0/en1. Set cache.host manually in $CONFIG_PATH"
|
||||
HOST_IP="127.0.0.1"
|
||||
fi
|
||||
echo "Host LAN IP: $HOST_IP"
|
||||
|
||||
# ── download act_runner binary ────────────────────────────────────────────────
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" == "arm64" ]]; then
|
||||
BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-arm64"
|
||||
else
|
||||
BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-amd64"
|
||||
fi
|
||||
|
||||
echo "Downloading act_runner for $ARCH..."
|
||||
curl -fsSL "$BINARY_URL" -o "$INSTALL_DIR/act_runner"
|
||||
chmod +x "$INSTALL_DIR/act_runner"
|
||||
echo "Installed: $("$INSTALL_DIR/act_runner" --version)"
|
||||
|
||||
# ── create working directory ──────────────────────────────────────────────────
|
||||
mkdir -p "$WORK_DIR"
|
||||
mkdir -p "$(dirname "$CONFIG_PATH")"
|
||||
|
||||
# ── install config ────────────────────────────────────────────────────────────
|
||||
# Use the checked-in static config and substitute the LAN IP placeholder.
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
sed "s/__HOST_IP__/$HOST_IP/" "$SCRIPT_DIR/runner-config-mac.yaml" > "$CONFIG_PATH"
|
||||
echo "Config written: labels=macos-latest:host, cache=$HOST_IP:$CACHE_PORT"
|
||||
|
||||
# ── register runner ───────────────────────────────────────────────────────────
|
||||
echo "Registering runner '$RUNNER_NAME'..."
|
||||
"$INSTALL_DIR/act_runner" register \
|
||||
--no-interactive \
|
||||
--config "$CONFIG_PATH" \
|
||||
--instance "$GITEA_URL" \
|
||||
--token "$REGISTRATION_TOKEN" \
|
||||
--name "$RUNNER_NAME" \
|
||||
--labels "macos-latest:host,macos-14:host"
|
||||
|
||||
# Copy .runner file to work dir if it was created in cwd
|
||||
[[ -f ".runner" ]] && cp .runner "$WORK_DIR/.runner"
|
||||
|
||||
# ── install LaunchDaemon ──────────────────────────────────────────────────────
|
||||
# PATH must include Homebrew + Xcode tools so xcodebuild, xcrun, npm, etc. are found.
|
||||
HOMEBREW_PREFIX=$([ "$ARCH" = "arm64" ] && echo "/opt/homebrew" || echo "/usr/local")
|
||||
|
||||
cat > "$LAUNCHDAEMON_PLIST" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.gitea.act_runner</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${INSTALL_DIR}/act_runner</string>
|
||||
<string>daemon</string>
|
||||
<string>--config</string>
|
||||
<string>${CONFIG_PATH}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${WORK_DIR}</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${WORK_DIR}/act_runner.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${WORK_DIR}/act_runner.err</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Xcode.app/Contents/Developer/usr/bin</string>
|
||||
<key>HOME</key>
|
||||
<string>${WORK_DIR}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
echo "LaunchDaemon written to $LAUNCHDAEMON_PLIST"
|
||||
|
||||
# ── load the daemon ───────────────────────────────────────────────────────────
|
||||
launchctl unload "$LAUNCHDAEMON_PLIST" 2>/dev/null || true
|
||||
launchctl load "$LAUNCHDAEMON_PLIST"
|
||||
echo "Runner '$RUNNER_NAME' started via LaunchDaemon"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " View logs: tail -f $WORK_DIR/act_runner.log"
|
||||
echo " Stop runner: sudo launchctl unload $LAUNCHDAEMON_PLIST"
|
||||
echo " Start runner: sudo launchctl load $LAUNCHDAEMON_PLIST"
|
||||
@@ -13,7 +13,6 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
return json({
|
||||
id: locals.user.id,
|
||||
username: locals.user.username,
|
||||
role: locals.user.role,
|
||||
created: locals.user.created ?? ''
|
||||
role: locals.user.role
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let html = $state(data.html);
|
||||
let fetchingContent = $state(!data.isPreview && !data.html);
|
||||
let fetchError = $state('');
|
||||
|
||||
// ── Word count ────────────────────────────────────────────────────────────
|
||||
function countWords(htmlStr: string | null): number {
|
||||
if (!htmlStr) return 0;
|
||||
@@ -15,10 +19,6 @@
|
||||
|
||||
const wordCount = $derived(countWords(html));
|
||||
|
||||
let html = $state(data.html);
|
||||
let fetchingContent = $state(!data.isPreview && !data.html);
|
||||
let fetchError = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
// Record reading progress (skip for preview chapters)
|
||||
if (!data.isPreview) {
|
||||
|
||||
Reference in New Issue
Block a user