macOS DMG installer

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-08-20 16:04:28 +03:00
Unverified
parent c903120ed4
commit d25cb7e57a
14 changed files with 429 additions and 164 deletions
+175
View File
@@ -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<Exec>("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<Copy>("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<Exec>("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<Directory>,
dmgOutput: Provider<RegularFile>,
distributableTask: String,
scriptName: String,
) = register<Exec>(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 1020 minutes."
}
tasks.register("packageDmg") {
group = "compose desktop"
description = "Alias for packageReleaseDmg (full release DMG pipeline)."
dependsOn("packageReleaseDmg")
}
+3 -1
View File
@@ -1,6 +1,8 @@
{
"appLabel": "FromChat",
"applicationsLabel": "Программы",
"primaryColor": "#dabaf9"
"primaryColor": "#dabaf9",
"titleBarInset": 22,
"windowChromeBottom": 22
}
+58 -72
View File
@@ -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);
const { measured, labelMetrics } = await exportAtScale(browser, baseUrl, cfg, 1);
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 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")}`);
+6 -5
View File
@@ -15,6 +15,7 @@
-->
<div id="dmg" role="img" aria-label="Фон установки FromChat (DMG)">
<div class="window-chrome-bleed" aria-hidden="true"></div>
<header class="brand">
<div class="brand__logo-wrap">
<div class="brand__logo" aria-hidden="true"></div>
@@ -25,22 +26,22 @@
<div class="install">
<div id="slot-app" class="slot" aria-hidden="true"></div>
<div class="slot__label" data-for="slot-app" aria-hidden="true">FromChat</div>
<div class="arrow" aria-hidden="true">
<!-- Material Symbols Rounded arrow_forward (Google Fonts / fonts.gstatic) -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" width="36" height="36" fill="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" width="44" height="44" fill="currentColor">
<path d="M686-450H190q-13 0-21.5-8.5T160-480q0-13 8.5-21.5T190-510h496L459-737q-9-9-9-21t9-21q9-9 21-9t21 9l278 278q5 5 7 10t2 11q0 6-2 11t-7 10L501-181q-9 9-21 9t-21-9q-9-9-9-21t9-21l227-227Z"/>
</svg>
</div>
<div id="slot-applications" class="slot" aria-hidden="true"></div>
<div class="slot__label" data-for="slot-applications" aria-hidden="true">Программы</div>
</div>
<div class="slot__label" data-for="slot-app" aria-hidden="true">FromChat</div>
<div class="slot__label" data-for="slot-applications" aria-hidden="true">Программы</div>
<p class="notice">
Если macOS блокирует приложение, откройте
<strong>Системные настройки → Конфиденциальность и безопасность</strong>
и разрешите его — или нажмите
<strong>правой кнопкой → Открыть</strong>.
и разрешите его.
</p>
</div>
</body>
+57
View File
@@ -0,0 +1,57 @@
{
"name": "fromchat-dmg-background-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fromchat-dmg-background-tools",
"dependencies": {
"playwright": "1.52.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.52.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+47 -55
View File
@@ -74,18 +74,16 @@ body {
--surface: rgb(21, 18, 24);
--on-surface: rgba(230, 224, 233, 0.92);
--on-surface-muted: rgba(230, 224, 233, 0.72);
/* Slightly smaller slots and outlines so they fit naturally with labels */
/* Extra strip at the bottom; Finder clips it so the visible area lines up with the title bar. */
--window-chrome-bottom: 22px;
--content-height: 380px;
--slot-size: 96px;
--slot-size: 88px;
--slot-radius: 14px;
--outline: rgba(230, 224, 233, 0.30);
}
#dmg {
position: relative;
width: 540px;
/* increase artboard height to avoid bottom cropping on Retina displays and room for title bar */
height: 380px;
height: calc(var(--content-height) + var(--window-chrome-bottom));
overflow: hidden;
color: var(--on-surface);
font-family: "Google Sans", system-ui, sans-serif;
@@ -93,17 +91,27 @@ body {
isolation: isolate;
}
/* Clipped by Finder at the bottom — keeps the designed content aligned under the title bar. */
.window-chrome-bleed {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--window-chrome-bottom);
background-color: var(--surface);
z-index: 0;
}
.brand {
position: absolute;
/* move brand further down to avoid title-bar overlap in Finder window */
top: 44px;
top: 28px;
left: 0;
right: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
gap: 6px;
}
.brand__logo-wrap {
@@ -120,7 +128,6 @@ body {
position: absolute;
width: 1200px;
height: 1200px;
/* move radial blur down slightly to reduce pattern frequency near center */
bottom: -180px;
opacity: 0.48;
left: calc(50% - 600px);
@@ -131,13 +138,13 @@ body {
}
.brand__logo {
width: 52px;
height: 52px;
width: 68px;
height: 68px;
background-image: url("assets/logo_square.png");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 13px;
border-radius: 17px;
position: relative;
z-index: 1;
}
@@ -145,8 +152,7 @@ body {
.brand__wordmark {
font-family: "Montserrat", "Google Sans", sans-serif;
font-weight: 700;
/* slightly larger for Retina */
font-size: 24px;
font-size: 30px;
letter-spacing: -0.03em;
line-height: 1;
background: var(--brand-gradient);
@@ -161,42 +167,37 @@ body {
.brand__tagline {
font-family: "Google Sans", sans-serif;
font-weight: 500;
font-size: 11px;
font-size: 13px;
letter-spacing: 0.01em;
/* ensure legible light text on dark background (avoid fallback black) */
color: rgba(230,224,233,0.9);
color: rgba(230, 224, 233, 0.9);
position: relative;
z-index: 1;
}
.install {
position: absolute;
/* move install row a bit lower to balance larger artboard */
top: 170px;
/* Vertically centered in the gap between brand block (~151px) and notice (~292px). */
top: 174px;
left: 0;
right: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
gap: 20px;
}
.slot {
width: var(--slot-size);
height: var(--slot-size);
border-radius: var(--slot-radius);
/* subtle solid outline plus a gentle reduced-frequency dot pattern */
border: 1px solid rgba(230,224,233,0.12);
background-color: transparent;
background-image: radial-gradient(rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 32px 32px;
border: none;
background: transparent;
flex-shrink: 0;
}
.arrow {
width: 36px;
height: 36px;
width: 44px;
height: 44px;
flex-shrink: 0;
color: rgba(230, 224, 233, 0.88);
display: flex;
@@ -206,21 +207,20 @@ body {
.arrow svg {
display: block;
width: 36px;
height: 36px;
width: 44px;
height: 44px;
}
.notice {
position: absolute;
left: 20px;
right: 20px;
/* increase bottom padding to avoid cropping */
bottom: 20px;
bottom: calc(var(--window-chrome-bottom) + 16px);
z-index: 1;
text-align: center;
font-family: "Google Sans", sans-serif;
font-weight: 400;
font-size: 10px;
font-size: 12px;
line-height: 1.4;
color: var(--on-surface-muted);
text-wrap: balance;
@@ -231,39 +231,31 @@ body {
color: var(--on-surface);
}
/* Preview-only slot labels (white text). Finder will draw real labels; these are used for the exported preview. */
.slot__label {
position: absolute;
margin-top: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: "Google Sans", system-ui, sans-serif;
font-size: 12px;
color: white; /* white app names as requested */
font-size: 13px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0.01em;
color: var(--primary-pill, #7e22ce);
text-align: center;
width: auto;
white-space: nowrap;
z-index: 3;
pointer-events: none;
}
.slot__label[data-for="slot-app"],
.slot__label[data-for="slot-applications"] {
/* positioning is set dynamically by the export script */
box-sizing: border-box;
min-height: 26px;
padding: 5px 12px;
}
/* pill background behind label using ::before */
.slot__label {
position: absolute;
display: inline-block;
line-height: 1;
}
.slot__label::before {
content: "";
position: absolute;
inset: 0;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 28px;
border-radius: 9999px;
background: var(--primary-pill, #7e22ce);
z-index: 2;
display: block;
z-index: -1;
}
+41
View File
@@ -0,0 +1,41 @@
# ProGuard specializes return types of @JvmMultifileClass actuals (VerifyError: Paragraph vs SkiaParagraph).
# Upstream: https://github.com/JetBrains/compose-multiplatform/pull/5652 (CMP-10488)
-keep,allowshrinking,allowobfuscation class **Kt__* { *; }
# Ktor ContentNegotiation JSON extension (ServiceLoader).
-keep class io.ktor.serialization.kotlinx.json.KotlinxSerializationJsonExtensionProvider { *; }
# Ktor CIO client engine (ServiceLoader).
-keep class io.ktor.client.engine.cio.CIOEngineContainer { *; }
# SQLite JDBC driver + JNI natives (ProGuard drops native methods otherwise).
-keepclasseswithmembernames class * {
native <methods>;
}
-keep class org.sqlite.** { *; }
# BouncyCastle security providers (ServiceLoader java.security.Provider).
-keep class org.bouncycastle.jce.provider.BouncyCastleProvider { *; }
-keep class org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider { *; }
# Cryptography provider (ServiceLoader).
-keep class dev.whyoleg.cryptography.providers.jdk.JdkCryptographyProviderContainer { *; }
# Coil fetchers/decoders registered via ServiceLoader (network + SVG).
-keep class coil3.network.ktor3.internal.KtorNetworkFetcherServiceLoaderTarget { *; }
-keep class coil3.svg.internal.SvgDecoderServiceLoaderTarget { *; }
# Compose Multiplatform generated resource accessors (logo, strings, fonts).
-keep class ru.fromchat.Drawable*_commonMainKt { *; }
-keep class ru.fromchat.String*_commonMainKt { *; }
-keep class ru.fromchat.Font*_commonMainKt { *; }
-keep class ru.fromchat.ActualResourceCollectorsKt { *; }
-keep class ru.fromchat.Res { *; }
-keep class ru.fromchat.Res$* { *; }
# App JSON models.
-keep @kotlinx.serialization.Serializable class ru.fromchat.** { *; }
-keepclassmembers @kotlinx.serialization.Serializable class ru.fromchat.** {
<fields>;
<init>(...);
}
+2
View File
@@ -71,6 +71,8 @@ kotlin {
compileSdk = 37
}
jvmToolchain(17)
compilerOptions {
freeCompilerArgs.addAll("-Xexpect-actual-classes")
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 94 KiB

@@ -1,5 +1,6 @@
package ru.fromchat.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -26,13 +27,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.auth_get_started
import ru.fromchat.auth_welcome_tagline
import ru.fromchat.auth_welcome_title
import ru.fromchat.logo_square
import ru.fromchat.ui.auth.PreAuthOverflowMenu
import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.Text
@@ -82,8 +84,8 @@ fun WelcomeScreen(
verticalArrangement = if (wide) Arrangement.Center else Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = Res.getUri("drawable/logo_square.png"),
Image(
painter = painterResource(Res.drawable.logo_square),
contentDescription = null,
modifier = Modifier
.size(112.dp)
@@ -13,6 +13,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -96,16 +97,17 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.action_delete
import ru.fromchat.action_mark_read
import ru.fromchat.logo_square
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ChatListSync
import ru.fromchat.api.StatusSubscriptionCoordinator
@@ -398,8 +400,8 @@ private fun ChatsNormalTopBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
AsyncImage(
model = Res.getUri("drawable/logo_square.png"),
Image(
painter = painterResource(Res.drawable.logo_square),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
@@ -1,5 +1,6 @@
package ru.fromchat.ui.main.settings
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -26,11 +27,11 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.ui.Website
import com.pr0gramm3r101.utils.conditional
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.AppBuildInfo
@@ -43,6 +44,7 @@ import ru.fromchat.about_link_terms
import ru.fromchat.about_link_website
import ru.fromchat.about_version
import ru.fromchat.app_desc
import ru.fromchat.logo_square
import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.BrandTitle
@@ -91,13 +93,13 @@ fun AboutScreen() {
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(Modifier.padding(bottom = 12.dp)) {
AsyncImage(
model = Res.getUri("drawable/logo_square.png"),
Image(
painter = painterResource(Res.drawable.logo_square),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.size(88.dp)
.clip(RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp)),
)
}
@@ -6,6 +6,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -63,7 +64,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.currentDeviceInfo
@@ -81,8 +84,10 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.os_linux
import ru.fromchat.os_macos
import ru.fromchat.os_windows
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.schema.user.devices.DeviceSessionInfo
@@ -238,11 +243,11 @@ private fun deviceSessionOsLine(d: DeviceSessionInfo): String? {
return if (version.isBlank()) os else "$os $version"
}
private fun deviceSessionLogoResource(d: DeviceSessionInfo): String? {
private fun deviceSessionLogoResource(d: DeviceSessionInfo): DrawableResource? {
return when (resolveDeviceOsName(d)?.lowercase()) {
"windows", "windows nt" -> "drawable/os_windows.svg"
"mac", "mac os", "macos" -> "drawable/os_macos.svg"
"linux" -> "drawable/os_linux.svg"
"windows", "windows nt" -> Res.drawable.os_windows
"mac", "mac os", "macos" -> Res.drawable.os_macos
"linux" -> Res.drawable.os_linux
else -> null
}
}
@@ -537,8 +542,8 @@ fun DevicesScreen(onBack: () -> Unit) {
),
leadingContent = {
if (osLogo != null) {
AsyncImage(
model = Res.getUri(osLogo),
Image(
painter = painterResource(osLogo),
contentDescription = null,
modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit,
+2
View File
@@ -6,6 +6,8 @@ plugins {
}
kotlin {
jvmToolchain(17)
compilerOptions {
freeCompilerArgs.addAll("-Xexpect-actual-classes")
}