Compare commits

...

29 Commits

Author SHA1 Message Date
Syer10
c84ab6e426 Simplify installExtension 2026-07-12 23:47:08 -04:00
Syer10
a830782b4b Improvements and fixes 2026-07-12 23:43:11 -04:00
Syer10
c0586e7f36 Optimize imports 2026-07-12 23:01:13 -04:00
Syer10
47f0e60659 Remove test function 2026-07-12 23:00:44 -04:00
Syer10
d65a004a17 Utility function 2026-07-12 22:58:36 -04:00
Syer10
c18155cf06 Lint 2026-07-12 22:57:38 -04:00
Syer10
4fdef7d17d Return pkgName for extension install 2026-07-12 22:56:28 -04:00
Syer10
a66649715e Changelog 2026-07-12 22:34:30 -04:00
Syer10
393a7349ef Install JARs directly 2026-07-12 22:13:54 -04:00
Constantin Piber
b0bc8c6fb3 fix: Do not shadow exceptions on ext install (#2180)
Depending on where the exception occurred, the uninstall may fail,
because the extension is not installed in the first place, which hides
the actual error
2026-07-12 16:21:47 -04:00
Mitchell Syer
9a0871ab93 Fix missing memo field in getPageList (#2175) 2026-07-08 15:41:22 -04:00
Syer10
0271a0174f Remove leading slash 2026-07-05 15:47:15 -04:00
Syer10
a1770cb055 Release v2.3.2238 2026-07-05 15:22:03 -04:00
Mitchell Syer
cf3589f272 Improve winget action (#2164) 2026-07-05 14:23:43 -04:00
Mitchell Syer
d7a839b1fd Fix automatic docker deploy for stable builds (#2162) 2026-07-05 14:23:30 -04:00
Mitchell Syer
5d46f2a197 Use temporary file for H2 migration tools download (#2169) 2026-07-05 14:23:15 -04:00
Mitchell Syer
fbd3a3cc5d Support Fully Qualified Class Names (#2168)
* Support Fully Qualified Class Names

* Lint
2026-07-05 14:23:01 -04:00
Mitchell Syer
41d23c0a09 Return Partial Manga Data with Errors (#2166)
* Return Partial Manga Data with Errors

* Lint

* Optimize imports

* Update server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt

Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>

* Update server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt

Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>

---------

Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>
2026-07-05 14:22:28 -04:00
Syer10
f473135d04 Release v2.3.2232 2026-07-03 19:09:53 -04:00
Mitchell Syer
36ecb3ce6f Update APK url on installed Extensions (#2159)
* Update APK url on installed Extensions

* Changelog
2026-07-02 13:39:03 -04:00
Syer10
0cac79f354 Release v2.3.2230 2026-07-01 17:36:21 -04:00
Mitchell Syer
0fd10cc08e Fix chmod attempts on Windows Java (#2156) 2026-07-01 17:23:17 -04:00
Mitchell Syer
87aac38253 Remove jogamp (#2155)
* Remove jogamp from bundler

* Changelog
2026-07-01 16:37:03 -04:00
Mitchell Syer
9ca66c18de Add error handling to bundler (#2154)
* Add error handling to bundler

* Changelog
2026-07-01 14:41:59 -04:00
Syer10
b3c7962503 Release v2.3.2226 2026-07-01 11:57:49 -04:00
schroda
ca93783e72 Fix gql "extension" "extension store" typing (#2151)
The local source, externally installed apks and extensions whose extension store got removed do not have a store. This caused gql to fail because the extensions "extensionStore" was expected to be non-nullable.
2026-07-01 11:51:53 -04:00
Syer10
c1d0df8e3a [skip ci] Changelog fixes 2026-06-30 15:37:29 -04:00
Syer10
612768faeb Release v2.3.2223 2026-06-30 15:17:18 -04:00
Syer10
9a1745b626 [skip ci] Add changelog for bind existing track 2026-06-30 14:53:58 -04:00
27 changed files with 903 additions and 326 deletions

View File

@@ -42,7 +42,7 @@ body:
label: Suwayomi-Server version label: Suwayomi-Server version
description: You can find your Suwayomi-Server version in **More → About**. description: You can find your Suwayomi-Server version in **More → About**.
placeholder: | placeholder: |
Example: "v2.2.2100" Example: "v2.3.2238"
validations: validations:
required: true required: true

View File

@@ -11,5 +11,5 @@ jobs:
steps: steps:
- name: run docker build and publish script - name: run docker build and publish script
run: | run: |
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_PREVIEW_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "stable"}}' https://api.github.com/repos/suwayomi/docker-tachidesk/actions/workflows/build_container_images.yml/dispatches curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_DOCKER_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "stable"}}' https://api.github.com/repos/suwayomi/suwayomi-server-docker/actions/workflows/build_container_images.yml/dispatches

View File

@@ -1,19 +1,52 @@
name: Publish to WinGet name: Publish to WinGet
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: Version description: Version
required: false required: false
type: string
release:
types: [published]
jobs: jobs:
publish: publish:
runs-on: windows-latest # action can only be run on windows runs-on: windows-latest # action can only be run on windows
steps: steps:
- uses: actions/checkout@v4
- name: Determine version
id: version
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
elif [ "${{ github.event_name }}" = "release" ]; then
VERSION="${{ github.event.release.tag_name }}"
else
VERSION="$(gh release view --json tagName -q .tagName)"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Sync winget-pkgs fork with upstream
shell: bash
env:
GH_TOKEN: ${{ secrets.WINGET_PUBLISH_PAT }}
run: |
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
"repos/Suwayomi/winget-pkgs/merge-upstream" \
-f branch=master
- uses: vedantmgoyal2009/winget-releaser@v2 - uses: vedantmgoyal2009/winget-releaser@v2
with: with:
identifier: Suwayomi.Suwayomi-Server identifier: Suwayomi.Suwayomi-Server
installers-regex: '.*x64.msi$' installers-regex: '.*x64.msi$'
token: ${{ secrets.WINGET_PUBLISH_PAT }} token: ${{ secrets.WINGET_PUBLISH_PAT }}
version: ${{ inputs.version || github.ref_name }} version: ${{ steps.version.outputs.version }}
release-tag: ${{ inputs.version || github.ref_name }} release-tag: ${{ steps.version.outputs.version }}

View File

@@ -6,10 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] (Preview) ## [Unreleased] (Preview)
### Added
- (**Extension**) Add ability to install jar versions of extensions
### Changed
- .
### Fixed
- (**Extension**) Fix missing memo field in getPageList
## [v2.3.2238] - 2026-07-05
### Changed
- (**Extension**) Support fully qualified extension class names
- (**Manga/API**) Return partial responses for Manga And Chapters query
### Fixed
- (**Database/H2**) Use temporary file for H2 migration tools download
## [v2.3.2232] - 2026-07-03
### Fixed
- (**Extension**) Update APK url on installed Extensions
## [v2.3.2230] - 2026-07-01
### Fixed
- (**Extension/API**) Fix GQL handling of extensions without an extension store
- (**Build/Bundler**) Fix build continuing if errors happen
- (**Build/Bundler**) Remove jogamp from build script
- (**Build/Bundler**) Fix chmod attempts on Windows Java
## [v2.3.2223] + [WebUI: v20260509.01] - 2026-06-30
### Major Changes
#### Added [SyncYomi](https://github.com/syncyomi/syncyomi) support
This allows you to sync your server manga with other Mihon-based forks! As long as the fork supports SyncYomi it can be sync with!
#### Support Extension API v1.6
This update allows Suwayomi to load and use v1.6 extensions, it is a minor improvement over the existing 1.4 extension API that cleans up much of what we had! It is the basis of future extension APIs that will allow for further development.
This also allows us to move to Mihon's Extension Store system and replace our Extension Repo system. Old Extension Repos are still compatible and will be automatically migrated if they move to the Extension Store system.
> [!WARNING]
> Please back up your Extension Repos, because of the new Extension Stores system you may lose them in the update process and may need to re-add them.
### Added ### Added
- (**Sync**) Added [SyncYomi](https://github.com/syncyomi/syncyomi) support - (**Sync**) Added [SyncYomi](https://github.com/syncyomi/syncyomi) support
- (**OPDS**) Add option to skip chapter metadata feed providing direct stream/download links - (**OPDS**) Add option to skip chapter metadata feed providing direct stream/download links
- (**Extension/API**) Support Extensions API v1.6 - (**Extension/API**) Support Extensions API v1.6
- (**Tracker/API**) Add mutation to bind existing track record
### Changed ### Changed
- (**Database/H2**) Use the latest H2 database engine - (**Database/H2**) Use the latest H2 database engine
@@ -426,6 +473,7 @@ Huge thanks to @martinek who pulled the most of the weight this release!
<!-- WEBUI LINKS --> <!-- WEBUI LINKS -->
[WebUI: v20260509.01]: https://github.com/Suwayomi/Suwayomi-WebUI/blob/master/CHANGELOG.md#2026050901-r3147---2026-05-09
[WebUI: v20260508.01]: https://github.com/Suwayomi/Suwayomi-WebUI/blob/master/CHANGELOG.md#2026050801-r3136---2026-05-08 [WebUI: v20260508.01]: https://github.com/Suwayomi/Suwayomi-WebUI/blob/master/CHANGELOG.md#2026050801-r3136---2026-05-08
[WebUI: v20251230.01]: https://github.com/Suwayomi/Suwayomi-WebUI/blob/master/CHANGELOG.md#2025123001-r2937---2025-12-30 [WebUI: v20251230.01]: https://github.com/Suwayomi/Suwayomi-WebUI/blob/master/CHANGELOG.md#2025123001-r2937---2025-12-30
[WebUI: v20250801.01]: https://github.com/Suwayomi/Suwayomi-WebUI/blob/master/CHANGELOG.md#2025080101-r2717---2025-08-01 [WebUI: v20250801.01]: https://github.com/Suwayomi/Suwayomi-WebUI/blob/master/CHANGELOG.md#2025080101-r2717---2025-08-01
@@ -452,7 +500,11 @@ Huge thanks to @martinek who pulled the most of the weight this release!
<!-- SERVER LINKS --> <!-- SERVER LINKS -->
[unreleased]: https://github.com/suwayomi/suwayomi-server/compare/v2.2.2100...HEAD [unreleased]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2238...HEAD
[v2.3.2238]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2232...v2.3.2238
[v2.3.2232]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2230...v2.3.2232
[v2.3.2230]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2223...v2.3.2230
[v2.3.2223]: https://github.com/suwayomi/suwayomi-server/compare/v2.2.2100...v2.3.2223
[v2.2.2100]: https://github.com/suwayomi/suwayomi-server/compare/v2.1.1867...v2.2.2100 [v2.2.2100]: https://github.com/suwayomi/suwayomi-server/compare/v2.1.1867...v2.2.2100
[v2.1.1867]: https://github.com/suwayomi/suwayomi-server/compare/v2.0.1727...v2.1.1867 [v2.1.1867]: https://github.com/suwayomi/suwayomi-server/compare/v2.0.1727...v2.1.1867
[v2.0.1727]: https://github.com/suwayomi/suwayomi-server/compare/v1.1.1...v2.0.1727 [v2.0.1727]: https://github.com/suwayomi/suwayomi-server/compare/v1.1.1...v2.0.1727

View File

@@ -10,9 +10,9 @@ import java.io.BufferedReader
const val MainClass = "suwayomi.tachidesk.MainKt" const val MainClass = "suwayomi.tachidesk.MainKt"
// should be bumped with each stable release // should be bumped with each stable release
val getTachideskVersion = { "v2.2.${getCommitCount()}" } val getTachideskVersion = { "v2.3.${getCommitCount()}" }
val webUIRevisionTag = "r3136" val webUIRevisionTag = "r3147"
val webviewJbrRelease = "jbr-release-25.0.3b508.4" val webviewJbrRelease = "jbr-release-25.0.3b508.4"

View File

@@ -110,6 +110,7 @@ dex2jar-tools = { module = "de.femtopedia.dex2jar:dex-tools", version.ref = "dex
# APK # APK
apk-parser = "net.dongliu:apk-parser:2.6.10" apk-parser = "net.dongliu:apk-parser:2.6.10"
apksig = "com.android.tools.build:apksig:9.2.1" apksig = "com.android.tools.build:apksig:9.2.1"
axml = "com.github.Aliucord:axml:ed26565eb0"
# Xml # Xml
xmlpull = "xmlpull:xmlpull:1.1.3.4a" xmlpull = "xmlpull:xmlpull:1.1.3.4a"
@@ -215,6 +216,7 @@ shared = [
"dex2jar-translator", "dex2jar-translator",
"dex2jar-tools", "dex2jar-tools",
"apk-parser", "apk-parser",
"axml",
"jackson-annotations", "jackson-annotations",
"jcef", "jcef",
] ]

View File

@@ -26,7 +26,7 @@ main() {
set -- "${POSITIONAL_ARGS[@]}" set -- "${POSITIONAL_ARGS[@]}"
OS="$1" OS="$1"
JAR="$(ls server/build/*.jar | tail -n1)" JAR="$(ls server/build/*.jar | tail -n1)" || error $LINENO "No JAR found in server/build/" 1
RELEASE_NAME="$(echo "${JAR%.*}" | xargs basename)-$OS" RELEASE_NAME="$(echo "${JAR%.*}" | xargs basename)-$OS"
RELEASE_VERSION=$(echo "$JAR" | grep -oP "v\K[0-9]+\.[0-9]+\.[0-9]+") RELEASE_VERSION=$(echo "$JAR" | grep -oP "v\K[0-9]+\.[0-9]+\.[0-9]+")
#RELEASE_REVISION_NUMBER="$(tmp="${JAR%.*}" && echo "${tmp##*-}" | tr -d r)" #RELEASE_REVISION_NUMBER="$(tmp="${JAR%.*}" && echo "${tmp##*-}" | tr -d r)"
@@ -34,12 +34,12 @@ main() {
# clean temporary directory on function return # clean temporary directory on function return
trap "rm -rf $RELEASE_NAME/" RETURN trap "rm -rf $RELEASE_NAME/" RETURN
mkdir "$RELEASE_NAME/" mkdir "$RELEASE_NAME/" || error $LINENO "Failed to create release directory" 1
download_launcher download_launcher
if [ ! -f scripts/resources/catch_abort.so ]; then if [ ! -f scripts/resources/catch_abort.so ]; then
gcc -fPIC -shared scripts/resources/catch_abort.c -lpthread -o scripts/resources/catch_abort.so gcc -fPIC -shared scripts/resources/catch_abort.c -lpthread -o scripts/resources/catch_abort.so || error $LINENO "Failed to compile catch_abort" 1
fi fi
JRE_ZULU="25.34.17_25.0.3" JRE_ZULU="25.34.17_25.0.3"
@@ -49,7 +49,6 @@ main() {
case "$OS" in case "$OS" in
debian-all) debian-all)
RELEASE="$RELEASE_NAME.deb" RELEASE="$RELEASE_NAME.deb"
download_jogamp "linux-*" # it's easier to bundle them ourselves than to handle Debian's path conventions
make_deb_package make_deb_package
move_release_to_output_dir move_release_to_output_dir
;; ;;
@@ -57,7 +56,6 @@ main() {
JRE="$ZULU_RELEASE-ca-$JRE_RELEASE-linux_x64.zip" JRE="$ZULU_RELEASE-ca-$JRE_RELEASE-linux_x64.zip"
JRE_DIR="${JRE%.*}" JRE_DIR="${JRE%.*}"
JRE_URL="https://cdn.azul.com/zulu/bin/$JRE" JRE_URL="https://cdn.azul.com/zulu/bin/$JRE"
download_jogamp "linux-amd64"
setup_jre setup_jre
RELEASE="$RELEASE_NAME.AppImage" RELEASE="$RELEASE_NAME.AppImage"
@@ -67,7 +65,7 @@ main() {
linux-assets) linux-assets)
RELEASE="$RELEASE_NAME.tar.gz" RELEASE="$RELEASE_NAME.tar.gz"
copy_linux_package_assets_to "$RELEASE_NAME/" copy_linux_package_assets_to "$RELEASE_NAME/"
tar -I "gzip -9" -cvf "$RELEASE" "$RELEASE_NAME/" tar -I "gzip -9" -cvf "$RELEASE" "$RELEASE_NAME/" || error $LINENO "Failed to create tar.gz" 1
move_release_to_output_dir move_release_to_output_dir
;; ;;
linux-x64) linux-x64)
@@ -77,7 +75,6 @@ main() {
ELECTRON="electron-$electron_version-linux-x64.zip" ELECTRON="electron-$electron_version-linux-x64.zip"
ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON" ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON"
download_electron download_electron
download_jogamp "linux-amd64"
setup_jre setup_jre
tree "$RELEASE_NAME" tree "$RELEASE_NAME"
@@ -92,7 +89,6 @@ main() {
ELECTRON="electron-$electron_version-darwin-x64.zip" ELECTRON="electron-$electron_version-darwin-x64.zip"
ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON" ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON"
download_electron download_electron
download_jogamp "macosx-universal"
setup_jre setup_jre
tree "$RELEASE_NAME" tree "$RELEASE_NAME"
@@ -107,7 +103,6 @@ main() {
ELECTRON="electron-$electron_version-darwin-arm64.zip" ELECTRON="electron-$electron_version-darwin-arm64.zip"
ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON" ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON"
download_electron download_electron
download_jogamp "macosx-universal"
setup_jre setup_jre
tree "$RELEASE_NAME" tree "$RELEASE_NAME"
@@ -122,7 +117,6 @@ main() {
ELECTRON="electron-$electron_version-win32-x64.zip" ELECTRON="electron-$electron_version-win32-x64.zip"
ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON" ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON"
download_electron download_electron
download_jogamp "windows-amd64"
setup_jre setup_jre
tree "$RELEASE_NAME" tree "$RELEASE_NAME"
@@ -145,52 +139,50 @@ move_release_to_output_dir() {
if [ -f "$OUTPUT_DIR/$RELEASE" ]; then if [ -f "$OUTPUT_DIR/$RELEASE" ]; then
rm "$OUTPUT_DIR/$RELEASE" rm "$OUTPUT_DIR/$RELEASE"
fi fi
mv "$RELEASE" "$OUTPUT_DIR/" mv "$RELEASE" "$OUTPUT_DIR/" || error $LINENO "Failed to move release to output dir" 1
} }
download_launcher() { download_launcher() {
LAUNCHER_URL=$(curl -sf "https://api.github.com/repos/Suwayomi/Suwayomi-Launcher/releases/latest" | grep "browser_download_url" | grep ".jar" | head -n 1 | cut -d '"' -f 4) LAUNCHER_URL=$(curl -sf "https://api.github.com/repos/Suwayomi/Suwayomi-Launcher/releases/latest" | grep "browser_download_url" | grep ".jar" | head -n 1 | cut -d '"' -f 4)
curl -fL "$LAUNCHER_URL" -o "Suwayomi-Launcher.jar" if [ -z "$LAUNCHER_URL" ]; then
mv "Suwayomi-Launcher.jar" "$RELEASE_NAME/Suwayomi-Launcher.jar" error $LINENO "Failed to determine launcher URL from GitHub API" 1
}
download_jogamp() {
local platform="$1"
if [ ! -f jogamp-all-platforms.7z ]; then
curl -f "https://jogamp.org/deployment/jogamp-current/archive/jogamp-all-platforms.7z" -o jogamp-all-platforms.7z
fi fi
curl -fL "$LAUNCHER_URL" -o "Suwayomi-Launcher.jar" || error $LINENO "Failed to download launcher JAR" 1
7z x jogamp-all-platforms.7z "jogamp-all-platforms/lib/$platform/" mv "Suwayomi-Launcher.jar" "$RELEASE_NAME/Suwayomi-Launcher.jar" || error $LINENO "Failed to move launcher JAR" 1
mkdir -p "$RELEASE_NAME/natives/"
mv jogamp-all-platforms/lib/* "$RELEASE_NAME/natives/"
rm -rf jogamp-all-platforms
} }
download_electron() { download_electron() {
if [ ! -f "$ELECTRON" ]; then if [ ! -f "$ELECTRON" ]; then
curl -fL "$ELECTRON_URL" -o "$ELECTRON" curl -fL "$ELECTRON_URL" -o "$ELECTRON" || error $LINENO "Failed to download electron" 1
fi fi
unzip "$ELECTRON" -d "$RELEASE_NAME/electron/" unzip "$ELECTRON" -d "$RELEASE_NAME/electron/" || error $LINENO "Failed to extract electron" 1
} }
setup_jre() { setup_jre() {
if [ -d "jre" ]; then if [ -d "jre" ]; then
chmod +x ./jre/bin/java if [ ! -f "./jre/bin/java.exe" ]; then
chmod +x ./jre/lib/jspawnhelper chmod +x ./jre/bin/java || error $LINENO "Failed to set java executable permission" 1
mv "jre" "$RELEASE_NAME/jre" chmod +x ./jre/lib/jspawnhelper || error $LINENO "Failed to set jspawnhelper executable permission" 1
fi
mv "jre" "$RELEASE_NAME/jre" || error $LINENO "Failed to move jre" 1
else else
if [ ! -f "$JRE" ]; then if [ ! -f "$JRE" ]; then
curl -fL "$JRE_URL" -o "$JRE" curl -fL "$JRE_URL" -o "$JRE" || error $LINENO "Failed to download JRE" 1
fi fi
local ext="${JRE##*.}" local ext="${JRE##*.}"
if [ "$ext" = "zip" ]; then if [ "$ext" = "zip" ]; then
unzip "$JRE" unzip "$JRE" || error $LINENO "Failed to extract JRE zip" 1
else else
tar xvf "$JRE" tar xvf "$JRE" || error $LINENO "Failed to extract JRE tar" 1
fi
mv "$JRE_DIR" "$RELEASE_NAME/jre" || error $LINENO "Failed to move extracted JRE" 1
if [ ! -f "$RELEASE_NAME/jre/bin/java.exe" ]; then
chmod +x "$RELEASE_NAME/jre/bin/java" || error $LINENO "Failed to set java executable permission" 1
chmod +x "$RELEASE_NAME/jre/lib/jspawnhelper" || error $LINENO "Failed to set jspawnhelper executable permission" 1
fi fi
mv "$JRE_DIR" "$RELEASE_NAME/jre"
fi fi
} }
@@ -198,31 +190,31 @@ copy_linux_package_assets_to() {
local output_dir local output_dir
output_dir="$(readlink -e "$1" || exit 1)" output_dir="$(readlink -e "$1" || exit 1)"
cp "scripts/resources/pkg/suwayomi-server.sh" "$output_dir/" cp "scripts/resources/pkg/suwayomi-server.sh" "$output_dir/" || error $LINENO "Failed to copy server script" 1
cp "scripts/resources/pkg/suwayomi-server.desktop" "$output_dir/" cp "scripts/resources/pkg/suwayomi-server.desktop" "$output_dir/" || error $LINENO "Failed to copy server desktop file" 1
cp "scripts/resources/pkg/suwayomi-launcher.sh" "$output_dir/" cp "scripts/resources/pkg/suwayomi-launcher.sh" "$output_dir/" || error $LINENO "Failed to copy launcher script" 1
cp "scripts/resources/pkg/suwayomi-launcher.desktop" "$output_dir/" cp "scripts/resources/pkg/suwayomi-launcher.desktop" "$output_dir/" || error $LINENO "Failed to copy launcher desktop file" 1
cp "scripts/resources/pkg/systemd"/* "$output_dir/" cp "scripts/resources/pkg/systemd"/* "$output_dir/" || error $LINENO "Failed to copy systemd files" 1
cp "server/src/main/resources/icon/faviconlogo-128.png" \ cp "server/src/main/resources/icon/faviconlogo-128.png" \
"$output_dir/suwayomi-server.png" "$output_dir/suwayomi-server.png" || error $LINENO "Failed to copy icon" 1
} }
make_linux_bundle() { make_linux_bundle() {
mkdir "$RELEASE_NAME/bin" mkdir "$RELEASE_NAME/bin"
cp "$JAR" "$RELEASE_NAME/bin/Suwayomi-Server.jar" cp "$JAR" "$RELEASE_NAME/bin/Suwayomi-Server.jar" || error $LINENO "Failed to copy server jar" 1
cp "scripts/resources/suwayomi-launcher.sh" "$RELEASE_NAME/" cp "scripts/resources/suwayomi-launcher.sh" "$RELEASE_NAME/" || error $LINENO "Failed to copy launcher script" 1
cp "scripts/resources/suwayomi-server.sh" "$RELEASE_NAME/" cp "scripts/resources/suwayomi-server.sh" "$RELEASE_NAME/" || error $LINENO "Failed to copy server script" 1
cp "scripts/resources/catch_abort.so" "$RELEASE_NAME/bin/" cp "scripts/resources/catch_abort.so" "$RELEASE_NAME/bin/" || error $LINENO "Failed to copy catch_abort" 1
tar -I "gzip -9" -cvf "$RELEASE" "$RELEASE_NAME/" tar -I "gzip -9" -cvf "$RELEASE" "$RELEASE_NAME/" || error $LINENO "Failed to create tar.gz" 1
} }
make_macos_bundle() { make_macos_bundle() {
mkdir "$RELEASE_NAME/bin" mkdir "$RELEASE_NAME/bin"
cp "$JAR" "$RELEASE_NAME/bin/Suwayomi-Server.jar" cp "$JAR" "$RELEASE_NAME/bin/Suwayomi-Server.jar" || error $LINENO "Failed to copy server jar" 1
cp "scripts/resources/Suwayomi Launcher.command" "$RELEASE_NAME/" cp "scripts/resources/Suwayomi Launcher.command" "$RELEASE_NAME/" || error $LINENO "Failed to copy launcher command" 1
tar -I "gzip -9" -cvf "$RELEASE" "$RELEASE_NAME/" tar -I "gzip -9" -cvf "$RELEASE" "$RELEASE_NAME/" || error $LINENO "Failed to create tar.gz" 1
} }
# https://wiki.debian.org/SimplePackagingTutorial # https://wiki.debian.org/SimplePackagingTutorial
@@ -233,28 +225,27 @@ make_deb_package() {
#behind $RELEASE_VERSION is underscore "_" #behind $RELEASE_VERSION is underscore "_"
local upstream_source="suwayomi-server_$RELEASE_VERSION.orig.tar.gz" local upstream_source="suwayomi-server_$RELEASE_VERSION.orig.tar.gz"
mkdir "$RELEASE_NAME/$source_dir/" mkdir "$RELEASE_NAME/$source_dir/" || error $LINENO "Failed to create source directory" 1
mv "$RELEASE_NAME/natives" "$RELEASE_NAME/$source_dir/natives" mv "$RELEASE_NAME/Suwayomi-Launcher.jar" "$RELEASE_NAME/$source_dir/Suwayomi-Launcher.jar" || error $LINENO "Failed to move launcher jar" 1
mv "$RELEASE_NAME/Suwayomi-Launcher.jar" "$RELEASE_NAME/$source_dir/Suwayomi-Launcher.jar" cp "$JAR" "$RELEASE_NAME/$source_dir/Suwayomi-Server.jar" || error $LINENO "Failed to copy server jar" 1
cp "$JAR" "$RELEASE_NAME/$source_dir/Suwayomi-Server.jar"
copy_linux_package_assets_to "$RELEASE_NAME/$source_dir/" copy_linux_package_assets_to "$RELEASE_NAME/$source_dir/"
cp "scripts/resources/catch_abort.so" "$RELEASE_NAME/$source_dir/" cp "scripts/resources/catch_abort.so" "$RELEASE_NAME/$source_dir/" || error $LINENO "Failed to copy catch_abort" 1
tar -I "gzip" -C "$RELEASE_NAME/" -cvf "$upstream_source" "$source_dir" tar -I "gzip" -C "$RELEASE_NAME/" -cvf "$upstream_source" "$source_dir" || error $LINENO "Failed to create source tar.gz" 1
cp -r "scripts/resources/deb/" "$RELEASE_NAME/$source_dir/debian/" cp -r "scripts/resources/deb/" "$RELEASE_NAME/$source_dir/debian/" || error $LINENO "Failed to copy debian resources" 1
sed -i "s/\$pkgver/$RELEASE_VERSION/" "$RELEASE_NAME/$source_dir/debian/changelog" sed -i "s/\$pkgver/$RELEASE_VERSION/" "$RELEASE_NAME/$source_dir/debian/changelog" || error $LINENO "Failed to update changelog" 1
sed -i "s/\$pkgrel/1/" "$RELEASE_NAME/$source_dir/debian/changelog" sed -i "s/\$pkgrel/1/" "$RELEASE_NAME/$source_dir/debian/changelog" || error $LINENO "Failed to update changelog" 1
if [ "${CI:-}" = true ]; then if [ "${CI:-}" = true ]; then
sudo apt update sudo apt update || error $LINENO "Failed to update apt" 1
sudo apt install devscripts build-essential dh-exec sudo apt install devscripts build-essential dh-exec || error $LINENO "Failed to install build deps" 1
fi fi
cd "$RELEASE_NAME/$source_dir/" cd "$RELEASE_NAME/$source_dir/" || error $LINENO "Failed to change directory" 1
dpkg-buildpackage --no-sign --build=all dpkg-buildpackage --no-sign --build=all || error $LINENO "Debian package build failed" 1
cd - cd - || error $LINENO "Failed to return to previous directory" 1
local deb="suwayomi-server_$RELEASE_VERSION-1_all.deb" local deb="suwayomi-server_$RELEASE_VERSION-1_all.deb"
mv "$RELEASE_NAME/$deb" "$RELEASE" mv "$RELEASE_NAME/$deb" "$RELEASE" || error $LINENO "Failed to move resulting .deb" 1
} }
# https://linuxconfig.org/building-a-hello-world-appimage-on-linux # https://linuxconfig.org/building-a-hello-world-appimage-on-linux
@@ -262,20 +253,20 @@ make_appimage() {
local APPIMAGE_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" local APPIMAGE_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
local APPIMAGE_TOOLNAME="appimagetool-x86_64.AppImage" local APPIMAGE_TOOLNAME="appimagetool-x86_64.AppImage"
mkdir "$RELEASE_NAME/bin/" mkdir "$RELEASE_NAME/bin/"
cp "$JAR" "$RELEASE_NAME/bin/Suwayomi-Server.jar" cp "$JAR" "$RELEASE_NAME/bin/Suwayomi-Server.jar" || error $LINENO "Failed to copy server jar" 1
cp "scripts/resources/pkg/suwayomi-server.desktop" "$RELEASE_NAME/suwayomi-server.desktop" cp "scripts/resources/pkg/suwayomi-server.desktop" "$RELEASE_NAME/suwayomi-server.desktop" || error $LINENO "Failed to copy desktop file" 1
cp "server/src/main/resources/icon/faviconlogo.png" "$RELEASE_NAME/suwayomi-server.png" cp "server/src/main/resources/icon/faviconlogo.png" "$RELEASE_NAME/suwayomi-server.png" || error $LINENO "Failed to copy icon" 1
cp "scripts/resources/appimage/AppRun" "$RELEASE_NAME/AppRun" cp "scripts/resources/appimage/AppRun" "$RELEASE_NAME/AppRun" || error $LINENO "Failed to copy AppRun" 1
chmod +x "$RELEASE_NAME/AppRun" chmod +x "$RELEASE_NAME/AppRun" || error $LINENO "Failed to make AppRun executable" 1
if [ "${CI:-}" = true ]; then if [ "${CI:-}" = true ]; then
sudo apt update sudo apt update || error $LINENO "Failed to update apt" 1
sudo apt install libfuse2 sudo apt install libfuse2 || error $LINENO "Failed to install libfuse2" 1
fi fi
curl -fL $APPIMAGE_URL -o $APPIMAGE_TOOLNAME curl -fL $APPIMAGE_URL -o $APPIMAGE_TOOLNAME || error $LINENO "Failed to download appimagetool" 1
chmod +x $APPIMAGE_TOOLNAME chmod +x $APPIMAGE_TOOLNAME || error $LINENO "Failed to make appimagetool executable" 1
ARCH=x86_64 ./$APPIMAGE_TOOLNAME "$RELEASE_NAME" "$RELEASE" ARCH=x86_64 ./$APPIMAGE_TOOLNAME "$RELEASE_NAME" "$RELEASE" || error $LINENO "AppImage creation failed" 1
} }
make_windows_bundle() { make_windows_bundle() {
@@ -300,7 +291,7 @@ make_windows_bundle() {
#local rcedit_url="https://github.com/electron/rcedit/releases/download/v0.1.1/$rcedit" #local rcedit_url="https://github.com/electron/rcedit/releases/download/v0.1.1/$rcedit"
## change electron's icon ## change electron's icon
#if [ ! -f "$rcedit" ]; then #if [ ! -f "$rcedit" ]; then
#curl -fL "$rcedit_url" -o "$rcedit" #curl -fL "$rcedit_url" -o "$rcedit" || error $LINENO "Failed to download rcedit" 1
#fi #fi
#local icon="server/src/main/resources/icon/faviconlogo.ico" #local icon="server/src/main/resources/icon/faviconlogo.ico"
@@ -308,38 +299,35 @@ make_windows_bundle() {
# --set-icon "$icon" # --set-icon "$icon"
mkdir "$RELEASE_NAME/bin" mkdir "$RELEASE_NAME/bin"
cp "$JAR" "$RELEASE_NAME/bin/Suwayomi-Server.jar" cp "$JAR" "$RELEASE_NAME/bin/Suwayomi-Server.jar" || error $LINENO "Failed to copy server jar" 1
cp "scripts/resources/Suwayomi Launcher.bat" "$RELEASE_NAME" cp "scripts/resources/Suwayomi Launcher.bat" "$RELEASE_NAME" || error $LINENO "Failed to copy launcher bat" 1
zip -9 -r "$RELEASE" "$RELEASE_NAME" zip -9 -r "$RELEASE" "$RELEASE_NAME" || error $LINENO "Failed to create windows bundle zip" 1
} }
make_windows_package() { make_windows_package() {
if [ "${CI:-}" = true ]; then if [ "${CI:-}" = true ]; then
sudo apt update sudo apt update || error $LINENO "Failed to update apt" 1
sudo apt install -y wixl sudo apt install -y wixl || error $LINENO "Failed to install wixl" 1
fi fi
find "$RELEASE_NAME/jre" \ find "$RELEASE_NAME/jre" \
| wixl-heat --var var.SourceDir -p "$RELEASE_NAME/" \ | wixl-heat --var var.SourceDir -p "$RELEASE_NAME/" \
--directory-ref jre --component-group jre >"$RELEASE_NAME/jre.wxs" --directory-ref jre --component-group jre >"$RELEASE_NAME/jre.wxs" || error $LINENO "Failed to heat jre" 1
find "$RELEASE_NAME/electron" \ find "$RELEASE_NAME/electron" \
| wixl-heat --var var.SourceDir -p "$RELEASE_NAME/" \ | wixl-heat --var var.SourceDir -p "$RELEASE_NAME/" \
--directory-ref electron --component-group electron >"$RELEASE_NAME/electron.wxs" --directory-ref electron --component-group electron >"$RELEASE_NAME/electron.wxs" || error $LINENO "Failed to heat electron" 1
find "$RELEASE_NAME/natives" \
| wixl-heat --var var.SourceDir -p "$RELEASE_NAME/" \
--directory-ref natives --component-group natives >"$RELEASE_NAME/natives.wxs"
find "$RELEASE_NAME/bin" \ find "$RELEASE_NAME/bin" \
| wixl-heat --var var.SourceDir -p "$RELEASE_NAME/" \ | wixl-heat --var var.SourceDir -p "$RELEASE_NAME/" \
--directory-ref bin --component-group bin >"$RELEASE_NAME/bin.wxs" --directory-ref bin --component-group bin >"$RELEASE_NAME/bin.wxs" || error $LINENO "Failed to heat bin" 1
local icon="server/src/main/resources/icon/faviconlogo.ico" local icon="server/src/main/resources/icon/faviconlogo.ico"
local arch=${OS##*-} local arch=${OS##*-}
wixl -D ProductVersion="$RELEASE_VERSION" -D SourceDir="$RELEASE_NAME" \ wixl -D ProductVersion="$RELEASE_VERSION" -D SourceDir="$RELEASE_NAME" \
-D Icon="$icon" --arch "$arch" "scripts/resources/msi/suwayomi-server-$arch.wxs" \ -D Icon="$icon" --arch "$arch" "scripts/resources/msi/suwayomi-server-$arch.wxs" \
"$RELEASE_NAME/jre.wxs" "$RELEASE_NAME/electron.wxs" "$RELEASE_NAME/natives.wxs" "$RELEASE_NAME/bin.wxs" -o "$RELEASE" "$RELEASE_NAME/jre.wxs" "$RELEASE_NAME/electron.wxs" "$RELEASE_NAME/bin.wxs" -o "$RELEASE" || error $LINENO "Windows package build failed" 1
} }
# Error handler # Error handler

View File

@@ -2,7 +2,6 @@
Suwayomi-Server.jar usr/share/java/suwayomi-server/bin/ Suwayomi-Server.jar usr/share/java/suwayomi-server/bin/
Suwayomi-Launcher.jar usr/share/java/suwayomi-server/ Suwayomi-Launcher.jar usr/share/java/suwayomi-server/
natives/* usr/share/java/suwayomi-server/natives/
suwayomi-server.png usr/share/pixmaps/ suwayomi-server.png usr/share/pixmaps/
suwayomi-server.desktop usr/share/applications/ suwayomi-server.desktop usr/share/applications/
suwayomi-launcher.desktop usr/share/applications/ suwayomi-launcher.desktop usr/share/applications/

View File

@@ -17,7 +17,6 @@
<Directory Id="INSTALLDIR" Name="Suwayomi-Server" > <Directory Id="INSTALLDIR" Name="Suwayomi-Server" >
<Directory Id="jre"/> <Directory Id="jre"/>
<Directory Id="electron"/> <Directory Id="electron"/>
<Directory Id="natives"/>
<Directory Id="bin"/> <Directory Id="bin"/>
</Directory> </Directory>
</Directory> </Directory>
@@ -63,7 +62,6 @@
<ComponentRef Id="SuwayomiLauncherBAT" /> <ComponentRef Id="SuwayomiLauncherBAT" />
<ComponentRef Id="ProgramMenuDir" /> <ComponentRef Id="ProgramMenuDir" />
<ComponentGroupRef Id="electron" /> <ComponentGroupRef Id="electron" />
<ComponentGroupRef Id="natives" />
</Feature> </Feature>
<Icon Id="Suwayomi.ico" SourceFile="$(var.Icon)" /> <Icon Id="Suwayomi.ico" SourceFile="$(var.Icon)" />

View File

@@ -179,10 +179,10 @@ class ExtensionMutation {
val (clientMutationId, extensionFile) = input val (clientMutationId, extensionFile) = input
return future { return future {
Extension.installExternalExtension(extensionFile.content(), extensionFile.filename()) val pkgName = Extension.installExternalExtension(extensionFile.content(), extensionFile.filename())
val dbExtension = val dbExtension =
transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq extensionFile.filename() }.first() } transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() }
InstallExternalExtensionPayload( InstallExternalExtensionPayload(
clientMutationId, clientMutationId,

View File

@@ -3,6 +3,8 @@
package suwayomi.tachidesk.graphql.mutations package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import com.expediagroup.graphql.server.extensions.toGraphQLError
import graphql.execution.DataFetcherResult
import org.jetbrains.exposed.v1.core.LikePattern import org.jetbrains.exposed.v1.core.LikePattern
import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.and
@@ -181,15 +183,21 @@ class MangaMutation {
) )
@RequireAuth @RequireAuth
fun fetchMangaAndChapters(input: FetchMangaAndChaptersInput): CompletableFuture<FetchMangaAndChaptersPayload?> { fun fetchMangaAndChapters(input: FetchMangaAndChaptersInput): CompletableFuture<DataFetcherResult<FetchMangaAndChaptersPayload?>> {
val (clientMutationId, id, fetchManga, fetchChapters) = input val (clientMutationId, id, fetchManga, fetchChapters) = input
return future { return future {
Manga.updateMangaAndChapters( val error =
mangaId = id, try {
updateManga = fetchManga, Manga.updateMangaAndChapters(
updateChapters = fetchChapters, mangaId = id,
) updateManga = fetchManga,
updateChapters = fetchChapters,
)
null
} catch (e: Exception) {
e
}
val (manga, chapters) = val (manga, chapters) =
transaction { transaction {
@@ -202,11 +210,20 @@ class MangaMutation {
.map { ChapterType(it) }, .map { ChapterType(it) },
) )
} }
FetchMangaAndChaptersPayload( @Suppress("UNCHECKED_CAST")
clientMutationId = clientMutationId, DataFetcherResult
manga = MangaType(manga), .newResult<FetchMangaAndChaptersPayload>()
chapters = chapters, .data(
) FetchMangaAndChaptersPayload(
clientMutationId = clientMutationId,
manga = MangaType(manga),
chapters = chapters,
),
).also {
if (error != null) {
it.error(error.toGraphQLError())
}
}.build() as DataFetcherResult<FetchMangaAndChaptersPayload?>
} }
} }

View File

@@ -103,6 +103,7 @@ class ExtensionQuery {
val name: String? = null, val name: String? = null,
val pkgName: String? = null, val pkgName: String? = null,
val apkUrl: String? = null, val apkUrl: String? = null,
val jarUrl: String? = null,
val extensionLib: String? = null, val extensionLib: String? = null,
val versionName: String? = null, val versionName: String? = null,
val versionCode: Int? = null, val versionCode: Int? = null,
@@ -122,6 +123,7 @@ class ExtensionQuery {
opAnd.eq(apkName, ExtensionTable.apkName) opAnd.eq(apkName, ExtensionTable.apkName)
opAnd.eq(iconUrl, ExtensionTable.iconUrl) opAnd.eq(iconUrl, ExtensionTable.iconUrl)
opAnd.eq(apkUrl, ExtensionTable.apkUrl) opAnd.eq(apkUrl, ExtensionTable.apkUrl)
opAnd.eq(jarUrl, ExtensionTable.jarUrl)
opAnd.eq(name, ExtensionTable.name) opAnd.eq(name, ExtensionTable.name)
opAnd.eq(extensionLib, ExtensionTable.extensionLib) opAnd.eq(extensionLib, ExtensionTable.extensionLib)
opAnd.eq(versionName, ExtensionTable.versionName) opAnd.eq(versionName, ExtensionTable.versionName)
@@ -150,6 +152,7 @@ class ExtensionQuery {
val name: StringFilter? = null, val name: StringFilter? = null,
val pkgName: StringFilter? = null, val pkgName: StringFilter? = null,
val apkUrl: StringFilter? = null, val apkUrl: StringFilter? = null,
val jarUrl: StringFilter? = null,
val versionName: StringFilter? = null, val versionName: StringFilter? = null,
val extensionLib: StringFilter? = null, val extensionLib: StringFilter? = null,
@GraphQLDeprecated("", ReplaceWith("versionCodeLong")) @GraphQLDeprecated("", ReplaceWith("versionCodeLong"))
@@ -175,6 +178,7 @@ class ExtensionQuery {
andFilterWithCompareString(ExtensionTable.name, name), andFilterWithCompareString(ExtensionTable.name, name),
andFilterWithCompareString(ExtensionTable.pkgName, pkgName), andFilterWithCompareString(ExtensionTable.pkgName, pkgName),
andFilterWithCompareString(ExtensionTable.apkUrl, apkUrl), andFilterWithCompareString(ExtensionTable.apkUrl, apkUrl),
andFilterWithCompareString(ExtensionTable.jarUrl, jarUrl),
andFilterWithCompareString(ExtensionTable.extensionLib, extensionLib), andFilterWithCompareString(ExtensionTable.extensionLib, extensionLib),
andFilterWithCompareString(ExtensionTable.versionName, versionName), andFilterWithCompareString(ExtensionTable.versionName, versionName),
andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong), andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong),

View File

@@ -32,6 +32,7 @@ class ExtensionType(
val name: String, val name: String,
val pkgName: String, val pkgName: String,
val apkUrl: String?, val apkUrl: String?,
val jarUrl: String?,
val extensionLib: String?, val extensionLib: String?,
val versionName: String, val versionName: String,
@GraphQLDeprecated( @GraphQLDeprecated(
@@ -56,6 +57,7 @@ class ExtensionType(
name = row[ExtensionTable.name], name = row[ExtensionTable.name],
pkgName = row[ExtensionTable.pkgName], pkgName = row[ExtensionTable.pkgName],
apkUrl = row[ExtensionTable.apkUrl], apkUrl = row[ExtensionTable.apkUrl],
jarUrl = row[ExtensionTable.jarUrl],
extensionLib = row[ExtensionTable.extensionLib], extensionLib = row[ExtensionTable.extensionLib],
versionName = row[ExtensionTable.versionName], versionName = row[ExtensionTable.versionName],
versionCode = row[ExtensionTable.versionCode].toInt(), versionCode = row[ExtensionTable.versionCode].toInt(),
@@ -71,8 +73,8 @@ class ExtensionType(
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceNodeList> = fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceNodeList> =
dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName) dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName)
fun extensionStore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionStoreType> = fun extensionStore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionStoreType?> =
dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionStoreType>("ExtensionStoreDataLoader", storeIndexUrl.orEmpty()) dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionStoreType?>("ExtensionStoreDataLoader", storeIndexUrl.orEmpty())
} }
data class ExtensionNodeList( data class ExtensionNodeList(

View File

@@ -63,8 +63,6 @@ object ExtensionController {
ctx.future { ctx.future {
future { future {
Extension.installExtension(pkgName) Extension.installExtension(pkgName)
}.thenApply {
ctx.status(it)
} }
} }
}, },
@@ -99,8 +97,6 @@ object ExtensionController {
uploadedFile.content(), uploadedFile.content(),
uploadedFile.filename(), uploadedFile.filename(),
) )
}.thenApply {
ctx.status(it)
} }
} }
}, },
@@ -126,8 +122,6 @@ object ExtensionController {
ctx.future { ctx.future {
future { future {
Extension.updateExtension(pkgName) Extension.updateExtension(pkgName)
}.thenApply {
ctx.status(it)
} }
} }
}, },

View File

@@ -14,6 +14,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.reactivecircus.cache4k.Cache import io.github.reactivecircus.cache4k.Cache
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
@@ -88,6 +89,7 @@ suspend fun refreshChapterPageList(
scanlator = chapterEntry[ChapterTable.scanlator] scanlator = chapterEntry[ChapterTable.scanlator]
chapter_number = chapterEntry[ChapterTable.chapter_number] chapter_number = chapterEntry[ChapterTable.chapter_number]
date_upload = chapterEntry[ChapterTable.date_upload] date_upload = chapterEntry[ChapterTable.date_upload]
memo = Json.decodeFromString(chapterEntry[ChapterTable.memo])
}, },
).mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) } ).mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) }

View File

@@ -17,9 +17,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import net.dongliu.apk.parser.ApkFile import net.dongliu.apk.parser.ApkFile
import net.dongliu.apk.parser.bean.Icon import net.dongliu.apk.parser.bean.Icon
import okhttp3.CacheControl import okhttp3.CacheControl
import okio.buffer import org.apache.commons.compress.archivers.zip.ZipFile
import okio.sink
import okio.source
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.insert
@@ -27,6 +25,7 @@ import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.manga.impl.util.AndroidManifestParser
import suwayomi.tachidesk.manga.impl.util.PackageTools import suwayomi.tachidesk.manga.impl.util.PackageTools
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
@@ -39,6 +38,7 @@ import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
import suwayomi.tachidesk.manga.impl.util.ResourceArscIconParser
import suwayomi.tachidesk.manga.impl.util.network.await import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.source.GetSource import suwayomi.tachidesk.manga.impl.util.source.GetSource
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
@@ -48,229 +48,448 @@ import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.nio.file.Path
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.Path import kotlin.io.path.Path
import kotlin.io.path.absolutePathString import kotlin.io.path.absolutePathString
import kotlin.io.path.copyTo
import kotlin.io.path.createDirectories
import kotlin.io.path.createParentDirectories
import kotlin.io.path.deleteExisting
import kotlin.io.path.deleteIfExists
import kotlin.io.path.deleteRecursively
import kotlin.io.path.div
import kotlin.io.path.exists
import kotlin.io.path.inputStream
import kotlin.io.path.isRegularFile
import kotlin.io.path.name
import kotlin.io.path.nameWithoutExtension
import kotlin.io.path.outputStream import kotlin.io.path.outputStream
import kotlin.io.path.relativeTo
import kotlin.io.path.walk
object Extension { object Extension {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val applicationDirs: ApplicationDirs by injectLazy() private val applicationDirs: ApplicationDirs by injectLazy()
suspend fun installExtension(pkgName: String): Int { suspend fun installExtension(pkgName: String): String {
logger.debug { "Installing $pkgName" } logger.debug { "Installing $pkgName" }
val apkUrl = val extension =
transaction { transaction {
ExtensionTable ExtensionTable
.select(ExtensionTable.apkUrl) .select(ExtensionTable.apkUrl, ExtensionTable.jarUrl)
.where { ExtensionTable.pkgName eq pkgName } .where { ExtensionTable.pkgName eq pkgName }
.firstOrNull() .firstOrNull()
?.get(ExtensionTable.apkUrl) } ?: throw NullPointerException("Could not find extension for $pkgName")
} ?: throw NullPointerException("Could not find extension $pkgName") val jarUrl = extension[ExtensionTable.jarUrl]
val apkUrl = extension[ExtensionTable.apkUrl]
val fetchToTemp: suspend (String) -> Path = { url ->
val name = Uri.parse(jarUrl).lastPathSegment!!
val savePath = Path(applicationDirs.tempRoot) / "extensions" / name
// download jar file
downloadExtension(url, savePath)
return installAPK { savePath
val apkName = Uri.parse(apkUrl).lastPathSegment!! }
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
// download apk file
downloadAPKFile(apkUrl, apkSavePath)
apkSavePath return when {
jarUrl != null -> {
installJAR { fetchToTemp(jarUrl) }
}
apkUrl != null -> {
installAPK { fetchToTemp(apkUrl) }
}
else -> {
throw NullPointerException("Could not find extension url for $pkgName")
}
} }
} }
suspend fun installExternalExtension( suspend fun installExternalExtension(
inputStream: InputStream, inputStream: InputStream,
apkName: String, extensionName: String,
): Int = ): String {
installAPK(true) { val copyToExtensionsRoot = {
val rootPath = Path(applicationDirs.extensionsRoot) val rootPath = Path(applicationDirs.tempRoot) / "extensions"
val downloadedFile = rootPath.resolve(apkName).normalize() val downloadedFile = rootPath.resolve(extensionName).normalize()
check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) { check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) {
"File '$apkName' is not a valid extension file" "File '$extensionName' is not a valid extension file"
} }
logger.debug { "Saving apk at $apkName" } logger.debug { "Saving jar at $extensionName" }
// download apk file // download jar file
downloadedFile.outputStream().sink().buffer().use { sink -> downloadedFile.createParentDirectories()
inputStream.source().use { source -> downloadedFile.outputStream().buffered().use { out ->
sink.writeAll(source) inputStream.use {
sink.flush() it.copyTo(out)
} }
} }
downloadedFile.absolutePathString() downloadedFile
} }
return when {
extensionName.endsWith(".jar") -> installJAR(true, copyToExtensionsRoot)
extensionName.endsWith(".apk") -> installAPK(true, copyToExtensionsRoot)
else -> throw NullPointerException("Could not find extension type for $extensionName")
}
}
suspend fun installAPK( suspend fun installAPK(
forceReinstall: Boolean = false, forceReinstall: Boolean = false,
fetcher: suspend () -> String, fetcher: suspend () -> Path,
): Int { ): String {
val apkFilePath = fetcher() val apkFile = fetcher()
val apkName = File(apkFilePath).name
val packageInfo = getPackageInfo(apkFile)
val pkgName = packageInfo.packageName
// check if we don't have the extension already installed // check if we don't have the extension already installed
// if it's installed and we want to update, it first has to be uninstalled // if it's installed and we want to update, it first has to be uninstalled
val isInstalled = val isInstalled =
transaction { transaction {
ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkName }.firstOrNull() ExtensionTable.select(ExtensionTable.isInstalled).where { ExtensionTable.pkgName eq pkgName }.firstOrNull()
}?.get(ExtensionTable.isInstalled) ?: false }?.get(ExtensionTable.isInstalled) ?: false
val fileNameWithoutType = apkName.substringBefore(".apk") val extensionsRoot = Path(applicationDirs.extensionsRoot)
val jarFile = extensionsRoot / (apkFile.nameWithoutExtension + ".jar")
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
val jarFilePath = "$dirPathWithoutType.jar"
val packageInfo = getPackageInfo(apkFilePath)
val pkgName = packageInfo.packageName
if (isInstalled && forceReinstall) { if (isInstalled && forceReinstall) {
uninstallExtension(pkgName) uninstallExtension(pkgName)
} else if (isInstalled) {
apkFile.deleteExisting()
return pkgName
} }
if (!isInstalled || forceReinstall) { if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) { throw Exception("This apk is not a Tachiyomi extension")
throw Exception("This apk is not a Tachiyomi extension") }
}
// Validate lib version // Validate lib version
val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble() val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
throw Exception( throw Exception(
"Lib version is $libVersion, while only versions " + "Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed", "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
) )
} }
// TODO: allow trusting keys // TODO: allow trusting keys
// val signatureHash = getSignatureHash(packageInfo) // val signatureHash = getSignatureHash(packageInfo)
//
// if (signatureHash == null) {
// throw Exception("Package $pkgName isn't signed")
// } else if (signatureHash !in trustedSignatures) {
// throw Exception("This apk is not a signed with the official tachiyomi signature")
// }
// if (signatureHash == null) { var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING)
// throw Exception("Package $pkgName isn't signed") if (contentWarning == 0) {
// } else if (signatureHash !in trustedSignatures) { contentWarning = packageInfo.applicationInfo.metaData
// throw Exception("This apk is not a signed with the official tachiyomi signature") .getString(METADATA_CONTENT_WARNING)
// } ?.toIntOrNull()
?: 0
var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING)
if (contentWarning == 0) { if (contentWarning == 0) {
contentWarning = packageInfo.applicationInfo.metaData contentWarning = packageInfo.applicationInfo.metaData
.getString(METADATA_CONTENT_WARNING) .getString(METADATA_NSFW)
?.toIntOrNull() ?.toIntOrNull()
?: 0 ?: 0
if (contentWarning == 0) { }
contentWarning = packageInfo.applicationInfo.metaData }
.getString(METADATA_NSFW)
?.toIntOrNull() val sourceClass =
?: 0 packageInfo.applicationInfo.metaData
} .getString(METADATA_SOURCE_CLASS)!!
.trim()
val className =
if (sourceClass.startsWith(".")) {
pkgName + sourceClass
} else {
sourceClass
} }
val className = logger.debug { "Main class for extension is $className" }
packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
logger.debug { "Main class for extension is $className" } dex2jar(apkFile, jarFile)
extractAssetsFromApk(apkFile, jarFile)
extractAndCacheApkIcon(apkFile, pkgName)
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType) // clean up
extractAssetsFromApk(apkFilePath, jarFilePath) apkFile.deleteExisting()
extractAndCacheApkIcon(apkFilePath, packageInfo.packageName)
// clean up try {
File(apkFilePath).delete() val extensionName =
packageInfo.applicationInfo.metaData.getString(METADATA_NAME)
?: packageInfo.applicationInfo.nonLocalizedLabel
.toString()
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
packageInfo.applicationInfo.metaData
.getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" }
?: packageInfo.versionName.substringBeforeLast('.')
setupJar(
jarFile,
className,
extensionName,
extensionLibVersion,
apkFile.name,
pkgName,
packageInfo.versionName,
packageInfo.versionCode,
contentWarning,
)
} catch (e: Throwable) {
// free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close()
jarFile.deleteIfExists()
try { try {
// collect sources from the extension uninstallExtension(pkgName)
val extensionMainClassInstance = loadExtensionSources(jarFilePath, className) } catch (_: Throwable) {
val sources: List<Source> = }
when (extensionMainClassInstance) { throw e
is Source -> listOf(extensionMainClassInstance) }
is SourceFactory -> extensionMainClassInstance.createSources() return pkgName
else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}") }
suspend fun installJAR(
forceReinstall: Boolean = false,
fetcher: suspend () -> Path,
): String {
val tmpJarFile = fetcher()
val jarFile = Path(applicationDirs.extensionsRoot) / tmpJarFile.name
val tmpIcon = jarFile.parent / (jarFile.nameWithoutExtension + ".icon")
val jarZip =
ZipFile
.builder()
.setPath(tmpJarFile)
.get()
val manifest: AndroidManifestParser.AndroidManifest
jarZip.use { jarZip ->
manifest =
jarZip
.getInputStream(jarZip.getEntry("AndroidManifest.xml"))
.use {
AndroidManifestParser.parse(it)
} }
try {
val langs = sources.map { it.lang }.toSet() ResourceArscIconParser.extractIcon(jarZip).use {
val extensionLang = tmpIcon.outputStream().use { out ->
when (langs.size) { it.copyTo(out)
0 -> ""
1 -> langs.first()
else -> "all"
}
val extensionName =
packageInfo.applicationInfo.metaData.getString(METADATA_NAME)
?: packageInfo.applicationInfo.nonLocalizedLabel
.toString()
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
packageInfo.applicationInfo.metaData
.getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" }
?: packageInfo.versionName.substringBeforeLast('.')
// update extension info
transaction {
if (ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
ExtensionTable.insert {
it[this.apkName] = apkName
it[name] = extensionName
it[this.pkgName] = packageInfo.packageName
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode.toLong()
it[extensionLib] = extensionLibVersion
it[lang] = extensionLang
it[this.contentWarning] = contentWarning
}
}
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[this.apkName] = apkName
it[this.isInstalled] = true
it[this.classFQName] = className
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode.toLong()
}
val extensionId =
ExtensionTable
.selectAll()
.where { ExtensionTable.pkgName eq pkgName }
.first()[ExtensionTable.id]
.value
sources.forEach { httpSource ->
SourceTable.insert {
it[id] = httpSource.id
it[name] = httpSource.name
it[lang] = httpSource.lang
it[extension] = extensionId
it[this.contentWarning] = contentWarning
}
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
} }
} }
return 201 // we installed successfully } catch (e: Exception) {
} catch (e: Throwable) { logger.warn(e) { "Failed to extract icon from JAR ${manifest.packageName}" }
// free up the file descriptor if exists }
PackageTools.jarLoaderMap.remove(jarFilePath)?.close() }
File(jarFilePath).delete()
val pkgName: String = manifest.packageName
uninstallExtension(pkgName)
throw e // check if we don't have the extension already installed
// if it's installed and we want to update, it first has to be uninstalled
val isInstalled =
transaction {
ExtensionTable
.select(ExtensionTable.isInstalled)
.where { ExtensionTable.pkgName eq pkgName }
.firstOrNull()
}?.get(ExtensionTable.isInstalled) ?: false
if (isInstalled && forceReinstall) {
uninstallExtension(pkgName)
} else if (isInstalled) {
tmpJarFile.deleteExisting()
tmpIcon.deleteIfExists()
return pkgName
}
if (!manifest.usesFeatures.any { it.name == EXTENSION_FEATURE }) {
throw Exception("This apk is not a Tachiyomi extension")
}
// Validate lib version
val libVersion = manifest.versionName!!.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
throw Exception(
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
)
}
if (tmpIcon.exists()) {
try {
cacheIcon(pkgName, tmpIcon.inputStream())
} catch (e: Exception) {
logger.warn(e) { "Failed to copy icon from JAR ${manifest.packageName}" }
}
}
tmpIcon.deleteIfExists()
tmpJarFile.copyTo(jarFile)
tmpJarFile.deleteExisting()
// TODO: allow trusting keys
// val signatureHash = getSignatureHash(packageInfo)
//
// if (signatureHash == null) {
// throw Exception("Package $pkgName isn't signed")
// } else if (signatureHash !in trustedSignatures) {
// throw Exception("This apk is not a signed with the official tachiyomi signature")
// }
fun List<AndroidManifestParser.MetaData>.getString(name: String): String? = this.find { it.name == name }?.value
var contentWarning =
manifest.application!!
.metaData
.getString(METADATA_CONTENT_WARNING)
?.toIntOrNull()
if (contentWarning == null) {
contentWarning = manifest.application.metaData
.getString(METADATA_NSFW)
?.toIntOrNull()
?: 0
}
val sourceClass =
manifest.application.metaData
.getString(METADATA_SOURCE_CLASS)!!
.trim()
val className =
if (sourceClass.startsWith(".")) {
pkgName + sourceClass
} else {
sourceClass
}
logger.debug { "Main class for extension is $className" }
try {
val extensionName =
manifest.application.metaData.getString(METADATA_NAME)
?: manifest.application.label!!
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
manifest.application.metaData
.getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" }
?: manifest.versionName!!.substringBeforeLast('.')
setupJar(
jarFile,
className,
extensionName,
extensionLibVersion,
jarFile.name.removeSuffix(".jar") + ".apk",
pkgName,
manifest.versionName!!,
manifest.versionCode!!,
contentWarning,
)
} catch (e: Throwable) {
// free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close()
jarFile.deleteIfExists()
try {
uninstallExtension(pkgName)
} catch (_: Exception) {
}
throw e
}
return pkgName
}
private fun setupJar(
jarFile: Path,
className: String,
extensionName: String,
extensionLibVersion: String,
apkName: String,
pkgName: String,
versionName: String,
versionCode: Int,
contentWarning: Int,
) {
// collect sources from the extension
val extensionMainClassInstance = loadExtensionSources(jarFile, className)
val sources: List<Source> =
when (extensionMainClassInstance) {
is Source -> listOf(extensionMainClassInstance)
is SourceFactory -> extensionMainClassInstance.createSources()
else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}")
}
val langs = sources.map { it.lang }.toSet()
val extensionLang =
when (langs.size) {
0 -> ""
1 -> langs.first()
else -> "all"
}
// update extension info
transaction {
if (ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
ExtensionTable.insert {
it[this.apkName] = apkName
it[name] = extensionName
it[this.pkgName] = pkgName
it[this.versionName] = versionName
it[this.versionCode] = versionCode.toLong()
it[extensionLib] = extensionLibVersion
it[lang] = extensionLang
it[this.contentWarning] = contentWarning
}
}
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[this.apkName] = apkName
it[this.isInstalled] = true
it[this.classFQName] = className
it[this.versionName] = versionName
it[this.versionCode] = versionCode.toLong()
}
val extensionId =
ExtensionTable
.selectAll()
.where { ExtensionTable.pkgName eq pkgName }
.first()[ExtensionTable.id]
.value
sources.forEach { httpSource ->
SourceTable.insert {
it[id] = httpSource.id
it[name] = httpSource.name
it[lang] = httpSource.lang
it[extension] = extensionId
it[this.contentWarning] = contentWarning
}
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
} }
} else {
return 302 // extension was already installed
} }
} }
private fun extractAndCacheApkIcon( private fun extractAndCacheApkIcon(
apkFilePath: String, apkFile: Path,
pkgName: String, pkgName: String,
) { ) {
val iconCacheDir = "${applicationDirs.extensionsRoot}/icon"
try { try {
val iconData = val iconData =
ApkFile(File(apkFilePath)).use { apk -> ApkFile(apkFile.toFile()).use { apk ->
apk.allIcons apk.allIcons
.filterIsInstance<Icon>() .filterIsInstance<Icon>()
.mapNotNull { it.data?.let { data -> data to it.density } } .mapNotNull { it.data?.let { data -> data to it.density } }
@@ -281,31 +500,48 @@ object Extension {
logger.warn { "No icon found in APK $pkgName" } logger.warn { "No icon found in APK $pkgName" }
return return
} }
cacheIcon(pkgName, iconData.inputStream())
File(iconCacheDir).mkdirs()
clearCachedImage(iconCacheDir, pkgName)
saveImage("$iconCacheDir/$pkgName", iconData.inputStream(), null)
} catch (e: Exception) { } catch (e: Exception) {
logger.warn(e) { "Failed to extract icon from APK $pkgName" } logger.warn(e) { "Failed to extract icon from APK $pkgName" }
} }
} }
private fun extractAssetsFromApk( private fun extractAndCacheJarIcon(
apkPath: String, zipFile: ZipFile,
jarPath: String, pkgName: String,
) { ) {
val apkFile = File(apkPath) try {
val jarFile = File(jarPath) val iconStream = ResourceArscIconParser.extractIcon(zipFile)
cacheIcon(pkgName, iconStream)
} catch (e: Exception) {
logger.warn(e) { "Failed to extract icon from JAR $pkgName" }
}
}
val assetsFolder = File("${apkFile.parent}/${apkFile.nameWithoutExtension}_assets") private fun cacheIcon(
assetsFolder.mkdir() pkgName: String,
inputStream: InputStream,
) {
val iconCacheDir = Path(applicationDirs.extensionsRoot) / "icon"
iconCacheDir.createDirectories()
clearCachedImage(iconCacheDir.absolutePathString(), pkgName)
saveImage("$iconCacheDir/$pkgName", inputStream, null)
}
@OptIn(ExperimentalPathApi::class)
private fun extractAssetsFromApk(
apkFile: Path,
jarFile: Path,
) {
val assetsFolder = apkFile.parent / "${apkFile.nameWithoutExtension}_assets"
assetsFolder.createDirectories()
ZipInputStream(apkFile.inputStream()).use { zipInputStream -> ZipInputStream(apkFile.inputStream()).use { zipInputStream ->
var zipEntry = zipInputStream.nextEntry var zipEntry = zipInputStream.nextEntry
while (zipEntry != null) { while (zipEntry != null) {
if (zipEntry.name.startsWith("assets/") && !zipEntry.isDirectory) { if (zipEntry.name.startsWith("assets/") && !zipEntry.isDirectory) {
val assetFile = File(assetsFolder, zipEntry.name) val assetFile = assetsFolder / zipEntry.name
assetFile.parentFile.mkdirs() assetFile.createParentDirectories()
FileOutputStream(assetFile).use { outputStream -> assetFile.outputStream().use { outputStream ->
zipInputStream.copyTo(outputStream) zipInputStream.copyTo(outputStream)
} }
} }
@@ -313,9 +549,9 @@ object Extension {
} }
} }
val tempJarFile = File("${jarFile.parent}/${jarFile.nameWithoutExtension}_temp.jar") val tempJarFile = jarFile.parent / "${jarFile.nameWithoutExtension}_temp.jar"
ZipInputStream(jarFile.inputStream()).use { jarZipInputStream -> ZipInputStream(jarFile.inputStream()).use { jarZipInputStream ->
ZipOutputStream(FileOutputStream(tempJarFile)).use { jarZipOutputStream -> ZipOutputStream(tempJarFile.outputStream()).use { jarZipOutputStream ->
var zipEntry = jarZipInputStream.nextEntry var zipEntry = jarZipInputStream.nextEntry
while (zipEntry != null) { while (zipEntry != null) {
if (!zipEntry.name.startsWith("META-INF/")) { if (!zipEntry.name.startsWith("META-INF/")) {
@@ -324,8 +560,8 @@ object Extension {
} }
zipEntry = jarZipInputStream.nextEntry zipEntry = jarZipInputStream.nextEntry
} }
assetsFolder.walkTopDown().forEach { file -> assetsFolder.walk().forEach { file ->
if (file.isFile) { if (file.isRegularFile()) {
jarZipOutputStream.putNextEntry(ZipEntry(file.relativeTo(assetsFolder).toString().replace("\\", "/"))) jarZipOutputStream.putNextEntry(ZipEntry(file.relativeTo(assetsFolder).toString().replace("\\", "/")))
file.inputStream().use { inputStream -> file.inputStream().use { inputStream ->
inputStream.copyTo(jarZipOutputStream) inputStream.copyTo(jarZipOutputStream)
@@ -336,17 +572,18 @@ object Extension {
} }
} }
jarFile.delete() jarFile.deleteIfExists()
tempJarFile.renameTo(jarFile) tempJarFile.copyTo(jarFile)
tempJarFile.deleteExisting()
assetsFolder.deleteRecursively() assetsFolder.deleteRecursively()
} }
private val network: NetworkHelper by injectLazy() private val network: NetworkHelper by injectLazy()
private suspend fun downloadAPKFile( private suspend fun downloadExtension(
url: String, url: String,
savePath: String, savePath: Path,
) { ) {
val response = val response =
network.client network.client
@@ -354,11 +591,10 @@ object Extension {
GET(url, cache = CacheControl.FORCE_NETWORK), GET(url, cache = CacheControl.FORCE_NETWORK),
).await() ).await()
val downloadedFile = File(savePath) savePath.createParentDirectories()
downloadedFile.sink().buffer().use { sink -> response.body.byteStream().use {
response.body.source().use { source -> savePath.outputStream().buffered().use { out ->
sink.writeAll(source) it.copyTo(out)
sink.flush()
} }
} }
} }
@@ -370,7 +606,7 @@ object Extension {
val fileNameWithoutType = val fileNameWithoutType =
extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk") extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk")
?: throw NullPointerException("Missing $pkgName apkName") ?: throw NullPointerException("Missing $pkgName apkName")
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar" val jarPath = Path(applicationDirs.extensionsRoot) / "$fileNameWithoutType.jar"
val sources = val sources =
transaction { transaction {
val extensionId = extensionRecord[ExtensionTable.id].value val extensionId = extensionRecord[ExtensionTable.id].value
@@ -392,18 +628,18 @@ object Extension {
sources sources
} }
if (File(jarPath).exists()) { if (jarPath.exists()) {
// free up the file descriptor if exists // free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarPath)?.close() PackageTools.jarLoaderMap.remove(jarPath.absolutePathString())?.close()
// clear all loaded sources // clear all loaded sources
sources.forEach { GetSource.unregisterSource(it) } sources.forEach { GetSource.unregisterSource(it) }
File(jarPath).delete() jarPath.deleteExisting()
} }
} }
suspend fun updateExtension(pkgName: String): Int { suspend fun updateExtension(pkgName: String): String {
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!! val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
uninstallExtension(pkgName) uninstallExtension(pkgName)
transaction { transaction {

View File

@@ -132,6 +132,8 @@ object ExtensionsList {
// Always update icon url and repo // Always update icon url and repo
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.jarUrl] = foundExtension.jarUrl
// add these because batch updates need matching columns // add these because batch updates need matching columns
this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate] this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate]
@@ -175,6 +177,7 @@ object ExtensionsList {
this[ExtensionTable.lang] = foundExtension.lang this[ExtensionTable.lang] = foundExtension.lang
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
this[ExtensionTable.apkUrl] = foundExtension.apkUrl this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.jarUrl] = foundExtension.jarUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
} }
}.toExecutable() }.toExecutable()
@@ -192,6 +195,7 @@ object ExtensionsList {
this[ExtensionTable.lang] = foundExtension.lang this[ExtensionTable.lang] = foundExtension.lang
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
this[ExtensionTable.apkUrl] = foundExtension.apkUrl this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.jarUrl] = foundExtension.jarUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
} }
} }

View File

@@ -53,6 +53,8 @@ data class NetworkExtensionStore(
data class Resources( data class Resources(
@ProtoNumber(1) val apkUrl: String, @ProtoNumber(1) val apkUrl: String,
@ProtoNumber(2) val iconUrl: String, @ProtoNumber(2) val iconUrl: String,
// Keiyoushi specific output
@ProtoNumber(501) val jarUrl: String? = null,
) )
@Serializable @Serializable
@@ -109,6 +111,7 @@ fun NetworkExtensionStore.ExtensionList.toExtensionInfos(store: ExtensionStore):
name = extension.name, name = extension.name,
pkgName = extension.packageName, pkgName = extension.packageName,
apkUrl = extension.resources.apkUrl, apkUrl = extension.resources.apkUrl,
jarUrl = extension.resources.jarUrl,
iconUrl = extension.resources.iconUrl, iconUrl = extension.resources.iconUrl,
extensionLib = extension.extensionLib, extensionLib = extension.extensionLib,
versionCode = extension.versionCode, versionCode = extension.versionCode,

View File

@@ -44,6 +44,7 @@ fun NetworkLegacyExtension.toExtensionInfo(
name = name.substringAfter("Tachiyomi: "), name = name.substringAfter("Tachiyomi: "),
pkgName = pkg, pkgName = pkg,
apkUrl = "$storeBaseUrl/apk/$apk", apkUrl = "$storeBaseUrl/apk/$apk",
jarUrl = null,
iconUrl = "$storeBaseUrl/icon/$pkg.png", iconUrl = "$storeBaseUrl/icon/$pkg.png",
extensionLib = version.substringBeforeLast('.'), extensionLib = version.substringBeforeLast('.'),
versionCode = code, versionCode = code,

View File

@@ -0,0 +1,148 @@
package suwayomi.tachidesk.manga.impl.util
import kotlinx.serialization.Serializable
import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.KtXmlReader
import nl.adaptivity.xmlutil.serialization.UnknownChildHandler
import nl.adaptivity.xmlutil.serialization.XML
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.serialization.XmlSerialName
import java.io.InputStream
object AndroidManifestParser {
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
@Serializable
@XmlSerialName("manifest", "", "")
data class AndroidManifest(
@XmlSerialName("package", "", "")
val packageName: String,
@XmlSerialName("versionCode", ANDROID_NS, "android")
val versionCode: Int? = null,
@XmlSerialName("versionName", ANDROID_NS, "android")
val versionName: String? = null,
@XmlElement(true)
@XmlSerialName("uses-sdk", "", "")
val usesSdk: UsesSdk? = null,
@XmlElement(true)
@XmlSerialName("uses-feature", "", "")
val usesFeatures: List<UsesFeature> = emptyList(),
@XmlElement(true)
@XmlSerialName("application", "", "")
val application: Application? = null,
)
@Serializable
@XmlSerialName("uses-sdk", "", "")
data class UsesSdk(
@XmlSerialName("minSdkVersion", ANDROID_NS, "android")
val minSdkVersion: Int? = null,
@XmlSerialName("targetSdkVersion", ANDROID_NS, "android")
val targetSdkVersion: Int? = null,
)
@Serializable
@XmlSerialName("uses-feature", "", "")
data class UsesFeature(
@XmlSerialName("name", ANDROID_NS, "android")
val name: String? = null,
)
@Serializable
@XmlSerialName("application", "", "")
data class Application(
@XmlSerialName("label", ANDROID_NS, "android")
val label: String? = null,
@XmlSerialName("icon", ANDROID_NS, "android")
val icon: String? = null,
@XmlSerialName("allowBackup", ANDROID_NS, "android")
val allowBackup: Boolean? = null,
@XmlSerialName("extractNativeLibs", ANDROID_NS, "android")
val extractNativeLibs: Boolean? = null,
@XmlElement(true)
@XmlSerialName("meta-data", "", "")
val metaData: List<MetaData> = emptyList(),
@XmlElement(true)
@XmlSerialName("activity", "", "")
val activities: List<Activity> = emptyList(),
)
@Serializable
@XmlSerialName("meta-data", "", "")
data class MetaData(
@XmlSerialName("name", ANDROID_NS, "android")
val name: String,
@XmlSerialName("value", ANDROID_NS, "android")
val value: String? = null,
@XmlSerialName("resource", ANDROID_NS, "android")
val resource: String? = null,
)
@Serializable
@XmlSerialName("activity", "", "")
data class Activity(
@XmlSerialName("name", ANDROID_NS, "android")
val name: String,
@XmlSerialName("exported", ANDROID_NS, "android")
val exported: Boolean? = null,
@XmlSerialName("theme", ANDROID_NS, "android")
val theme: String? = null,
@XmlElement(true)
@XmlSerialName("intent-filter", "", "")
val intentFilters: List<IntentFilter> = emptyList(),
)
@Serializable
@XmlSerialName("intent-filter", "", "")
data class IntentFilter(
@XmlElement(true)
@XmlSerialName("action", "", "")
val actions: List<Action> = emptyList(),
@XmlElement(true)
@XmlSerialName("category", "", "")
val categories: List<Category> = emptyList(),
@XmlElement(true)
@XmlSerialName("data", "", "")
val data: List<Data> = emptyList(),
)
@Serializable
@XmlSerialName("action", "", "")
data class Action(
@XmlSerialName("name", ANDROID_NS, "android")
val name: String,
)
@Serializable
@XmlSerialName("category", "", "")
data class Category(
@XmlSerialName("name", ANDROID_NS, "android")
val name: String,
)
@Serializable
@XmlSerialName("data", "", "")
data class Data(
@XmlSerialName("scheme", ANDROID_NS, "android")
val scheme: String? = null,
@XmlSerialName("host", ANDROID_NS, "android")
val host: String? = null,
@XmlSerialName("pathPattern", ANDROID_NS, "android")
val pathPattern: String? = null,
)
@OptIn(ExperimentalXmlUtilApi::class)
private val xml =
XML {
autoPolymorphic = false
repairNamespaces = true
xmlDeclMode = XmlDeclMode.Minimal
defaultPolicy {
unknownChildHandler = UnknownChildHandler { _, _, _, _, _ -> emptyList() }
}
}
@OptIn(ExperimentalXmlUtilApi::class)
fun parse(input: InputStream): AndroidManifest = xml.decodeFromReader(AndroidManifest.serializer(), KtXmlReader(input))
}

View File

@@ -23,13 +23,14 @@ import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
import xyz.nulldev.androidcompat.pm.toPackageInfo import xyz.nulldev.androidcompat.pm.toPackageInfo
import java.io.File
import java.net.URL import java.net.URL
import java.net.URLClassLoader import java.net.URLClassLoader
import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import javax.xml.parsers.DocumentBuilderFactory import javax.xml.parsers.DocumentBuilderFactory
import kotlin.io.path.Path import kotlin.io.path.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.nameWithoutExtension
import kotlin.io.path.readBytes
import kotlin.io.path.relativeTo import kotlin.io.path.relativeTo
object PackageTools { object PackageTools {
@@ -52,15 +53,13 @@ object PackageTools {
* Convert dex to jar, a wrapper for the dex2jar library * Convert dex to jar, a wrapper for the dex2jar library
*/ */
fun dex2jar( fun dex2jar(
dexFile: String, dexFile: Path,
jarFile: String, jarFile: Path,
fileNameWithoutType: String,
) { ) {
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine // adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java // source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
val jarFilePath = File(jarFile).toPath() val reader = MultiDexFileReader.open(dexFile.readBytes())
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
val handler = BaksmaliBaseDexExceptionHandler() val handler = BaksmaliBaseDexExceptionHandler()
Dex2jar Dex2jar
.from(reader) .from(reader)
@@ -73,10 +72,10 @@ object PackageTools {
.noCode(false) .noCode(false)
.skipExceptions(false) .skipExceptions(false)
.dontSanitizeNames(true) .dontSanitizeNames(true)
.to(jarFilePath) .to(jarFile)
if (handler.hasException()) { if (handler.hasException()) {
val rootPath = Path(applicationDirs.extensionsRoot) val rootPath = Path(applicationDirs.extensionsRoot)
val errorFile: Path = rootPath.resolve("$fileNameWithoutType-error.txt") val errorFile: Path = rootPath.resolve("${dexFile.nameWithoutExtension}-error.txt")
logger.error { logger.error {
""" """
Detail Error Information in File ${errorFile.relativeTo(rootPath)} Detail Error Information in File ${errorFile.relativeTo(rootPath)}
@@ -89,15 +88,14 @@ object PackageTools {
} }
handler.dump(errorFile, emptyArray<String>()) handler.dump(errorFile, emptyArray<String>())
} else { } else {
BytecodeEditor.fixAndroidClasses(jarFilePath) BytecodeEditor.fixAndroidClasses(jarFile)
} }
} }
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */ /** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
fun getPackageInfo(apkFilePath: String): PackageInfo { fun getPackageInfo(apkFile: Path): PackageInfo =
val apk = File(apkFilePath) ApkParsers.getMetaInfo(apkFile.toFile()).toPackageInfo(apkFile.toFile()).apply {
return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply { val parsed = ApkFile(apkFile.toFile())
val parsed = ApkFile(apk)
val dbFactory = DocumentBuilderFactory.newInstance() val dbFactory = DocumentBuilderFactory.newInstance()
val dBuilder = dbFactory.newDocumentBuilder() val dBuilder = dbFactory.newDocumentBuilder()
val doc = val doc =
@@ -138,7 +136,6 @@ object PackageTools {
.map { Signature(it.data) } .map { Signature(it.data) }
.toTypedArray() .toTypedArray()
} }
}
fun getSignatureHash(pkgInfo: PackageInfo): String? { fun getSignatureHash(pkgInfo: PackageInfo): String? {
val signatures = pkgInfo.signatures val signatures = pkgInfo.signatures
@@ -156,19 +153,19 @@ object PackageTools {
* It may return an instance of HttpSource or SourceFactory depending on the extension. * It may return an instance of HttpSource or SourceFactory depending on the extension.
*/ */
fun loadExtensionSources( fun loadExtensionSources(
jarPath: String, jar: Path,
className: String, className: String,
): Any { ): Any {
try { try {
logger.debug { "loading jar with path: $jarPath" } logger.debug { "loading jar with path: ${jar.absolutePathString()}" }
val classLoader = jarLoaderMap[jarPath] ?: ChildFirstURLClassLoader(arrayOf<URL>(Path(jarPath).toUri().toURL())) val classLoader = jarLoaderMap[jar.absolutePathString()] ?: ChildFirstURLClassLoader(arrayOf<URL>(jar.toUri().toURL()))
val classToLoad = Class.forName(className, false, classLoader) val classToLoad = Class.forName(className, false, classLoader)
jarLoaderMap[jarPath] = classLoader jarLoaderMap[jar.absolutePathString()] = classLoader
return classToLoad.getDeclaredConstructor().newInstance() return classToLoad.getDeclaredConstructor().newInstance()
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { "Failed to load jar with path: $jarPath" } logger.error(e) { "Failed to load jar with path: ${jar.absolutePathString()}" }
throw e throw e
} }
} }

View File

@@ -0,0 +1,71 @@
package suwayomi.tachidesk.manga.impl.util
import nl.adaptivity.xmlutil.core.impl.multiplatform.InputStream
import org.apache.commons.compress.archivers.zip.ZipFile
import pxb.android.arsc.ArscParser
import pxb.android.arsc.Config
import pxb.android.arsc.Pkg
import java.nio.ByteBuffer
import java.nio.ByteOrder
object ResourceArscIconParser {
private data class IconCandidate(
val density: Int,
val path: String,
)
fun extractIcon(zip: ZipFile): InputStream {
val packages =
zip
.getInputStream(zip.getEntry("resources.arsc"))
.use { ArscParser(it.readBytes()).parse() }
val icon =
packages
.flatMap { it.iconCandidates() }
.maxByOrNull { it.density }
?: throw NullPointerException("No valid icons")
val entry =
zip.getEntry(icon.path)
?: throw NullPointerException("Icon ${icon.path} missing")
return zip.getInputStream(entry)
}
private fun Pkg.iconCandidates(): List<IconCandidate> =
types.values
.filter { it.name == "mipmap" || it.name == "drawable" }
.flatMap {
it.configs.flatMap {
val density = it.density()
it.resources.values
.asSequence()
.filter { it.spec.name == "ic_launcher" }
.map { it.value.toString() }
.filter(::isRasterImage)
.map { IconCandidate(density, it) }
}
}
private fun Config.density(): Int =
ByteBuffer
.wrap(id)
.order(ByteOrder.LITTLE_ENDIAN)
.getShort(14)
.toInt() and 0xffff
private val rasterExtensions =
setOf(
"png",
"webp",
"jpg",
"jpeg",
)
private fun isRasterImage(path: String): Boolean =
path
.substringAfterLast('.', "")
.lowercase() in rasterExtensions
}

View File

@@ -20,6 +20,8 @@ import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.io.path.Path
import kotlin.io.path.div
object GetSource { object GetSource {
private val logger = KotlinLogging.logger { } private val logger = KotlinLogging.logger { }
@@ -49,7 +51,7 @@ object GetSource {
?: throw NullPointerException("Missing apkName") ?: throw NullPointerException("Missing apkName")
val className = extensionRecord[ExtensionTable.classFQName] val className = extensionRecord[ExtensionTable.classFQName]
val jarName = apkName.substringBefore(".apk") + ".jar" val jarName = apkName.substringBefore(".apk") + ".jar"
val jarPath = "${applicationDirs.extensionsRoot}/$jarName" val jarPath = Path(applicationDirs.extensionsRoot) / jarName
when (val instance = loadExtensionSources(jarPath, className)) { when (val instance = loadExtensionSources(jarPath, className)) {
is Source -> listOf(instance) is Source -> listOf(instance)

View File

@@ -12,6 +12,7 @@ data class ExtensionInfo(
val name: String, val name: String,
val pkgName: String, val pkgName: String,
val apkUrl: String, val apkUrl: String,
val jarUrl: String?,
val iconUrl: String, val iconUrl: String,
val extensionLib: String, val extensionLib: String,
val versionCode: Long, val versionCode: Long,

View File

@@ -24,6 +24,7 @@ object ExtensionTable : IntIdTable() {
val name = varchar("name", 128) val name = varchar("name", 128)
val pkgName = varchar("pkg_name", 128) val pkgName = varchar("pkg_name", 128)
val apkUrl = varchar("apk_url", 2048).nullable() val apkUrl = varchar("apk_url", 2048).nullable()
val jarUrl = varchar("jar_url", 2048).nullable()
val extensionLib = varchar("extension_lib", 16).nullable() val extensionLib = varchar("extension_lib", 16).nullable()
val versionName = varchar("version_name", 16) val versionName = varchar("version_name", 16)
val versionCode = long("version_code") val versionCode = long("version_code")

View File

@@ -130,9 +130,12 @@ object H2Migration {
) )
} }
output.outputStream().use { out -> val tmp = output.resolveSibling(output.name + ".tmp")
tmp.outputStream().use { out ->
response.body.byteStream().copyTo(out) response.body.byteStream().copyTo(out)
} }
tmp.copyTo(output)
tmp.deleteExisting()
} }
logger.info { "Saved: ${output.absolutePathString()}" } logger.info { "Saved: ${output.absolutePathString()}" }

View File

@@ -0,0 +1,19 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.AddColumnMigration
@Suppress("ClassName", "unused")
class M0059_AddJarUrlToExtensionTable :
AddColumnMigration(
"extension",
"jar_url",
"VARCHAR(2048)",
"NULL",
)