diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml new file mode 100644 index 00000000..0ce12c27 --- /dev/null +++ b/.github/workflows/spotube-publish-binary.yml @@ -0,0 +1,274 @@ +name: Publish + +# Publishes an existing GitHub release (made by spotube-release-binary.yml) to the package managers. +# The `render` job checks out the release tag, downloads its assets and renders packaging/ with +# Gradle (:composeApp:renderPackaging: version, checksums, git-cliff release notes). The publish +# jobs only push what it rendered. +# +# Secrets: +# FLATHUB_TOKEN PAT with push access to team-spotube/org.spotube.spotube +# AUR_USERNAME, AUR_EMAIL, AUR_SSH_PRIVATE_KEY AUR account that maintains spotube-bin +# WINGET_TOKEN classic PAT (public_repo) that can push to team-spotube/winget-pkgs and open PRs +# CHOCO_API_KEY chocolatey.org API key + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag to publish (vX.Y.Z) + required: true + type: string + jobs: + description: Jobs to run (flathub,aur,winget,chocolatey) + required: true + type: string + default: flathub,aur,winget,chocolatey + aur_pkgrel: + description: AUR pkgrel (bump to re-release the same version) + required: true + type: string + default: '1' + dry_run: + description: Dry run (render the packages, push nothing) + required: true + type: boolean + default: true + +permissions: + contents: read + +env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + +jobs: + render: + name: Render packages + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Validate tag and find release + id: version + env: + DRY_RUN: ${{ inputs.dry_run }} + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::tag must look like vX.Y.Z, got '$TAG'" + exit 1 + fi + is_draft=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft) + # The packages download from the public release URLs, which a draft doesn't have yet + if [ "$is_draft" = "true" ] && [ "$DRY_RUN" != "true" ]; then + echo "::error::release $TAG is still a draft; publish it first" + exit 1 + fi + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + # Full history: git-cliff builds the release notes from it + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + # Configuring :composeApp runs cargo (Gobley) + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Install git-cliff + uses: taiki-e/install-action@v2 + with: + tool: git-cliff + + - name: Download release assets + run: | + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir release-assets \ + --pattern 'spotube-linux-*.tar.xz' --pattern 'Spotube-windows-x86_64-setup.exe' + + - name: Render packages + env: + PKGREL: ${{ inputs.aur_pkgrel }} + run: | + chmod +x gradlew + ./gradlew :composeApp:renderPackagingFlatpak :composeApp:renderPackagingAur \ + :composeApp:renderPackagingWinget :composeApp:renderPackagingChocolatey \ + -Pnightly=false -Ppkgrel="$PKGREL" -PpackagingArtifactsDir=release-assets + for f in composeApp/build/packaging/{flatpak/*.yml,aur/PKGBUILD,winget/*.yaml,chocolatey/*.nuspec}; do + echo "::group::$f" + cat "$f" + echo "::endgroup::" + done + + - name: Upload rendered packages + uses: actions/upload-artifact@v7 + with: + name: packaging + path: | + composeApp/build/packaging/flatpak + composeApp/build/packaging/aur + composeApp/build/packaging/winget + composeApp/build/packaging/chocolatey + + # Chocolatey embeds the installer in the package + - name: Upload Windows installer + if: contains(inputs.jobs, 'chocolatey') + uses: actions/upload-artifact@v7 + with: + name: windows-installer + path: release-assets/Spotube-windows-x86_64-setup.exe + + flathub: + name: Flathub + needs: render + if: contains(inputs.jobs, 'flathub') + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.render.outputs.version }} + FLATHUB_REPO: team-spotube/org.spotube.spotube + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - uses: actions/checkout@v7 + with: + repository: ${{ env.FLATHUB_REPO }} + token: ${{ secrets.FLATHUB_TOKEN }} + path: flathub + + - name: Commit + working-directory: flathub + run: | + cp ../packaging/flatpak/org.spotube.spotube.yml . + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "update-$VERSION" + git add org.spotube.spotube.yml + git diff --cached + git commit -m "Update to $VERSION" + + - name: Push and open PR + if: ${{ !inputs.dry_run }} + working-directory: flathub + env: + GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }} + run: | + git push -u origin "update-$VERSION" + gh pr create --repo "$FLATHUB_REPO" --head "update-$VERSION" \ + --title "Update to $VERSION" \ + --body "Release notes: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG" + + aur: + name: AUR + needs: render + if: contains(inputs.jobs, 'aur') + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - name: Publish to AUR + if: ${{ !inputs.dry_run }} + uses: KSXGitHub/github-actions-deploy-aur@v4.2.0 + with: + pkgname: spotube-bin + pkgbuild: packaging/aur/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: Update to ${{ needs.render.outputs.version }}-${{ inputs.aur_pkgrel }} + + winget: + name: WinGet + needs: render + if: contains(inputs.jobs, 'winget') + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.render.outputs.version }} + FORK: team-spotube/winget-pkgs + MANIFEST_DIR: manifests/k/KRTirtho/Spotube + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - name: Sync fork with upstream + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: gh repo sync "$FORK" --source microsoft/winget-pkgs --branch master + + # winget-pkgs is huge: only fetch this package's directory + - uses: actions/checkout@v7 + if: ${{ !inputs.dry_run }} + with: + repository: ${{ env.FORK }} + ref: master + token: ${{ secrets.WINGET_TOKEN }} + path: winget-pkgs + sparse-checkout: ${{ env.MANIFEST_DIR }} + + - name: Push and open PR + if: ${{ !inputs.dry_run }} + working-directory: winget-pkgs + env: + GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + branch="KRTirtho.Spotube-$VERSION" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + mkdir -p "$MANIFEST_DIR/$VERSION" + cp ../packaging/winget/*.yaml "$MANIFEST_DIR/$VERSION/" + git add "$MANIFEST_DIR/$VERSION" + git commit -m "New version: KRTirtho.Spotube version $VERSION" + git push -u origin "$branch" + gh pr create --repo microsoft/winget-pkgs --base master --head "${FORK%%/*}:$branch" \ + --title "New version: KRTirtho.Spotube version $VERSION" \ + --body "Release notes: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG" + + chocolatey: + name: Chocolatey + needs: render + if: contains(inputs.jobs, 'chocolatey') + runs-on: windows-latest + env: + VERSION: ${{ needs.render.outputs.version }} + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - uses: actions/download-artifact@v8 + with: + name: windows-installer + path: packaging/chocolatey/tools + + - name: Pack + run: choco pack packaging/chocolatey/spotube.nuspec --outputdirectory dist + + - name: Upload package + uses: actions/upload-artifact@v7 + with: + name: chocolatey-nupkg + path: dist/*.nupkg + + - name: Publish to Chocolatey + if: ${{ !inputs.dry_run }} + env: + CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} + run: choco push "dist/spotube.$env:VERSION.nupkg" --source https://push.chocolatey.org/ --api-key "$env:CHOCO_API_KEY" diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index fdb64445..c3ddf0f8 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -155,7 +155,10 @@ jobs: timeout-minutes: 120 runs-on: ubuntu-latest steps: + # Full history: the metainfo's release notes come from git-cliff - uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Download maven local uses: actions/download-artifact@v8 @@ -196,6 +199,30 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + # Portable tarball the AUR and Flathub packages install from. Like deb/rpm it uses the system's VLC, + # GTK and WebKitGTK, so it is packed from the distributable before the AppImage build replaces it + - name: Install git-cliff + uses: taiki-e/install-action@v2 + with: + tool: git-cliff + + - name: Package Linux tarball + run: | + if [ "${{ inputs.release_type }}" = "nightly" ]; then + VERSION=nightly + else + VERSION=$(./gradlew -q :composeApp:printVersion) + fi + # Desktop entry + metainfo, with the release history from git-cliff + ./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type }}" . + + - name: Upload tarball + uses: actions/upload-artifact@v7 + with: + name: linux-tarball + path: spotube-linux-*-x86_64.tar.xz + # Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages - name: Package Linux AppImage env: @@ -241,7 +268,10 @@ jobs: timeout-minutes: 120 runs-on: ubuntu-24.04-arm steps: + # Full history: the metainfo's release notes come from git-cliff - uses: actions/checkout@v7 + with: + fetch-depth: 0 # The arm64 runner image ships without an Android SDK/NDK, but :composeApp configuration needs both # (Gobley's cargo plugin resolves the NDK directory for the Android Rust targets) @@ -289,6 +319,30 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + # Portable tarball the AUR and Flathub packages install from. Like deb/rpm it uses the system's VLC, + # GTK and WebKitGTK, so it is packed from the distributable before the AppImage build replaces it + - name: Install git-cliff + uses: taiki-e/install-action@v2 + with: + tool: git-cliff + + - name: Package Linux tarball + run: | + if [ "${{ inputs.release_type }}" = "nightly" ]; then + VERSION=nightly + else + VERSION=$(./gradlew -q :composeApp:printVersion) + fi + # Desktop entry + metainfo, with the release history from git-cliff + ./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} + packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type }}" . + + - name: Upload tarball + uses: actions/upload-artifact@v7 + with: + name: linux-tarball-arm64 + path: spotube-linux-*-aarch64.tar.xz + # Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages - name: Package Linux AppImage env: @@ -507,7 +561,7 @@ jobs: run: | echo "## Downloads" > release-notes.md echo "" >> release-notes.md - for dir in android-apk linux-deb linux-rpm linux-appimage linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 windows-exe macos-dmg; do + for dir in android-apk linux-deb linux-rpm linux-appimage linux-tarball linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 linux-tarball-arm64 windows-exe macos-dmg; do if [ -d "$dir" ]; then echo "### $dir" >> release-notes.md for f in "$dir"/*; do @@ -540,8 +594,10 @@ jobs: linux-deb/*.deb linux-rpm/*.rpm linux-appimage/*.AppImage + linux-tarball/*.tar.xz linux-deb-arm64/*.deb linux-rpm-arm64/*.rpm linux-appimage-arm64/*.AppImage + linux-tarball-arm64/*.tar.xz windows-exe/*.exe macos-dmg/*.dmg diff --git a/AGENTS.md b/AGENTS.md index 6fdadf2b..816d9590 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,12 @@ - **Android signing**: decodes `secrets.KEYSTORE` (base64) → `composeApp/upload-keystore.jks`, writes signing config into `local.properties` from secrets. - `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached. +## Package managers (`packaging/`, `.github/workflows/spotube-publish-binary.yml`) +- `packaging/` (`linux`, `aur`, `flatpak`, `winget`, `chocolatey`) holds JMustache templates (`*.mustache`) plus files copied as is. `:composeApp:renderPackaging` renders them into `composeApp/build/packaging/` (`RenderPackagingTask` in buildSrc documents the template data). Never hand-write versions, checksums or release notes into these files. +- Checksums come from release assets in `-PpackagingArtifactsDir=`; a template using a missing value fails the render. Release notes come from `git-cliff --context` with `cliff.toml` (`GitCliffContextTask`): needs `git-cliff` on the PATH and full git history. `gradle.properties` defaults to `nightly=true`, which leaves commits unreleased: pass `-Pnightly=false` for a stable render. +- `packaging/package-linux-tarball.sh` packs `spotube-linux--.tar.xz` (the AUR and Flathub source) from the GraalVM distributable and the rendered `linux` dir; the release workflow builds it before the AppImage step. +- The publish workflow (`workflow_dispatch`, input `tag`) renders once, then pushes to Flathub (`team-spotube/org.spotube.spotube`), the AUR (`spotube-bin`), WinGet (PR from `team-spotube/winget-pkgs`) and Chocolatey. + ## OpenCode Agent MCP Execution Directives ### 1. Code Discovery & Context Search diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 28845360..0479ace4 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,5 +1,6 @@ -// Build logic for the Linux AppImage bundle (see DownloadLinuxGtkBundleTask). Lives in buildSrc so the IDE -// resolves it; an `apply(from = ...)` script plugin gets no Kotlin classpath in Android Studio. +// Build logic: the Linux AppImage bundle (DownloadLinuxGtkBundleTask) and the package manager manifests +// (GitCliffContextTask, RenderPackagingTask). Lives in buildSrc so the IDE resolves it; an +// `apply(from = ...)` script plugin gets no Kotlin classpath in Android Studio. plugins { `kotlin-dsl` } @@ -7,3 +8,7 @@ plugins { repositories { mavenCentral() } + +dependencies { + implementation(libs.jmustache) +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 00000000..ac722dd3 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,8 @@ +// Shares the root version catalog (gradle/libs.versions.toml) with the build logic +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt new file mode 100644 index 00000000..88d13ed0 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Writes git-cliff's changelog context (every release with its parsed, grouped commits) as JSON, using +// the repository's cliff.toml. RenderPackagingTask turns it into release notes for the package manifests. +// Needs `git-cliff` on the PATH and the full git history (CI: `fetch-depth: 0`). + +package org.spotube.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +import javax.inject.Inject + +@DisableCachingByDefault(because = "Reads the git history, which is not a declared input") +abstract class GitCliffContextTask @Inject constructor(private val execOperations: ExecOperations) : DefaultTask() { + @get:Input + abstract val executable: Property + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val configFile: RegularFileProperty + + @get:Internal + abstract val repositoryDir: DirectoryProperty + + /** Tag for the commits since the last release (`--tag`); unset leaves them unreleased (nightlies). */ + @get:Input + @get:Optional + abstract val tag: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + init { + group = "distribution" + description = "Writes the git-cliff changelog context (releases and their commits) as JSON." + executable.convention("git-cliff") + // New commits and tags change the result without touching any declared input + outputs.upToDateWhen { false } + } + + @TaskAction + fun run() { + val output = outputFile.get().asFile + output.parentFile.mkdirs() + val args = buildList { + add(executable.get()) + addAll(listOf("--config", configFile.get().asFile.absolutePath, "--context")) + tag.orNull?.let { addAll(listOf("--tag", it)) } + addAll(listOf("--output", output.absolutePath)) + } + try { + execOperations.exec { + workingDir = repositoryDir.get().asFile + commandLine(args) + } + } catch (e: Exception) { + throw GradleException( + "Running git-cliff failed (${args.joinToString(" ")}). Install it from https://git-cliff.org " + + "and make sure the full git history is available.", + e, + ) + } + } +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt new file mode 100644 index 00000000..27b57fa3 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Renders one package manager's directory (packaging/) into the build dir: `*.mustache` files are +// rendered with JMustache (suffix dropped), everything else is copied as is. +// +// Template data: +// version, tag, pkgrel, repositoryUrl +// artifacts..{fileName, url, sha256, sha256Upper} release assets found in artifactsDir +// release.{version, date, url, groups, notes} the release being packaged +// releases tagged releases, newest first (metainfo) +// groups[].{title, entries[].text} changelog grouped like cliff.toml +// #indent2 / #indent4 indent a multi-line block (YAML block scalars) +// +// Missing variables fail the render, so a manifest never ships with an empty checksum or version. +// HTML/XML escaping is on for .xml and .nuspec output only. + +package org.spotube.gradle + +import com.samskivert.mustache.Mustache +import com.samskivert.mustache.MustacheException +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.security.MessageDigest +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +@CacheableTask +abstract class RenderPackagingTask : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val templateDir: DirectoryProperty + + @get:Input + abstract val version: Property + + @get:Input + abstract val pkgrel: Property + + @get:Input + abstract val repositoryUrl: Property + + /** Release assets by template key, e.g. `linuxX86_64` -> `spotube-linux-6.0.0-x86_64.tar.xz`. */ + @get:Input + abstract val artifactNames: MapProperty + + /** Directory holding the downloaded release assets; only needed by templates that use `artifacts`. */ + @get:Internal + abstract val artifactsDir: DirectoryProperty + + /** The release assets that exist in [artifactsDir]; their checksums are part of the output. */ + @get:Input + protected val artifactChecksums: Map + get() = artifactFiles().mapValues { (_, file) -> sha256(file) } + + /** git-cliff `--context` output (GitCliffContextTask). */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val changelogContext: RegularFileProperty + + /** How many tagged releases `releases` lists. */ + @get:Input + abstract val maxReleases: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + init { + group = "distribution" + pkgrel.convention("1") + maxReleases.convention(10) + } + + @TaskAction + fun render() { + val source = templateDir.get().asFile + val target = outputDir.get().asFile + target.deleteRecursively() + target.mkdirs() + + val data = templateData() + source.walkTopDown().filter { it.isFile }.forEach { file -> + val relative = file.relativeTo(source).path + if (!relative.endsWith(TEMPLATE_SUFFIX)) { + file.copyTo(target.resolve(relative), overwrite = true) + return@forEach + } + val output = target.resolve(relative.removeSuffix(TEMPLATE_SUFFIX)) + val compiler = Mustache.compiler() + .escapeHTML(output.extension in XML_EXTENSIONS) + .emptyStringIsFalse(true) + .zeroIsFalse(true) + val rendered = try { + compiler.compile(file.readText()).execute(data) + } catch (e: MustacheException) { + throw GradleException( + "Rendering $relative failed: ${e.message?.trimEnd('.')}. Templates that use `artifacts` need " + + "-PpackagingArtifactsDir=.", + e, + ) + } + output.parentFile.mkdirs() + output.writeText(rendered) + } + } + + private fun templateData(): Map { + val version = version.get() + val tag = "v$version" + val repositoryUrl = repositoryUrl.get() + val downloadUrl = "$repositoryUrl/releases/download/$tag" + + val artifacts = artifactFiles().mapValues { (_, file) -> + val sha256 = sha256(file) + mapOf( + "fileName" to file.name, + "url" to "$downloadUrl/${file.name}", + "sha256" to sha256, + "sha256Upper" to sha256.uppercase(), + ) + } + + val changelog = changelogContext.orNull?.asFile?.let(::readChangelog).orEmpty() + val tagged = changelog.filter { it.version != null }.sortedByDescending { it.timestamp } + // The release being packaged; nightlies have no tag, so they get the unreleased commits + val current = changelog.firstOrNull { it.version == tag } + ?: changelog.firstOrNull { it.version == null } + ?: ChangelogRelease(tag, Instant.now().epochSecond, emptyList()) + + return mapOf( + "version" to version, + "tag" to tag, + "pkgrel" to pkgrel.get(), + "repositoryUrl" to repositoryUrl, + "artifacts" to artifacts, + "release" to releaseData(current, version, repositoryUrl), + "releases" to tagged.take(maxReleases.get()).map { + releaseData(it, it.version!!.removePrefix("v"), repositoryUrl) + }, + "indent2" to indent(2), + "indent4" to indent(4), + ) + } + + private fun artifactFiles(): Map { + val dir = artifactsDir.orNull?.asFile ?: return emptyMap() + return artifactNames.get() + .mapValues { (_, name) -> dir.resolve(name) } + .filterValues { it.isFile } + } + + private fun releaseData(release: ChangelogRelease, version: String, repositoryUrl: String): Map { + val groups = release.commits + .groupBy { it.group } + .toSortedMap() + .map { (group, commits) -> + mapOf( + // cliff.toml orders groups with an HTML comment prefix: "Features" + "title" to group.replace(HTML_COMMENT, "").trim(), + "entries" to commits.map { mapOf("text" to it.text) }, + ) + } + val notes = groups.joinToString("\n\n") { group -> + @Suppress("UNCHECKED_CAST") + val entries = group["entries"] as List> + "${group["title"]}\n" + entries.joinToString("\n") { "- ${it["text"]}" } + } + return mapOf( + "version" to version, + "date" to LocalDate.ofInstant(Instant.ofEpochSecond(release.timestamp), ZoneOffset.UTC).toString(), + "url" to "$repositoryUrl/releases/tag/v$version", + "groups" to groups, + "notes" to notes, + ) + } + + @Suppress("UNCHECKED_CAST") + private fun readChangelog(file: File): List { + val releases = JsonSlurper().parse(file) as List> + return releases.map { release -> + val commits = (release["commits"] as List>).mapNotNull { commit -> + val group = commit["group"] as String? ?: return@mapNotNull null + val message = (commit["message"] as String).plainText().replaceFirstChar { it.uppercase() } + val scope = commit["scope"] as String? + val breaking = commit["breaking"] == true + val text = buildString { + if (breaking) append("Breaking: ") + if (scope != null) append("$scope: ") + append(message) + } + ChangelogCommit(group, text) + } + ChangelogRelease( + version = release["version"] as String?, + timestamp = (release["timestamp"] as Number?)?.toLong() ?: Instant.now().epochSecond, + commits = commits, + ) + } + } + + private data class ChangelogRelease(val version: String?, val timestamp: Long, val commits: List) + + private data class ChangelogCommit(val group: String, val text: String) + + private companion object { + const val TEMPLATE_SUFFIX = ".mustache" + val XML_EXTENSIONS = setOf("xml", "nuspec") + val HTML_COMMENT = Regex("") + val MARKDOWN_LINK = Regex("""\[([^\]]+)]\([^)]+\)""") + + // cliff.toml's commit preprocessors turn issue numbers into Markdown links + fun String.plainText() = replace(MARKDOWN_LINK, "$1").replace("**", "").trim() + + fun indent(spaces: Int) = Mustache.Lambda { fragment, out -> + val prefix = " ".repeat(spaces) + out.write(fragment.execute().lines().joinToString("\n") { if (it.isEmpty()) it else prefix + it }) + } + + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + } +} diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 70c93430..fb121190 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -25,6 +25,8 @@ import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.gradle.api.tasks.Copy import org.spotube.gradle.DownloadLinuxGtkBundleTask +import org.spotube.gradle.GitCliffContextTask +import org.spotube.gradle.RenderPackagingTask import java.io.FileInputStream import java.util.Properties @@ -76,6 +78,37 @@ tasks.register("printVersion") { val appVersion = appVersionName doLast { println(appVersion) } } + +// Package manager manifests: packaging/ rendered into build/packaging/ with the version, the +// release assets' checksums (-PpackagingArtifactsDir=) and the git-cliff changelog. See RenderPackagingTask. +val gitCliffContext = tasks.register("gitCliffContext") { + configFile.set(rootProject.layout.projectDirectory.file("cliff.toml")) + repositoryDir.set(rootProject.layout.projectDirectory) + // Nightlies are not a release: their commits stay unreleased + if (!isNightly) tag.set("v$appVersionName") + outputFile.set(layout.buildDirectory.file("packaging/changelog-context.json")) +} +val packagingRootDir = rootProject.layout.projectDirectory +val packagingArtifactsDir = packagingRootDir.dir(providers.gradleProperty("packagingArtifactsDir")) +listOf("linux", "aur", "flatpak", "winget", "chocolatey").forEach { target -> + tasks.register("renderPackaging${target.replaceFirstChar { it.uppercase() }}") { + description = "Renders packaging/$target into build/packaging/$target." + templateDir.set(packagingRootDir.dir("packaging/$target")) + version.set(appVersionName) + pkgrel.set(providers.gradleProperty("pkgrel").orElse("1")) + repositoryUrl.set(appRepositoryUrl) + artifactNames.set( + mapOf( + "linuxX86_64" to "spotube-linux-$appVersionName-x86_64.tar.xz", + "linuxAarch64" to "spotube-linux-$appVersionName-aarch64.tar.xz", + "windowsX64" to "Spotube-windows-x86_64-setup.exe", + ) + ) + artifactsDir.set(packagingArtifactsDir) + changelogContext.set(gitCliffContext.flatMap { it.outputFile }) + outputDir.set(layout.buildDirectory.dir("packaging/$target")) + } +} val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/") val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest" val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin") diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt index 9655a6d3..37a15a6f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt @@ -262,10 +262,7 @@ fun App( update = update, onDismiss = pluginUpdateService::dismissUpdate, onIgnore = pluginUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser(update.info.directDownloadUrl) } - pluginUpdateService.dismissUpdate() - }) + onUpdate = pluginUpdateService::installUpdate) } SupportDialog( visible = showSupportDialog, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 6b2ed87a..13d1f380 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -116,6 +116,9 @@ class PluginManager( val paths: Paths, private val httpClientFactory: HttpClientFactory) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() + + // plugin.json may carry fields newer than this app knows about (e.g. `entryPoint`). + private val pluginJsonParser = Json { ignoreUnknownKeys = true } private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } } @@ -325,6 +328,15 @@ class PluginManager( } } + /** Downloads and installs a newer plugin build over the installed one, without prompting. */ + suspend fun updatePluginFromURL(url: String) { + withContext(Dispatchers.IO) { + val response = httpClient.get(url) + val bytes = response.bodyAsChannel().readRemaining().readByteArray() + addPluginFromByteArray(bytes = bytes, allowReplacingInstalled = true) + } + } + /** Parses the zip, reads plugin.json, then surfaces a PendingPlugin for the UI to confirm. */ suspend fun preparePlugin(bytes: ByteArray) { withContext(Dispatchers.IO) { @@ -363,7 +375,7 @@ class PluginManager( val pluginJson = FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { it.readUtf8() } val pluginEntry = try { - Json.decodeFromString(pluginJson) + pluginJsonParser.decodeFromString(pluginJson) } catch (e: Exception) { throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) } @@ -485,7 +497,7 @@ class PluginManager( } val pluginEntry = try { - Json.decodeFromString(pluginJson) + pluginJsonParser.decodeFromString(pluginJson) } catch (e: Exception) { throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt index 2048248f..050dd859 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt @@ -18,9 +18,18 @@ package org.spotube.spotube.modules.plugin import com.goncalossilva.murmurhash.MurmurHash3 +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +@Serializable enum class PluginCapability { @SerialName("persistent_storage") PERSISTENT_STORAGE, @@ -43,6 +52,38 @@ enum class PluginAbility { SCROBBLE, } +/** + * Decodes a list of enums while dropping values this app version doesn't know about, so a + * plugin.json written for another plugin API revision still parses. + */ +abstract class LenientEnumListSerializer>( + private val elementSerializer: KSerializer) : KSerializer> { + private val listSerializer = ListSerializer(elementSerializer) + override val descriptor: SerialDescriptor = listSerializer.descriptor + + override fun serialize(encoder: Encoder, value: List) = + listSerializer.serialize(encoder, value) + + override fun deserialize(decoder: Decoder): List { + val jsonDecoder = decoder as? JsonDecoder ?: return listSerializer.deserialize(decoder) + val array = jsonDecoder.decodeJsonElement() as? JsonArray + ?: throw SerializationException("Expected a JSON array") + return array.mapNotNull { element -> + try { + jsonDecoder.json.decodeFromJsonElement(elementSerializer, element) + } catch (_: SerializationException) { + null + } + } + } +} + +object LenientPluginCapabilityListSerializer : + LenientEnumListSerializer(PluginCapability.serializer()) + +object LenientPluginAbilityListSerializer : + LenientEnumListSerializer(PluginAbility.serializer()) + @Serializable data class PluginEntry( val name: String, @@ -50,7 +91,9 @@ data class PluginEntry( val apiVersion: String, val description: String, val author: String, + @Serializable(with = LenientPluginCapabilityListSerializer::class) val capabilities: List, + @Serializable(with = LenientPluginAbilityListSerializer::class) val abilities: List, val contact: String, val repository: String, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt index dc9d3388..dbfd236f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt @@ -20,7 +20,10 @@ package org.spotube.spotube.modules.update import org.spotube.millennium.composables.MillenniumAlertDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.text.selection.SelectionContainer import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text @@ -40,7 +43,7 @@ import spotube.composeapp.generated.resources.update_action_update /** * Prompts the user that a newer version of one of their installed plugins is - * available. [onUpdate] should open the plugin's direct download URL; [onIgnore] + * available. [onUpdate] should download and install the update; [onIgnore] * persistently silences this specific plugin version so the check moves on to the * next newer one (or another plugin). */ @@ -53,6 +56,7 @@ fun PluginUpdateDialog( MillenniumAlertDialog( visible = true, onDismissRequest = onDismiss, + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing), title = { Text( stringResource( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt index 0b941cde..6c09dbeb 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt @@ -124,6 +124,18 @@ class PluginUpdateService( } } + fun installUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + scope.launch { + runCatching { + pluginManager.updatePluginFromURL(update.info.directDownloadUrl) + }.onFailure { e -> + logger.e(e) { "Failed to install update for plugin ${update.pluginName}" } + } + } + } + fun dismissUpdate() { _availableUpdate.value = null } diff --git a/gradle.properties b/gradle.properties index dc5eecc2..a49b0977 100644 --- a/gradle.properties +++ b/gradle.properties @@ -34,7 +34,4 @@ android.builtInKotlin=false android.newDsl=false android.sourceset.disallowProvider=false -org.gradle.vfs.watch=true - -nightly=true -buildNumber=999 \ No newline at end of file +org.gradle.vfs.watch=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30876263..30e39bf7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ appdirs = "1.5.0" cache4k = "0.14.0" carApp = "1.7.0" composeHotReload = "1.2.0" -composeMultiplatform = "1.12.2" +composeMultiplatform = "1.11.1" coil = "3.5.0" composeShimmer = "1.5.0" desugar_jdk_libs = "2.1.5" @@ -37,7 +37,7 @@ jna = "5.18.1" junit = "4.13.2" kermit = "2.1.0" kmpZip = "1.0.0" -kotlin = "2.4.21" +kotlin = "2.3.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" kotlinx-serialization-json = "1.11.0" @@ -52,7 +52,7 @@ nucleusNucleusApplication = "2.5.18" semver = "2.1.0" vlcj = "4.12.1" vlcjNative = "4.12.0" -ziplineVersion = "1.28.0" +ziplineVersion = "1.27.0" ktor = "3.5.1" dnssdkt = "1.1.0" runner = "1.7.0" @@ -68,7 +68,7 @@ feather-icons = "1.1.1" kotlinx-datetime = "0.8.0" media3 = "1.10.1" androidx-datastore = "1.2.1" -ksp = "2.3.12" +ksp = "2.3.5" spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" @@ -76,7 +76,7 @@ vlcjBundler = "0.2.0" vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" kotlinx-atomicfu = "0.33.0" -mokkery = "3.5.1" +mokkery = "3.3.0" [libraries] appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO deleted file mode 100644 index f6d76556..00000000 --- a/packaging/aur/.SRCINFO +++ /dev/null @@ -1,19 +0,0 @@ -pkgbase = spotube-bin -pkgdesc = Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! -pkgver = 4.0.0 -pkgrel = 1 -url = https://spotube.org -arch = x86_64 -license = AGPL-3.0-or-later -depends = mpv -depends = libappindicator-gtk3 -depends = libsecret -depends = jsoncpp -depends = libnotify -depends = xdg-user-dirs -depends = webkit2gtk-4.1 -optdepends = yt-dlp-git -source = https://github.com/team-spotube/spotube/releases/download/v3.7.1/spotube-linux-3.7.1-x86_64.tar.xz -md5sums = 475b1ae9b08f27743a4d4749391ae3db - -pkgname = spotube-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD deleted file mode 100644 index da28fde8..00000000 --- a/packaging/aur/PKGBUILD +++ /dev/null @@ -1,40 +0,0 @@ -# Maintainer: Kingkor Roy Tirtho -pkgname=spotube-bin -pkgver=%{{SPOTUBE_VERSION}}% -pkgrel=%{{PKGREL}}% -epoch= -pkgdesc="Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!" -arch=(x86_64) -url="https://spotube.org" -license=('AGPL-3.0-or-later') -groups=() -depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs' 'webkit2gtk-4.1') -makedepends=() -checkdepends=() -optdepends=('yt-dlp-git') -provides=() -conflicts=() -replaces=() -backup=() -options=() -install= -changelog= -source=("https://github.com/team-spotube/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-x86_64.tar.xz") -noextract=() -md5sums=(%{{LINUX_MD5}}%) -validpgpkeys=() - -package() { - install -dm755 "${pkgdir}/usr/share/icons/spotube" - install -dm755 "${pkgdir}/usr/share/applications" - install -dm755 "${pkgdir}/usr/share/appdata" - install -dm755 "${pkgdir}/usr/share/${pkgname}" - install -dm755 "${pkgdir}/usr/bin" - - mv ./spotube.desktop "${pkgdir}/usr/share/applications" - mv ./spotube-logo.png "${pkgdir}/usr/share/icons/spotube/" - mv ./com.github.KRTirtho.Spotube.appdata.xml "${pkgdir}/usr/share/appdata/spotube.appdata.xml" - cp -ra ./data ./lib ./spotube "${pkgdir}/usr/share/${pkgname}" - sed -i 's|com.github.KRTirtho.Spotube|spotube|' "${pkgdir}/usr/share/appdata/spotube.appdata.xml" - ln -s "/usr/share/${pkgname}/spotube" "${pkgdir}/usr/bin/spotube" -} diff --git a/packaging/aur/PKGBUILD.mustache b/packaging/aur/PKGBUILD.mustache new file mode 100644 index 00000000..0fa2e183 --- /dev/null +++ b/packaging/aur/PKGBUILD.mustache @@ -0,0 +1,32 @@ +# Maintainer: Kingkor Roy Tirtho +pkgname=spotube-bin +pkgver={{version}} +pkgrel={{pkgrel}} +pkgdesc="Open source, plugin powered music streaming client" +arch=(x86_64 aarch64) +url="https://spotube.org" +license=('AGPL-3.0-or-later') +# libvlc + plugins for playback (FFmpeg demux/decode, GnuTLS for HTTPS streams, PulseAudio/PipeWire output), +# WebKitGTK 4.1 for the webview, and the X11/GL/ALSA libraries the AWT/Skiko natives link +depends=( + 'libvlc' 'vlc-plugins-base' 'vlc-plugin-ffmpeg' 'vlc-plugin-gnutls' 'vlc-plugin-pulse' + 'webkit2gtk-4.1' 'gtk3' 'glib2' 'dbus' 'alsa-lib' 'libglvnd' 'fontconfig' 'freetype2' + 'libx11' 'libxext' 'libxi' 'libxrender' 'libxtst' 'zlib' +) +provides=('spotube') +conflicts=('spotube') +options=('!strip' '!debug') +source_x86_64=("https://github.com/team-spotube/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-x86_64.tar.xz") +source_aarch64=("https://github.com/team-spotube/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-aarch64.tar.xz") +sha256sums_x86_64=('{{artifacts.linuxX86_64.sha256}}') +sha256sums_aarch64=('{{artifacts.linuxAarch64.sha256}}') + +package() { + cd "spotube-linux-${pkgver}-${CARCH}" + + install -dm755 "${pkgdir}/opt" "${pkgdir}/usr/bin" "${pkgdir}/usr/share" + cp -a app "${pkgdir}/opt/spotube" + cp -a share/. "${pkgdir}/usr/share/" + ln -s /opt/spotube/spotube "${pkgdir}/usr/bin/spotube" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/packaging/chocolatey/spotube.nuspec b/packaging/chocolatey/spotube.nuspec deleted file mode 100644 index ec0ec79a..00000000 --- a/packaging/chocolatey/spotube.nuspec +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - spotube - %{{SPOTUBE_VERSION}}% - https://github.com/team-spotube/spotube/tree/master/choco-struct - Kingkor Roy Tirtho - - - - spotube (Install) - Kingkor Roy Tirtho - https://spotube.org - - https://rawcdn.githack.com/team-spotube/spotube/7edb0bb834eb18c05551e30a891720a6abf53dbe/assets/branding/spotube-logo.png - 2022 Spotube - - https://github.com/team-spotube/spotube/blob/master/LICENSE - true - https://github.com/team-spotube/spotube - https://spotube.org - https://github.com/team-spotube/spotube/issues/new - spotube music audio youtube flutter - 🎧 Open source music client that doesn't require Premium nor uses Electron! Available - for both desktop & mobile! - - Spotube is a Flutter based lightweight music client. It utilizes the power - of music metadata providers & Youtube's public API & creates a hazardless, performant - & resource - friendly User Experience - - # Features - - Open source/libre software - - Anonymous/guest login - - Cross platform support - - No telemetry, diagnostics or user data collection - - Lightweight & resource-friendly - - Native performance (Thanks to Flutter+Skia) - - Playback control is done locally instead of on the server - - Small size & less data usage - - No ads since it uses all public & free APIs (It is still recommended - to support the creators by watching/liking/subscribing to the artists' YouTube channels or - liking their tracks on different music platforms.) - - Time synced lyrics - - Downloadable tracks - - https://github.com/team-spotube/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% - - - - - \ No newline at end of file diff --git a/packaging/chocolatey/spotube.nuspec.mustache b/packaging/chocolatey/spotube.nuspec.mustache new file mode 100644 index 00000000..a864042f --- /dev/null +++ b/packaging/chocolatey/spotube.nuspec.mustache @@ -0,0 +1,48 @@ + + + + + + spotube + {{version}} + https://github.com/team-spotube/spotube/tree/master/packaging/chocolatey + Kingkor Roy Tirtho + + + + spotube (Install) + Team Spotube + https://spotube.org + + https://rawcdn.githack.com/team-spotube/spotube/69a310c78f5ceaf4eab7dfee98f187d38211c9ba/assets/branding/spotube-logo.png + 2026 Kingkor Roy Tirtho and Spotube Contributors + + https://github.com/team-spotube/spotube/blob/master/LICENSE + true + https://github.com/team-spotube/spotube + https://spotube.org + https://github.com/team-spotube/spotube/issues/new + spotube music audio music-player streaming lyrics plugins foss + Open source, plugin powered music streaming client + + A cross-platform, extensible, open source music streaming platform. Bring your own music + metadata, playlists and audio sources with plugins made by the community or by yourself. + + # Features + - Plugin powered: supports any platform or custom music service through plugins + - Community driven plugins for popular platforms, or create your own + - Freely downloadable tracks with tagged metadata + - Time synced lyrics regardless of plugin support + - No telemetry, diagnostics or user data collection + - Small size, low data usage and native performance + - Open source/libre software + + {{release.url}} + +{{release.notes}} + + + + + \ No newline at end of file diff --git a/packaging/chocolatey/tools/VERIFICATION.txt b/packaging/chocolatey/tools/VERIFICATION.txt.mustache similarity index 69% rename from packaging/chocolatey/tools/VERIFICATION.txt rename to packaging/chocolatey/tools/VERIFICATION.txt.mustache index 9ca4799f..4d1d6c40 100644 --- a/packaging/chocolatey/tools/VERIFICATION.txt +++ b/packaging/chocolatey/tools/VERIFICATION.txt.mustache @@ -7,17 +7,17 @@ in verifying that this package's contents are trustworthy. Please go to releases page https://github.com/team-spotube/spotube/releases -Download same version as this choco package (example for v%{{SPOTUBE_VERSION}}%) -https://github.com/team-spotube/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% +Download same version as this choco package (v{{version}}) +https://github.com/team-spotube/spotube/releases/tag/v{{version}} 1. get hashes. Run: powershell -command Get-FileHash tools\Spotube-windows-x86_64-setup.exe 2. The checksums should match the following: --- -Version Hashes for v%{{SPOTUBE_VERSION}}% +Version Hashes for v{{version}} Algorithm Hash Path --------- ---- ---- -SHA256 %{{WIN_SHA256}}% tools\Spotube-windows-x86_64-setup.exe \ No newline at end of file +SHA256 {{artifacts.windowsX64.sha256Upper}} tools\Spotube-windows-x86_64-setup.exe \ No newline at end of file diff --git a/packaging/chocolatey/tools/chocolateyinstall.ps1 b/packaging/chocolatey/tools/chocolateyinstall.ps1 index 8e84266e..f188e3cc 100644 --- a/packaging/chocolatey/tools/chocolateyinstall.ps1 +++ b/packaging/chocolatey/tools/chocolateyinstall.ps1 @@ -5,13 +5,15 @@ $fileLocation = Join-Path $toolsDir 'Spotube-windows-x86_64-setup.exe' $packageArgs = @{ packageName = $env:ChocolateyPackageName - unzipLocation = $toolsDir - fileType = 'exe' #only one of these: exe, msi, msu - file = $fileLocation + fileType = 'exe' + file = $fileLocation - softwareName = 'spotube*' #part or all of the Display Name as you see it in Programs and Features. It should be enough to be unique - silentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' # Inno Setup + softwareName = 'Spotube*' + silentArgs = '/S' # NSIS (electron-builder one-click, per-user) validExitCodes= @(0) } -Install-ChocolateyInstallPackage @packageArgs \ No newline at end of file +Install-ChocolateyInstallPackage @packageArgs + +# The installer was only needed to install; don't keep a copy in the package directory +Remove-Item $fileLocation -Force -ErrorAction SilentlyContinue diff --git a/packaging/chocolatey/tools/chocolateyuninstall.ps1 b/packaging/chocolatey/tools/chocolateyuninstall.ps1 index 17b8e929..d1e9f55a 100644 --- a/packaging/chocolatey/tools/chocolateyuninstall.ps1 +++ b/packaging/chocolatey/tools/chocolateyuninstall.ps1 @@ -1,17 +1,23 @@ $ErrorActionPreference = 'Stop'; # stop on all errors $packageArgs = @{ packageName = $env:ChocolateyPackageName - softwareName = 'spotube*' + softwareName = 'Spotube*' fileType = 'exe' - validExitCodes= @(0, 3010, 1605, 1614, 1641) - silentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' # Inno Setup + validExitCodes= @(0) } [array]$key = Get-UninstallRegistryKey -SoftwareName $packageArgs['softwareName'] if ($key.Count -eq 1) { $key | % { - $packageArgs['file'] = "$($_.UninstallString)" #NOTE: You may need to split this if it contains spaces, see below + # electron-builder writes UninstallString as "\Uninstall Spotube.exe" /currentuser + if ($_.UninstallString -match '^\s*"([^"]+)"\s*(.*)$') { + $packageArgs['file'] = $Matches[1] + $packageArgs['silentArgs'] = "$($Matches[2]) /S".Trim() + } else { + $packageArgs['file'] = $_.UninstallString + $packageArgs['silentArgs'] = '/S' + } Uninstall-ChocolateyPackage @packageArgs } @@ -22,4 +28,4 @@ if ($key.Count -eq 1) { Write-Warning "To prevent accidental data loss, no programs will be uninstalled." Write-Warning "Please alert package maintainer the following keys were matched:" $key | % {Write-Warning "- $($_.DisplayName)"} -} \ No newline at end of file +} diff --git a/packaging/flatpak/org.spotube.spotube.yml.mustache b/packaging/flatpak/org.spotube.spotube.yml.mustache new file mode 100644 index 00000000..d0744bff --- /dev/null +++ b/packaging/flatpak/org.spotube.spotube.yml.mustache @@ -0,0 +1,112 @@ +id: org.spotube.spotube +# GTK 3 and WebKitGTK 4.1 (the webview) come from the GNOME runtime +runtime: org.gnome.Platform +runtime-version: '51' +sdk: org.gnome.Sdk +command: spotube + +finish-args: + - --share=ipc + - --share=network + # Compose renders through AWT/Skiko, which needs X11 (XWayland on Wayland sessions) + - --socket=x11 + - --socket=pulseaudio + - --device=dri + # Downloaded tracks go to ~/Downloads/Spotube; local tracks are scanned from ~/Music and ~/Downloads + - --filesystem=xdg-download + - --filesystem=xdg-music:ro + # Tray icon (StatusNotifierItem) + - --talk-name=org.kde.StatusNotifierWatcher + +cleanup: + - /include + - /lib/pkgconfig + - /share/man + - /share/doc + - '*.la' + - '*.a' + +modules: + # libvlc for vlcj (the audio player). Audio only: no video outputs, UI or Lua; decoding and demuxing + # go through the runtime's FFmpeg (proprietary codecs come from the auto-installed codecs-extra extension). + - name: vlc + build-options: + env: + # configure looks for a c99/c11 wrapper for its build-time tools + BUILDCC: gcc -std=gnu11 + config-opts: + - --disable-vlc + - --disable-qt + - --disable-skins2 + - --disable-ncurses + - --disable-lua + - --disable-a52 + - --disable-dbus + - --disable-xcb + - --disable-xvideo + - --disable-wayland + - --disable-vdpau + - --disable-libva + - --disable-gles2 + - --disable-sdl-image + - --disable-chromecast + - --disable-udev + - --disable-v4l2 + - --disable-dc1394 + - --disable-dv1394 + - --disable-linsys + - --disable-dvbpsi + - --disable-bluray + - --disable-dvdread + - --disable-dvdnav + - --disable-vcd + - --disable-libcddb + - --disable-screen + - --disable-addonmanagermodules + - --disable-update-check + cleanup: + - /share/applications + - /share/icons + - /share/kde4 + - /share/metainfo + - /share/solid + - /share/vlc + sources: + - type: archive + url: https://download.videolan.org/pub/videolan/vlc/3.0.24/vlc-3.0.24.tar.xz + sha256: e7cab503d1d7d5849b89d2cf0e1ee60d0ef6d012407791b644b9cfc0cc225fdf + x-checker-data: + type: anitya + project-id: 6504 + stable-only: true + versions: {<: '4.0'} + url-template: https://download.videolan.org/pub/videolan/vlc/$version/vlc-$version.tar.xz + + # The GraalVM native image, repackaged from the release tarball (packaging/package-linux-tarball.sh) + - name: spotube + buildsystem: simple + build-commands: + - cp -a app ${FLATPAK_DEST}/lib/spotube + - install -d ${FLATPAK_DEST}/bin + - ln -s ../lib/spotube/spotube ${FLATPAK_DEST}/bin/spotube + - cp -a share/. ${FLATPAK_DEST}/share/ + sources: + - type: archive + only-arches: [x86_64] + url: {{artifacts.linuxX86_64.url}} + sha256: {{artifacts.linuxX86_64.sha256}} + x-checker-data: + type: json + url: https://api.github.com/repos/team-spotube/spotube/releases/latest + version-query: .tag_name | sub("^v"; "") + url-query: '"https://github.com/team-spotube/spotube/releases/download/v" + $version + "/spotube-linux-" + $version + "-x86_64.tar.xz"' + is-main-source: true + - type: archive + only-arches: [aarch64] + url: {{artifacts.linuxAarch64.url}} + sha256: {{artifacts.linuxAarch64.sha256}} + x-checker-data: + type: json + url: https://api.github.com/repos/team-spotube/spotube/releases/latest + version-query: .tag_name | sub("^v"; "") + url-query: '"https://github.com/team-spotube/spotube/releases/download/v" + $version + "/spotube-linux-" + $version + "-aarch64.tar.xz"' diff --git a/packaging/linux/org.spotube.spotube.desktop b/packaging/linux/org.spotube.spotube.desktop new file mode 100644 index 00000000..6e56f0f6 --- /dev/null +++ b/packaging/linux/org.spotube.spotube.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Spotube +GenericName=Music Player +Comment=Open source, plugin powered music streaming client +Exec=spotube %U +Icon=org.spotube.spotube +Terminal=false +Categories=AudioVideo;Audio;Music;Player; +Keywords=music;streaming;player;audio;lyrics;playlist; +StartupNotify=true +StartupWMClass=org-spotube-spotube-MainKt diff --git a/packaging/linux/org.spotube.spotube.metainfo.xml.mustache b/packaging/linux/org.spotube.spotube.metainfo.xml.mustache new file mode 100644 index 00000000..9519ac88 --- /dev/null +++ b/packaging/linux/org.spotube.spotube.metainfo.xml.mustache @@ -0,0 +1,99 @@ + + + org.spotube.spotube + + + com.github.KRTirtho.Spotube + + + com.github.KRTirtho.Spotube + + + Spotube + Plugin powered, open source music streaming client + + Team Spotube + + + CC0-1.0 + AGPL-3.0-or-later + + +

+ A cross-platform, extensible, open source music streaming platform. Bring your own music + metadata, playlists and audio sources with plugins made by the community or by yourself. +

+
    +
  • Plugin powered: supports any platform or custom music service through plugins
  • +
  • Community driven plugins for popular platforms, or create your own
  • +
  • Freely downloadable tracks with tagged metadata
  • +
  • Time synced lyrics regardless of plugin support
  • +
  • No telemetry, diagnostics or user data collection
  • +
  • Small size, low data usage and native performance
  • +
+
+ + org.spotube.spotube.desktop + + + AudioVideo + Audio + Music + + + music + streaming + player + lyrics + + + https://spotube.org + https://github.com/team-spotube/spotube/issues + https://github.com/team-spotube/spotube + https://opencollective.com/spotube + https://github.com/team-spotube/spotube/blob/master/CONTRIBUTION.md + + + + Spotube on the desktop + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/branding/spotube-screenshot.png + + + + + #84b2ff + #01185c + + + + + + 360 + + + pointing + keyboard + touch + + + + +{{#releases}} + + {{url}} +{{#notes}} + +{{#groups}} +

{{title}}

+
    +{{#entries}} +
  • {{text}}
  • +{{/entries}} +
+{{/groups}} +
+{{/notes}} +
+{{/releases}} +
+
diff --git a/packaging/package-linux-tarball.sh b/packaging/package-linux-tarball.sh new file mode 100755 index 00000000..9b2c4157 --- /dev/null +++ b/packaging/package-linux-tarball.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Packs the GraalVM native distributable into spotube-linux--.tar.xz, the +# portable tarball the AUR (spotube-bin) and Flathub (org.spotube.spotube) packages install from. +# +# Run after `:composeApp:createGraalvmNativeDistributable` WITHOUT -PappImage=true (the tarball uses the +# system's or the Flatpak runtime's VLC, GTK 3 and WebKitGTK, like the deb/rpm) and +# `:composeApp:renderPackagingLinux` (desktop entry + metainfo with the release notes). +# +# Usage: packaging/package-linux-tarball.sh [stable|nightly] [out-dir] +# +# Layout: +# spotube-linux--/ +# app/ binary + the JDK/Skiko libraries it loads +# share/applications/org.spotube.spotube.desktop +# share/metainfo/org.spotube.spotube.metainfo.xml +# share/icons/hicolor/512x512/apps/org.spotube.spotube.png +# LICENSE +set -euo pipefail + +version="${1:?usage: $0 [stable|nightly] [out-dir]}" +channel="${2:-stable}" +out_dir="${3:-.}" + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +app_dir="${SPOTUBE_APP_DIR:-$root/composeApp/build/compose/binaries/main/graalvm-app/org.spotube.spotube}" +linux_dir="$root/composeApp/build/packaging/linux" + +case "$(uname -m)" in + x86_64 | amd64) arch=x86_64 ;; + aarch64 | arm64) arch=aarch64 ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +if [[ ! -x "$app_dir/spotube" ]]; then + echo "No native distributable at $app_dir; run :composeApp:createGraalvmNativeDistributable first" >&2 + exit 1 +fi +if [[ ! -f "$linux_dir/org.spotube.spotube.metainfo.xml" ]]; then + echo "No rendered metainfo in $linux_dir; run :composeApp:renderPackagingLinux first" >&2 + exit 1 +fi +# An AppImage build (-PappImage=true) leaves VLC plugins and a GTK/WebKit tree in the same dir +if [[ -d "$app_dir/plugins" || -d "$app_dir/usr" ]]; then + echo "$app_dir bundles VLC/GTK (an -PappImage=true build); rebuild without it" >&2 + exit 1 +fi + +name="spotube-linux-$version-$arch" +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT +dest="$staging/$name" + +mkdir -p "$dest/share/applications" "$dest/share/metainfo" "$dest/share/icons/hicolor/512x512/apps" +cp -a "$app_dir" "$dest/app" +rm -f "$dest/app/.complete" +install -m644 "$linux_dir/org.spotube.spotube.desktop" "$dest/share/applications/" +install -m644 "$linux_dir/org.spotube.spotube.metainfo.xml" "$dest/share/metainfo/" +install -m644 "$root/composeApp/icons/$channel/icon.png" "$dest/share/icons/hicolor/512x512/apps/org.spotube.spotube.png" +install -m644 "$root/LICENSE" "$dest/LICENSE" + +mkdir -p "$out_dir" +tarball="$(cd "$out_dir" && pwd)/$name.tar.xz" +tar -C "$staging" --sort=name --owner=0 --group=0 --numeric-owner \ + --mtime="@${SOURCE_DATE_EPOCH:-$(git -C "$root" log -1 --format=%ct)}" \ + -cf - "$name" | xz -T0 -9 > "$tarball" + +echo "$tarball" diff --git a/packaging/winget/KRTirtho.Spotube.installer.yaml.mustache b/packaging/winget/KRTirtho.Spotube.installer.yaml.mustache new file mode 100644 index 00000000..32490e24 --- /dev/null +++ b/packaging/winget/KRTirtho.Spotube.installer.yaml.mustache @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json + +PackageIdentifier: KRTirtho.Spotube +PackageVersion: {{version}} +Platform: +- Windows.Desktop +MinimumOSVersion: 10.0.0.0 +# electron-builder one-click NSIS installer (Nucleus), installs per user; winget's nullsoft default /S is silent +InstallerType: nullsoft +Scope: user +InstallModes: +- silent +- silentWithProgress +UpgradeBehavior: install +ReleaseDate: {{release.date}} +Installers: +- Architecture: x64 + InstallerUrl: {{artifacts.windowsX64.url}} + InstallerSha256: {{artifacts.windowsX64.sha256Upper}} +ManifestType: installer +ManifestVersion: 1.10.0 diff --git a/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache b/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache new file mode 100644 index 00000000..fcaae4f9 --- /dev/null +++ b/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache @@ -0,0 +1,34 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.10.0.schema.json + +PackageIdentifier: KRTirtho.Spotube +PackageVersion: {{version}} +PackageLocale: en-US +Publisher: Team Spotube +PublisherUrl: https://github.com/team-spotube +PublisherSupportUrl: https://github.com/team-spotube/spotube/issues +PrivacyUrl: https://spotube.org/p/privacy +Author: Team Spotube +PackageName: Spotube +PackageUrl: https://spotube.org +License: AGPL-3.0-or-later +LicenseUrl: https://github.com/team-spotube/spotube/blob/master/LICENSE +Copyright: Copyright (c) 2022-2026 Kingkor Roy Tirtho and Spotube Contributors +ShortDescription: Open source, plugin powered music streaming client +Description: |- + A cross-platform, extensible, open source music streaming platform. Bring your own music + metadata, playlists and audio sources with plugins made by the community or by yourself. +Moniker: spotube +Tags: +- audio +- music +- music-player +- streaming +- lyrics +- plugins +{{#release.notes}} +ReleaseNotes: |- +{{#indent2}}{{release.notes}}{{/indent2}} +{{/release.notes}} +ReleaseNotesUrl: {{release.url}} +ManifestType: defaultLocale +ManifestVersion: 1.10.0 diff --git a/packaging/winget/KRTirtho.Spotube.yaml.mustache b/packaging/winget/KRTirtho.Spotube.yaml.mustache new file mode 100644 index 00000000..1c92b1d4 --- /dev/null +++ b/packaging/winget/KRTirtho.Spotube.yaml.mustache @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.10.0.schema.json + +PackageIdentifier: KRTirtho.Spotube +PackageVersion: {{version}} +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.10.0