diff --git a/.gitattributes b/.gitattributes index f91f646..a54f28a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,12 +1,19 @@ # # https://help.github.com/articles/dealing-with-line-endings/ # -# Linux start script should use lf -/gradlew text eol=lf +# All text files use LF so Spotless checks and checksum-based tests are stable +# on Windows runners (where core.autocrlf would otherwise convert to CRLF). +* text=auto eol=lf -# These are Windows script files and should use crlf +# Windows script files keep CRLF. *.bat text eol=crlf +*.cmd text eol=crlf -# Binary files should be left untouched +# Binary files should be left untouched. *.jar binary - +*.png binary +*.ico binary +*.icns binary +*.pdf binary +*.xlsx binary +*.zip binary diff --git a/.github/workflows/build-distributions.yml b/.github/workflows/build-distributions.yml index 588e784..7e5e5be 100644 --- a/.github/workflows/build-distributions.yml +++ b/.github/workflows/build-distributions.yml @@ -19,7 +19,7 @@ jobs: - os: macos-latest platform: macos tasks: packageMacosAppImage - artifact: app/build/release/macos/* + artifact: app/build/release/macos/AlipsaAccounting-macos.zip runs-on: ${{ matrix.os }} diff --git a/app/build.gradle b/app/build.gradle index d22e444..ddd10fd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -58,6 +58,10 @@ def licenseFilePath = rootProject.file('LICENSE').absolutePath def linuxDesktopTemplate = releaseConfig.packagingRoot.dir('linux/desktop').file("${releaseConfig.appName}.desktop.template") def linuxDesktopEntry = releaseConfig.generatedRoot.map { it.file("linux-desktop/${releaseConfig.appName}.desktop") } +def linuxInstallTemplate = releaseConfig.packagingRoot.dir('linux').file('install.sh.template') +def linuxUninstallTemplate = releaseConfig.packagingRoot.dir('linux').file('uninstall.sh.template') +def linuxInstallScript = releaseConfig.generatedRoot.map { it.file('linux-scripts/install.sh') } +def linuxUninstallScript = releaseConfig.generatedRoot.map { it.file('linux-scripts/uninstall.sh') } def currentOsName = System.getProperty('os.name', 'unknown').toLowerCase(Locale.ROOT) def currentPlatform = currentOsName.contains('win') ? 'windows' : currentOsName.contains('mac') ? 'macos' : 'linux' @@ -186,6 +190,26 @@ tasks.register('generateLinuxDesktopEntry') { } } +tasks.register('generateLinuxInstallScripts') { + inputs.file(linuxInstallTemplate) + inputs.file(linuxUninstallTemplate) + outputs.file(linuxInstallScript) + outputs.file(linuxUninstallScript) + doLast { + [ + (linuxInstallTemplate.asFile): linuxInstallScript.get().asFile, + (linuxUninstallTemplate.asFile): linuxUninstallScript.get().asFile + ].each { File source, File target -> + target.parentFile.mkdirs() + target.text = source.getText('UTF-8') + .replace('@DISPLAY_NAME@', releaseConfig.displayName) + .replace('@DESCRIPTION@', releaseConfig.description) + .replace('@APP_NAME@', releaseConfig.appName) + .replace('@PACKAGE_NAME@', releaseConfig.packageName) + } + } +} + tasks.register('prepareLinuxPackagingResources', Copy) { dependsOn 'generateReleaseMetadata', 'generateLinuxDesktopEntry' from(releaseConfig.packagingRoot.dir('linux')) @@ -242,15 +266,23 @@ tasks.register('packageLinuxAppImage', Exec) { tasks.register('packageLinuxReleaseZip', Zip) { onlyIf { isLinux } - dependsOn 'packageLinuxAppImage', 'generateLinuxDesktopEntry' + dependsOn 'packageLinuxAppImage', 'generateLinuxInstallScripts' archiveBaseName = releaseConfig.packageName archiveVersion = releaseConfig.version archiveClassifier = 'linux' destinationDirectory = releaseConfig.releaseRoot.map { it.dir('linux') } from(releaseConfig.releaseRoot.map { it.dir("linux/${releaseConfig.appName}") }) { into(releaseConfig.appName) + filesMatching("bin/${releaseConfig.appName}") { + it.permissions { unix(0755) } + } + } + from(linuxInstallScript) { + filePermissions { unix(0755) } + } + from(linuxUninstallScript) { + filePermissions { unix(0755) } } - from(linuxDesktopEntry) } tasks.register('packageWindowsAppImage', Exec) { diff --git a/app/src/main/groovy/se/alipsa/accounting/AlipsaAccounting.groovy b/app/src/main/groovy/se/alipsa/accounting/AlipsaAccounting.groovy index 56cebe0..975caaa 100644 --- a/app/src/main/groovy/se/alipsa/accounting/AlipsaAccounting.groovy +++ b/app/src/main/groovy/se/alipsa/accounting/AlipsaAccounting.groovy @@ -57,6 +57,10 @@ final class AlipsaAccounting { log.warning("Launch verification completed with warnings: ${startupReport.warnings.join(' | ')}") } System.out.println("Launch verification OK: ${versionLine()} [home=${AppPaths.applicationHome()}]") + // Release the H2 and logging file handles so the caller (tests, packaging verification) + // can delete the verification home directory on Windows. + DatabaseService.instance.shutdown() + LoggingConfigurer.shutdown() return } if (!startupReport.ok || !startupReport.warnings.isEmpty()) { diff --git a/app/src/main/groovy/se/alipsa/accounting/service/DatabaseService.groovy b/app/src/main/groovy/se/alipsa/accounting/service/DatabaseService.groovy index cf1663c..91527d5 100644 --- a/app/src/main/groovy/se/alipsa/accounting/service/DatabaseService.groovy +++ b/app/src/main/groovy/se/alipsa/accounting/service/DatabaseService.groovy @@ -114,6 +114,27 @@ final class DatabaseService { } } + /** + * Issues an H2 {@code SHUTDOWN} so the engine releases its file handles. Required on Windows + * before deleting the database directory (e.g. JUnit {@code @TempDir} cleanup), since H2 keeps + * the {@code .mv.db} file open between connections. + */ + void shutdown() { + Sql sql = Sql.newInstance(databaseUrl(), USERNAME, PASSWORD, DRIVER) + try { + sql.execute('shutdown') + } catch (java.sql.SQLException ignored) { + // H2 closes the connection as part of SHUTDOWN and reports "Database is already closed" + // when the Statement tries to finalize. The shutdown itself succeeded. + } finally { + try { + sql.close() + } catch (java.sql.SQLException ignored) { + // Connection is already closed by SHUTDOWN. + } + } + } + private String defaultDatabaseUrl() { embeddedDatabaseUrl(AppPaths.applicationHome()) } diff --git a/app/src/test/groovy/integration/se/alipsa/accounting/service/ReportServicesTest.groovy b/app/src/test/groovy/integration/se/alipsa/accounting/service/ReportServicesTest.groovy index e29d634..290bfc3 100644 --- a/app/src/test/groovy/integration/se/alipsa/accounting/service/ReportServicesTest.groovy +++ b/app/src/test/groovy/integration/se/alipsa/accounting/service/ReportServicesTest.groovy @@ -132,7 +132,8 @@ class ReportServicesTest { assertEquals(ReportType.INCOME_STATEMENT, archive.reportType) assertTrue(new String(pdf, 0, 5, StandardCharsets.US_ASCII).startsWith('%PDF-')) assertTrue(Files.size(reportArchiveService.resolveStoredPath(archive)) > 500L) - assertEquals('ccf4d53a601b18ee472bdb2c9b945f7b8bb1c297239e58cb3f7bc2ebbf56e19d', sha256(journoReportService.renderHtml(preview).getBytes(StandardCharsets.UTF_8))) + String html = journoReportService.renderHtml(preview).replace('\r\n', '\n').replace('\r', '\n') + assertEquals('ccf4d53a601b18ee472bdb2c9b945f7b8bb1c297239e58cb3f7bc2ebbf56e19d', sha256(html.getBytes(StandardCharsets.UTF_8))) } @Test diff --git a/app/src/test/groovy/unit/se/alipsa/accounting/support/AppPathsTest.groovy b/app/src/test/groovy/unit/se/alipsa/accounting/support/AppPathsTest.groovy index c9a4cb9..e0b24ce 100644 --- a/app/src/test/groovy/unit/se/alipsa/accounting/support/AppPathsTest.groovy +++ b/app/src/test/groovy/unit/se/alipsa/accounting/support/AppPathsTest.groovy @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir import java.nio.file.Path import java.nio.file.Paths @@ -14,6 +15,9 @@ import java.nio.file.Paths // interfering with other tests that call AppPaths.applicationHome() concurrently. class AppPathsTest { + @TempDir + Path tempDir + private String previousOsName private String previousUserHome private String previousHomeOverride @@ -59,11 +63,12 @@ class AppPathsTest { @Test void applicationHomeOverrideTakesPrecedenceForAllDerivedDirectories() { - System.setProperty(AppPaths.HOME_OVERRIDE_PROPERTY, '/tmp/accounting-home') + Path overrideHome = tempDir.resolve('accounting-home') + System.setProperty(AppPaths.HOME_OVERRIDE_PROPERTY, overrideHome.toString()) Path home = AppPaths.applicationHome() - assertEquals(Paths.get('/tmp/accounting-home'), home) + assertEquals(overrideHome.toAbsolutePath().normalize(), home) assertEquals(home.resolve('data'), AppPaths.dataDirectory()) assertEquals(home.resolve('logs'), AppPaths.logDirectory()) assertEquals(home.resolve('attachments'), AppPaths.attachmentsDirectory()) diff --git a/createDist.sh b/createDist.sh old mode 100644 new mode 100755 diff --git a/packaging/linux/install.sh.template b/packaging/linux/install.sh.template new file mode 100644 index 0000000..397c482 --- /dev/null +++ b/packaging/linux/install.sh.template @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Installationsskript för @DISPLAY_NAME@. +# Registrerar en desktop-genväg mot den plats där zip:en har extraherats, +# och säkerställer att launchern är exekverbar. +# +# Användning: kör ./install.sh från samma katalog som @APP_NAME@/ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_DIR="${SCRIPT_DIR}/@APP_NAME@" + +if [ ! -d "${INSTALL_DIR}" ]; then + echo "Fel: hittade inte ${INSTALL_DIR}." >&2 + echo "Kör install.sh från samma katalog som @APP_NAME@/." >&2 + exit 1 +fi + +LAUNCHER="${INSTALL_DIR}/bin/@APP_NAME@" +if [ ! -f "${LAUNCHER}" ]; then + echo "Fel: launchern saknas: ${LAUNCHER}" >&2 + exit 1 +fi +chmod +x "${LAUNCHER}" + +ICON="${INSTALL_DIR}/lib/@APP_NAME@.png" +if [ ! -f "${ICON}" ]; then + ICON="${SCRIPT_DIR}/@APP_NAME@.png" +fi + +APPLICATIONS_DIR="${XDG_DATA_HOME:-${HOME}/.local/share}/applications" +mkdir -p "${APPLICATIONS_DIR}" + +DESKTOP_FILE="${APPLICATIONS_DIR}/@APP_NAME@.desktop" +cat > "${DESKTOP_FILE}" </dev/null 2>&1; then + update-desktop-database "${APPLICATIONS_DIR}" >/dev/null 2>&1 || true +fi + +echo "Installerat." +echo " Launcher: ${LAUNCHER}" +echo " Genväg: ${DESKTOP_FILE}" +echo +echo "Starta från applikationsmenyn, eller direkt via launchern ovan." diff --git a/packaging/linux/uninstall.sh.template b/packaging/linux/uninstall.sh.template new file mode 100644 index 0000000..3770c91 --- /dev/null +++ b/packaging/linux/uninstall.sh.template @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# +# Avinstallationsskript för @DISPLAY_NAME@. +# Tar bort desktop-genvägen. Radera sedan katalogen @APP_NAME@/ manuellt. + +set -euo pipefail + +APPLICATIONS_DIR="${XDG_DATA_HOME:-${HOME}/.local/share}/applications" +DESKTOP_FILE="${APPLICATIONS_DIR}/@APP_NAME@.desktop" + +if [ -f "${DESKTOP_FILE}" ]; then + rm -f "${DESKTOP_FILE}" + echo "Desktop-genvägen borttagen: ${DESKTOP_FILE}" + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "${APPLICATIONS_DIR}" >/dev/null 2>&1 || true + fi +else + echo "Ingen genväg att ta bort (${DESKTOP_FILE} finns inte)." +fi + +echo "Radera katalogen @APP_NAME@/ manuellt om du vill ta bort applikationen helt." +echo "Användardata ligger under \${XDG_DATA_HOME:-\$HOME/.local/share}/alipsa-accounting — ta bort det separat om det inte ska sparas." diff --git a/release.sh b/release.sh index bf9ac1a..951bea5 100755 --- a/release.sh +++ b/release.sh @@ -68,12 +68,24 @@ if [ "$BUILD" = true ]; then echo "Downloading artifacts to $DIST_DIR/" rm -rf "$DIST_DIR" - mkdir -p "$DIST_DIR" - gh run download "$run_id" --repo "$REPO" --dir "$DIST_DIR" + mkdir -p "$DIST_DIR/extras" + gh run download "$run_id" --repo "$REPO" --dir "$DIST_DIR/extras" + + # Promote known distribution files to the dist root; leave anything else + # behind in extras/ so the root stays clean. + shopt -s nullglob + for f in "$DIST_DIR"/extras/*/alipsa-accounting-*.zip \ + "$DIST_DIR"/extras/*/AlipsaAccounting-*.exe \ + "$DIST_DIR"/extras/*/AlipsaAccounting-*.zip \ + "$DIST_DIR"/extras/*/app-*.zip; do + mv "$f" "$DIST_DIR/" + done + shopt -u nullglob - # Flatten: move files out of per-platform subdirectories (no-clobber to avoid overwrites) - find "$DIST_DIR" -mindepth 2 -type f -exec mv -n {} "$DIST_DIR/" \; - find "$DIST_DIR" -mindepth 1 -type d -empty -delete + # Flatten any residual platform subdirs inside extras/ and drop empties. + find "$DIST_DIR/extras" -mindepth 2 -type f -exec mv -n {} "$DIST_DIR/extras/" \; + find "$DIST_DIR/extras" -mindepth 1 -type d -empty -delete + rmdir "$DIST_DIR/extras" 2>/dev/null || true echo "" echo "Generating SHA-256 checksums..." @@ -143,11 +155,12 @@ gpg --verify .asc fi echo "Creating GitHub release $TAG..." + mapfile -t release_assets < <(find "$DIST_DIR" -maxdepth 1 -type f) gh release create "$TAG" \ --repo "$REPO" \ --title "Alipsa Accounting ${VERSION}" \ --notes "$release_body" \ - "$DIST_DIR"/* + "${release_assets[@]}" echo "" release_url=$(gh release view "$TAG" --repo "$REPO" --json url --jq '.url')