From d25cb7e57afbd55c9f08841ea8aab8660ac56048 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 20 Aug 2026 16:04:28 +0300 Subject: [PATCH] macOS DMG installer Signed-off-by: denis0001-dev --- app/desktop/build.gradle.kts | 175 ++++++++++++++++++ app/desktop/dmg-background/dmg-config.json | 4 +- app/desktop/dmg-background/export.mjs | 148 +++++++-------- app/desktop/dmg-background/index.html | 11 +- app/desktop/dmg-background/package-lock.json | 57 ++++++ app/desktop/dmg-background/styles.css | 102 +++++----- app/desktop/proguard-rules.pro | 41 ++++ app/shared/build.gradle.kts | 2 + .../composeResources/drawable/logo_square.svg | 4 - .../kotlin/ru/fromchat/ui/WelcomeScreen.kt | 8 +- .../ru/fromchat/ui/main/chats/ChatsTab.kt | 8 +- .../fromchat/ui/main/settings/AboutScreen.kt | 10 +- .../ui/main/settings/DevicesScreen.kt | 21 ++- utils/shared/build.gradle.kts | 2 + 14 files changed, 429 insertions(+), 164 deletions(-) create mode 100644 app/desktop/dmg-background/package-lock.json create mode 100644 app/desktop/proguard-rules.pro delete mode 100644 app/shared/src/commonMain/composeResources/drawable/logo_square.svg diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts index 43912a7..e919ced 100644 --- a/app/desktop/build.gradle.kts +++ b/app/desktop/build.gradle.kts @@ -25,16 +25,43 @@ dependencies { val desktopWindowIconPng = layout.projectDirectory.file("src/main/resources/app_window_icon.png") val desktopWindowIconIcns = layout.projectDirectory.file("icons/app_window_icon.icns") +val dmgBackgroundDir = layout.projectDirectory.dir("dmg-background") +val dmgStageDir = layout.buildDirectory.dir("dmg-background/stage") +val dmgDistDir = layout.buildDirectory.dir("dmg-background/dist") +val debugAppBundle = layout.buildDirectory.dir("compose/binaries/main/app/FromChat.app") +val releaseAppBundle = layout.buildDirectory.dir("compose/binaries/main-release/app/FromChat.app") +val testDmgOutput = layout.buildDirectory.file("distributions/FromChat-test.dmg") +val releaseDmgOutput = layout.buildDirectory.file("distributions/FromChat.dmg") + +fun resolvePackagingJdkHome(): String { + System.getenv("FROMCHAT_PACKAGING_JDK")?.takeIf { it.isNotBlank() }?.let { return it } + val candidates = listOf( + "${System.getProperty("user.home")}/.gradle/jdks/eclipse_adoptium-17-aarch64-os_x.2/jdk-17.0.19+10/Contents/Home", + "/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home", + "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home", + "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home", + ) + return candidates.firstOrNull { File(it, "bin/jpackage").isFile } + ?: error( + "No JDK with jpackage found. Install Temurin 17+ or set FROMCHAT_PACKAGING_JDK.", + ) +} compose.desktop { application { mainClass = "ru.fromchat.desktop.MainKt" + javaHome = resolvePackagingJdkHome() + + buildTypes.release.proguard { + configurationFiles.from(project.file("proguard-rules.pro")) + } nativeDistributions { packageName = "FromChat" packageVersion = "1.0.0" description = "FromChat desktop" copyright = "© FromChat" + includeAllModules = true modules( "javafx.base", @@ -307,3 +334,151 @@ private fun openjfxClassifier(): String { else -> "linux" } } + +val runningOnMacOs = System.getProperty("os.name").orEmpty().lowercase().contains("mac") + +val installDmgBackgroundTools = tasks.register("installDmgBackgroundTools") { + onlyIf { runningOnMacOs } + workingDir = dmgBackgroundDir.asFile + inputs.file(dmgBackgroundDir.file("package.json")) + inputs.file(dmgBackgroundDir.file("package-lock.json")) + outputs.dir(dmgBackgroundDir.dir("node_modules/playwright")) + commandLine( + "bash", + "-lc", + """ + if [ ! -f node_modules/playwright/package.json ]; then + npm install --no-fund --no-audit + npx playwright install chromium + fi + """.trimIndent(), + ) +} + +val stageDmgBackground = tasks.register("stageDmgBackground") { + onlyIf { runningOnMacOs } + dependsOn(":app:shared:prepareComposeResourcesTaskForCommonMain") + mustRunAfter(installDmgBackgroundTools) + from(dmgBackgroundDir) { + exclude("node_modules/**") + } + into(dmgStageDir) + doLast { + val sharedLogo = rootProject.project(":app:shared").layout.buildDirectory + .file("generated/compose/resourceGenerator/preparedResources/commonMain/composeResources/drawable/logo_square.png") + .get().asFile + if (sharedLogo.isFile) { + val target = dmgStageDir.get().asFile.resolve("assets/logo_square.png") + target.parentFile.mkdirs() + sharedLogo.copyTo(target, overwrite = true) + } else { + logger.lifecycle("Compose logo not generated; using bundled dmg-background/assets/logo_square.png") + } + } +} + +val exportDmgBackground = tasks.register("exportDmgBackground") { + onlyIf { runningOnMacOs } + dependsOn(stageDmgBackground, installDmgBackgroundTools) + workingDir = dmgBackgroundDir.asFile + val stage = dmgStageDir.get().asFile.absolutePath + val dist = dmgDistDir.get().asFile.absolutePath + inputs.dir(dmgStageDir) + outputs.dir(dmgDistDir) + commandLine( + "bash", + "-lc", + "node export.mjs '${stage}' '${dist}'", + ) +} + +fun org.gradle.api.tasks.TaskContainer.registerPackageDmgTask( + name: String, + appBundle: Provider, + dmgOutput: Provider, + distributableTask: String, + scriptName: String, +) = register(name) { + onlyIf { runningOnMacOs } + group = "compose desktop" + dependsOn(distributableTask, exportDmgBackground) + val positionsFile = dmgDistDir.map { it.file("icon-positions.json") } + val dmgScript = layout.buildDirectory.file("dmg-background/$scriptName") + inputs.dir(appBundle) + inputs.dir(dmgDistDir) + inputs.file(positionsFile) + outputs.file(dmgOutput) + doFirst { + val app = appBundle.get().asFile + check(app.isDirectory) { + "Missing ${app.absolutePath}. Run :app:desktop:$distributableTask first." + } + val positions = positionsFile.get().asFile + check(positions.isFile) { "Missing ${positions.absolutePath}" } + val script = dmgScript.get().asFile + script.parentFile.mkdirs() + script.writeText( + """ + #!/usr/bin/env bash + set -euo pipefail + POSITIONS='${positions.absolutePath}' + APP='${app.absolutePath}' + OUT='${dmgOutput.get().asFile.absolutePath}' + DIST='${dmgDistDir.get().asFile.absolutePath}' + STAGING="${'$'}{TMPDIR:-/tmp}/fromchat-dmg-staging-${'$'}RANDOM" + rm -rf "${'$'}OUT" "${'$'}STAGING" + mkdir -p "${'$'}STAGING" + cp -R "${'$'}APP" "${'$'}STAGING/" + WINDOW_SIZE=($(node -e "const c=require('${'$'}POSITIONS').createDmg; console.log(c.windowSize.join(' '))")) + ICON_SIZE=$(node -e "console.log(require('${'$'}POSITIONS').createDmg.iconSize)") + APP_POS=($(node -e "const i=require('${'$'}POSITIONS').createDmg.icons.find(x=>x[0]==='FromChat.app'); console.log(i[1], i[2])")) + APPS_POS=($(node -e "const i=require('${'$'}POSITIONS').createDmg.icons.find(x=>x[0]==='Applications'); console.log(i[1], i[2])")) + ( + cd "${'$'}DIST" + create-dmg \ + --volname "FromChat" \ + --window-size "${'$'}{WINDOW_SIZE[0]}" "${'$'}{WINDOW_SIZE[1]}" \ + --icon-size "${'$'}ICON_SIZE" \ + --icon "FromChat.app" "${'$'}{APP_POS[0]}" "${'$'}{APP_POS[1]}" \ + --hide-extension "FromChat.app" \ + --app-drop-link "${'$'}{APPS_POS[0]}" "${'$'}{APPS_POS[1]}" \ + --app-drop-link-name "Программы" \ + --text-size 14 \ + --background "dmg-background@2x.png" \ + "${'$'}OUT" \ + "${'$'}STAGING" + ) + rm -rf "${'$'}STAGING" + """.trimIndent() + "\n", + ) + script.setExecutable(true) + commandLine(script.absolutePath) + } +} + +tasks.registerPackageDmgTask( + name = "packageTestDmg", + appBundle = debugAppBundle, + dmgOutput = testDmgOutput, + distributableTask = "createDistributable", + scriptName = "package-test-dmg.sh", +).configure { + description = "Build debug app bundle, export DMG background, and package FromChat-test.dmg." +} + +tasks.registerPackageDmgTask( + name = "packageReleaseDmg", + appBundle = releaseAppBundle, + dmgOutput = releaseDmgOutput, + distributableTask = "createReleaseDistributable", + scriptName = "package-release-dmg.sh", +).configure { + description = + "Build release app (ProGuard + jpackage), export DMG background, and package FromChat.dmg. ProGuard may take 10–20 minutes." +} + +tasks.register("packageDmg") { + group = "compose desktop" + description = "Alias for packageReleaseDmg (full release DMG pipeline)." + dependsOn("packageReleaseDmg") +} diff --git a/app/desktop/dmg-background/dmg-config.json b/app/desktop/dmg-background/dmg-config.json index 4f0f5c4..908048f 100644 --- a/app/desktop/dmg-background/dmg-config.json +++ b/app/desktop/dmg-background/dmg-config.json @@ -1,6 +1,8 @@ { "appLabel": "FromChat", "applicationsLabel": "Программы", - "primaryColor": "#dabaf9" + "primaryColor": "#dabaf9", + "titleBarInset": 22, + "windowChromeBottom": 22 } diff --git a/app/desktop/dmg-background/export.mjs b/app/desktop/dmg-background/export.mjs index c27bbd2..c0168f1 100644 --- a/app/desktop/dmg-background/export.mjs +++ b/app/desktop/dmg-background/export.mjs @@ -147,7 +147,7 @@ function buildPositionsJson(measured) { }, createDmg: { windowSize: [round(artboard.width), round(artboard.height)], - iconSize: Math.round(Math.min(app.width, app.height) * 0.86), + iconSize: Math.round(Math.min(app.width, app.height) * 0.88), icons: [ ["FromChat.app", round(app.centerX), round(app.centerY)], ["Applications", round(applications.centerX), round(applications.centerY)], @@ -157,6 +157,20 @@ function buildPositionsJson(measured) { }; } +async function applyConfig(page, config) { + const windowChromeBottom = config.windowChromeBottom ?? config.titleBarInset ?? 22; + await page.evaluate( + ({ bottom, primaryColor }) => { + document.documentElement.style.setProperty("--window-chrome-bottom", `${bottom}px`); + if (primaryColor) { + document.documentElement.style.setProperty("--primary-pill", primaryColor); + } + }, + { bottom: windowChromeBottom, primaryColor: config.primaryColor }, + ); + return windowChromeBottom; +} + async function applyLabelsAndMeasure(page, config, measured) { // Set labels from config into DOM, measure their rendered widths and apply inline styles, // then position the label pills centered under measured slot centers. @@ -188,43 +202,23 @@ async function applyLabelsAndMeasure(page, config, measured) { return w; }; - const horizPad = 18; + const horizPad = 24; const appW = measureTextWidth(appLabel); const appsW = measureTextWidth(appsLabel); + // Finder draws labels below icons; placeholders stay invisible in the PNG. + const labelGapBelowSlot = -1; - if (appLabel) { - appLabel.style.width = (appW + horizPad) + "px"; - appLabel.style.padding = "4px 9px"; - } - if (appsLabel) { - appsLabel.style.width = (appsW + horizPad) + "px"; - appsLabel.style.padding = "4px 9px"; - } - - // Position labels using translate-based offsets from 50% (keeps consistent centering and avoids - // coordinate-space mismatches). Compute translateX so that the pill is centered at slot.centerX. - const artboard = document.querySelector("#dmg"); - const artWidth = measured?.artboard?.width || artboard.clientWidth; - const verticalOffset = 54; // px, matches previous visual placement - - const computeTranslateX = (slotCenterX, labelWidth) => { - // desiredLeft = slotCenterX - labelWidth/2 - // with left:50% the origin is artWidth/2, so translateX = desiredLeft - artWidth/2 - return Math.round(slotCenterX - labelWidth / 2 - artWidth / 2); + const placeLabel = (el, slot, textWidth) => { + if (!el || !slot) return; + const width = textWidth + horizPad; + el.style.width = `${width}px`; + el.style.left = `${Math.round(slot.centerX)}px`; + el.style.top = `${Math.round(slot.centerY + slot.height / 2 + labelGapBelowSlot)}px`; + el.style.transform = "translateX(-50%)"; }; - if (appLabel && measured?.slots?.app) { - const s = measured.slots.app; - const tx = computeTranslateX(s.centerX, appW + horizPad); - appLabel.style.left = "50%"; - appLabel.style.transform = `translate(${tx}px, ${verticalOffset}px)`; - } - if (appsLabel && measured?.slots?.applications) { - const s = measured.slots.applications; - const tx = computeTranslateX(s.centerX, appsW + horizPad); - appsLabel.style.left = "50%"; - appsLabel.style.transform = `translate(${tx}px, ${verticalOffset}px)`; - } + placeLabel(appLabel, measured?.slots?.app, appW); + placeLabel(appsLabel, measured?.slots?.applications, appsW); const rectFor = (el) => { if (!el) return null; @@ -259,6 +253,34 @@ async function screenshotArtboard(page, outPath, deviceScaleFactor) { }); } +async function loadConfig() { + try { + const cfgText = await readFileSync(CONFIG_PATH, "utf8"); + return JSON.parse(cfgText); + } catch { + return {}; + } +} + +async function exportAtScale(browser, baseUrl, cfg, scale) { + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + deviceScaleFactor: scale, + }); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.evaluate(async () => { + if (document.fonts?.ready) await document.fonts.ready; + }); + await applyConfig(page, cfg); + const measured = await measureSlots(page); + const labelMetrics = await applyLabelsAndMeasure(page, cfg, measured); + const suffix = scale === 2 ? "@2x" : ""; + await screenshotArtboard(page, path.join(DIST, `dmg-background${suffix}.png`), scale); + await context.close(); + return { measured, labelMetrics }; +} + async function main() { await mkdir(DIST, { recursive: true }); const { server, port } = await startStaticServer(); @@ -266,56 +288,20 @@ async function main() { let browser; try { browser = await chromium.launch({ headless: true }); + const cfg = await loadConfig(); - { - const context = await browser.newContext({ - viewport: { width: 1280, height: 900 }, - deviceScaleFactor: 1, - }); - const page = await context.newPage(); - await page.goto(baseUrl, { waitUntil: "networkidle" }); - await page.evaluate(async () => { - if (document.fonts?.ready) await document.fonts.ready; - }); - // load config - let cfg = {}; - try { - const cfgText = await readFileSync(CONFIG_PATH, "utf8"); - cfg = JSON.parse(cfgText); - } catch (e) { - cfg = {}; - } - // measure slots first - const measured = await measureSlots(page); - // apply labels/pill widths and position them based on measured slot coordinates - const labelMetrics = await applyLabelsAndMeasure(page, cfg, measured); - console.log("label metrics:", JSON.stringify(labelMetrics)); - const positions = buildPositionsJson(measured); - // attach label metrics to the output so build tasks can pre-calc sizes - positions.labels = labelMetrics.labels; - await writeFile( - path.join(DIST, "icon-positions.json"), - `${JSON.stringify(positions, null, 2)}\n`, - "utf8", - ); - await screenshotArtboard(page, path.join(DIST, "dmg-background.png"), 1); - await context.close(); - console.log(`canvas ${positions.canvas.width}×${positions.canvas.height}`); - } + const { measured, labelMetrics } = await exportAtScale(browser, baseUrl, cfg, 1); + console.log("label metrics:", JSON.stringify(labelMetrics)); + const positions = buildPositionsJson(measured); + positions.labels = labelMetrics.labels; + await writeFile( + path.join(DIST, "icon-positions.json"), + `${JSON.stringify(positions, null, 2)}\n`, + "utf8", + ); + console.log(`canvas ${positions.canvas.width}×${positions.canvas.height}`); - { - const context = await browser.newContext({ - viewport: { width: 1280, height: 900 }, - deviceScaleFactor: 2, - }); - const page = await context.newPage(); - await page.goto(baseUrl, { waitUntil: "networkidle" }); - await page.evaluate(async () => { - if (document.fonts?.ready) await document.fonts.ready; - }); - await screenshotArtboard(page, path.join(DIST, "dmg-background@2x.png"), 2); - await context.close(); - } + await exportAtScale(browser, baseUrl, cfg, 2); console.log(`wrote ${path.join(DIST, "dmg-background.png")}`); console.log(`wrote ${path.join(DIST, "dmg-background@2x.png")}`); diff --git a/app/desktop/dmg-background/index.html b/app/desktop/dmg-background/index.html index 42d6414..27c1907 100644 --- a/app/desktop/dmg-background/index.html +++ b/app/desktop/dmg-background/index.html @@ -15,6 +15,7 @@ -->