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 desktopWindowIconPng = layout.projectDirectory.file("src/main/resources/app_window_icon.png")
val desktopWindowIconIcns = layout.projectDirectory.file("icons/app_window_icon.icns") 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 { compose.desktop {
application { application {
mainClass = "ru.fromchat.desktop.MainKt" mainClass = "ru.fromchat.desktop.MainKt"
javaHome = resolvePackagingJdkHome()
buildTypes.release.proguard {
configurationFiles.from(project.file("proguard-rules.pro"))
}
nativeDistributions { nativeDistributions {
packageName = "FromChat" packageName = "FromChat"
packageVersion = "1.0.0" packageVersion = "1.0.0"
description = "FromChat desktop" description = "FromChat desktop"
copyright = "© FromChat" copyright = "© FromChat"
includeAllModules = true
modules( modules(
"javafx.base", "javafx.base",
@@ -307,3 +334,151 @@ private fun openjfxClassifier(): String {
else -> "linux" 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", "appLabel": "FromChat",
"applicationsLabel": "Программы", "applicationsLabel": "Программы",
"primaryColor": "#dabaf9" "primaryColor": "#dabaf9",
"titleBarInset": 22,
"windowChromeBottom": 22
} }
+58 -72
View File
@@ -147,7 +147,7 @@ function buildPositionsJson(measured) {
}, },
createDmg: { createDmg: {
windowSize: [round(artboard.width), round(artboard.height)], 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: [ icons: [
["FromChat.app", round(app.centerX), round(app.centerY)], ["FromChat.app", round(app.centerX), round(app.centerY)],
["Applications", round(applications.centerX), round(applications.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) { async function applyLabelsAndMeasure(page, config, measured) {
// Set labels from config into DOM, measure their rendered widths and apply inline styles, // Set labels from config into DOM, measure their rendered widths and apply inline styles,
// then position the label pills centered under measured slot centers. // then position the label pills centered under measured slot centers.
@@ -188,43 +202,23 @@ async function applyLabelsAndMeasure(page, config, measured) {
return w; return w;
}; };
const horizPad = 18; const horizPad = 24;
const appW = measureTextWidth(appLabel); const appW = measureTextWidth(appLabel);
const appsW = measureTextWidth(appsLabel); const appsW = measureTextWidth(appsLabel);
// Finder draws labels below icons; placeholders stay invisible in the PNG.
const labelGapBelowSlot = -1;
if (appLabel) { const placeLabel = (el, slot, textWidth) => {
appLabel.style.width = (appW + horizPad) + "px"; if (!el || !slot) return;
appLabel.style.padding = "4px 9px"; const width = textWidth + horizPad;
} el.style.width = `${width}px`;
if (appsLabel) { el.style.left = `${Math.round(slot.centerX)}px`;
appsLabel.style.width = (appsW + horizPad) + "px"; el.style.top = `${Math.round(slot.centerY + slot.height / 2 + labelGapBelowSlot)}px`;
appsLabel.style.padding = "4px 9px"; el.style.transform = "translateX(-50%)";
}
// 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);
}; };
if (appLabel && measured?.slots?.app) { placeLabel(appLabel, measured?.slots?.app, appW);
const s = measured.slots.app; placeLabel(appsLabel, measured?.slots?.applications, appsW);
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)`;
}
const rectFor = (el) => { const rectFor = (el) => {
if (!el) return null; 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() { async function main() {
await mkdir(DIST, { recursive: true }); await mkdir(DIST, { recursive: true });
const { server, port } = await startStaticServer(); const { server, port } = await startStaticServer();
@@ -266,56 +288,20 @@ async function main() {
let browser; let browser;
try { try {
browser = await chromium.launch({ headless: true }); browser = await chromium.launch({ headless: true });
const cfg = await loadConfig();
{ const { measured, labelMetrics } = await exportAtScale(browser, baseUrl, cfg, 1);
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)); console.log("label metrics:", JSON.stringify(labelMetrics));
const positions = buildPositionsJson(measured); const positions = buildPositionsJson(measured);
// attach label metrics to the output so build tasks can pre-calc sizes
positions.labels = labelMetrics.labels; positions.labels = labelMetrics.labels;
await writeFile( await writeFile(
path.join(DIST, "icon-positions.json"), path.join(DIST, "icon-positions.json"),
`${JSON.stringify(positions, null, 2)}\n`, `${JSON.stringify(positions, null, 2)}\n`,
"utf8", "utf8",
); );
await screenshotArtboard(page, path.join(DIST, "dmg-background.png"), 1);
await context.close();
console.log(`canvas ${positions.canvas.width}×${positions.canvas.height}`); console.log(`canvas ${positions.canvas.width}×${positions.canvas.height}`);
}
{ await exportAtScale(browser, baseUrl, cfg, 2);
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();
}
console.log(`wrote ${path.join(DIST, "dmg-background.png")}`); console.log(`wrote ${path.join(DIST, "dmg-background.png")}`);
console.log(`wrote ${path.join(DIST, "dmg-background@2x.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 id="dmg" role="img" aria-label="Фон установки FromChat (DMG)">
<div class="window-chrome-bleed" aria-hidden="true"></div>
<header class="brand"> <header class="brand">
<div class="brand__logo-wrap"> <div class="brand__logo-wrap">
<div class="brand__logo" aria-hidden="true"></div> <div class="brand__logo" aria-hidden="true"></div>
@@ -25,22 +26,22 @@
<div class="install"> <div class="install">
<div id="slot-app" class="slot" aria-hidden="true"></div> <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"> <div class="arrow" aria-hidden="true">
<!-- Material Symbols Rounded arrow_forward (Google Fonts / fonts.gstatic) --> <!-- 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"/> <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> </svg>
</div> </div>
<div id="slot-applications" class="slot" aria-hidden="true"></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>
<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"> <p class="notice">
Если macOS блокирует приложение, откройте Если macOS блокирует приложение, откройте
<strong>Системные настройки → Конфиденциальность и безопасность</strong> <strong>Системные настройки → Конфиденциальность и безопасность</strong>
и разрешите его — или нажмите и разрешите его.
<strong>правой кнопкой → Открыть</strong>.
</p> </p>
</div> </div>
</body> </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); --surface: rgb(21, 18, 24);
--on-surface: rgba(230, 224, 233, 0.92); --on-surface: rgba(230, 224, 233, 0.92);
--on-surface-muted: rgba(230, 224, 233, 0.72); --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: 96px;
--slot-size: 88px;
--slot-radius: 14px;
--outline: rgba(230, 224, 233, 0.30);
} }
#dmg { #dmg {
position: relative; position: relative;
width: 540px; width: 540px;
/* increase artboard height to avoid bottom cropping on Retina displays and room for title bar */ height: calc(var(--content-height) + var(--window-chrome-bottom));
height: 380px;
overflow: hidden; overflow: hidden;
color: var(--on-surface); color: var(--on-surface);
font-family: "Google Sans", system-ui, sans-serif; font-family: "Google Sans", system-ui, sans-serif;
@@ -93,17 +91,27 @@ body {
isolation: isolate; 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 { .brand {
position: absolute; position: absolute;
/* move brand further down to avoid title-bar overlap in Finder window */ top: 28px;
top: 44px;
left: 0; left: 0;
right: 0; right: 0;
z-index: 1; z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
} }
.brand__logo-wrap { .brand__logo-wrap {
@@ -120,7 +128,6 @@ body {
position: absolute; position: absolute;
width: 1200px; width: 1200px;
height: 1200px; height: 1200px;
/* move radial blur down slightly to reduce pattern frequency near center */
bottom: -180px; bottom: -180px;
opacity: 0.48; opacity: 0.48;
left: calc(50% - 600px); left: calc(50% - 600px);
@@ -131,13 +138,13 @@ body {
} }
.brand__logo { .brand__logo {
width: 52px; width: 68px;
height: 52px; height: 68px;
background-image: url("assets/logo_square.png"); background-image: url("assets/logo_square.png");
background-size: cover; background-size: cover;
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
border-radius: 13px; border-radius: 17px;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -145,8 +152,7 @@ body {
.brand__wordmark { .brand__wordmark {
font-family: "Montserrat", "Google Sans", sans-serif; font-family: "Montserrat", "Google Sans", sans-serif;
font-weight: 700; font-weight: 700;
/* slightly larger for Retina */ font-size: 30px;
font-size: 24px;
letter-spacing: -0.03em; letter-spacing: -0.03em;
line-height: 1; line-height: 1;
background: var(--brand-gradient); background: var(--brand-gradient);
@@ -161,42 +167,37 @@ body {
.brand__tagline { .brand__tagline {
font-family: "Google Sans", sans-serif; font-family: "Google Sans", sans-serif;
font-weight: 500; font-weight: 500;
font-size: 11px; font-size: 13px;
letter-spacing: 0.01em; 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; position: relative;
z-index: 1; z-index: 1;
} }
.install { .install {
position: absolute; position: absolute;
/* move install row a bit lower to balance larger artboard */ /* Vertically centered in the gap between brand block (~151px) and notice (~292px). */
top: 170px; top: 174px;
left: 0; left: 0;
right: 0; right: 0;
z-index: 1; z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 18px; gap: 20px;
} }
.slot { .slot {
width: var(--slot-size); width: var(--slot-size);
height: var(--slot-size); height: var(--slot-size);
border-radius: var(--slot-radius); border: none;
/* subtle solid outline plus a gentle reduced-frequency dot pattern */ background: transparent;
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;
flex-shrink: 0; flex-shrink: 0;
} }
.arrow { .arrow {
width: 36px; width: 44px;
height: 36px; height: 44px;
flex-shrink: 0; flex-shrink: 0;
color: rgba(230, 224, 233, 0.88); color: rgba(230, 224, 233, 0.88);
display: flex; display: flex;
@@ -206,21 +207,20 @@ body {
.arrow svg { .arrow svg {
display: block; display: block;
width: 36px; width: 44px;
height: 36px; height: 44px;
} }
.notice { .notice {
position: absolute; position: absolute;
left: 20px; left: 20px;
right: 20px; right: 20px;
/* increase bottom padding to avoid cropping */ bottom: calc(var(--window-chrome-bottom) + 16px);
bottom: 20px;
z-index: 1; z-index: 1;
text-align: center; text-align: center;
font-family: "Google Sans", sans-serif; font-family: "Google Sans", sans-serif;
font-weight: 400; font-weight: 400;
font-size: 10px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
color: var(--on-surface-muted); color: var(--on-surface-muted);
text-wrap: balance; text-wrap: balance;
@@ -231,39 +231,31 @@ body {
color: var(--on-surface); color: var(--on-surface);
} }
/* Preview-only slot labels (white text). Finder will draw real labels; these are used for the exported preview. */
.slot__label { .slot__label {
position: absolute; position: absolute;
margin-top: 12px; display: inline-flex;
align-items: center;
justify-content: center;
font-family: "Google Sans", system-ui, sans-serif; font-family: "Google Sans", system-ui, sans-serif;
font-size: 12px; font-size: 13px;
color: white; /* white app names as requested */ font-weight: 500;
line-height: 1.2;
letter-spacing: 0.01em;
color: var(--primary-pill, #7e22ce);
text-align: center; text-align: center;
width: auto; white-space: nowrap;
z-index: 3; z-index: 3;
pointer-events: none; pointer-events: none;
} box-sizing: border-box;
.slot__label[data-for="slot-app"], min-height: 26px;
.slot__label[data-for="slot-applications"] { padding: 5px 12px;
/* positioning is set dynamically by the export script */
} }
/* pill background behind label using ::before */
.slot__label {
position: absolute;
display: inline-block;
line-height: 1;
}
.slot__label::before { .slot__label::before {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 28px;
border-radius: 9999px; border-radius: 9999px;
background: var(--primary-pill, #7e22ce); background: var(--primary-pill, #7e22ce);
z-index: 2; z-index: -1;
display: block;
} }
+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 compileSdk = 37
} }
jvmToolchain(17)
compilerOptions { compilerOptions {
freeCompilerArgs.addAll("-Xexpect-actual-classes") 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 package ru.fromchat.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints 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.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.auth_get_started import ru.fromchat.auth_get_started
import ru.fromchat.auth_welcome_tagline import ru.fromchat.auth_welcome_tagline
import ru.fromchat.auth_welcome_title import ru.fromchat.auth_welcome_title
import ru.fromchat.logo_square
import ru.fromchat.ui.auth.PreAuthOverflowMenu import ru.fromchat.ui.auth.PreAuthOverflowMenu
import ru.fromchat.ui.components.ActionButton import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
@@ -82,8 +84,8 @@ fun WelcomeScreen(
verticalArrangement = if (wide) Arrangement.Center else Arrangement.Center, verticalArrangement = if (wide) Arrangement.Center else Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
AsyncImage( Image(
model = Res.getUri("drawable/logo_square.png"), painter = painterResource(Res.drawable.logo_square),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(112.dp) .size(112.dp)
@@ -13,6 +13,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.supportClipboardManagerImpl import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.action_delete import ru.fromchat.action_delete
import ru.fromchat.action_mark_read import ru.fromchat.action_mark_read
import ru.fromchat.logo_square
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ChatListSync import ru.fromchat.api.ChatListSync
import ru.fromchat.api.StatusSubscriptionCoordinator import ru.fromchat.api.StatusSubscriptionCoordinator
@@ -398,8 +400,8 @@ private fun ChatsNormalTopBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
) { ) {
AsyncImage( Image(
model = Res.getUri("drawable/logo_square.png"), painter = painterResource(Res.drawable.logo_square),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
@@ -1,5 +1,6 @@
package ru.fromchat.ui.main.settings package ru.fromchat.ui.main.settings
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.ui.Website import com.pr0gramm3r101.ui.Website
import com.pr0gramm3r101.utils.conditional import com.pr0gramm3r101.utils.conditional
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.AppBuildInfo import ru.fromchat.AppBuildInfo
@@ -43,6 +44,7 @@ import ru.fromchat.about_link_terms
import ru.fromchat.about_link_website import ru.fromchat.about_link_website
import ru.fromchat.about_version import ru.fromchat.about_version
import ru.fromchat.app_desc import ru.fromchat.app_desc
import ru.fromchat.logo_square
import ru.fromchat.legal.DocumentType import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.BrandTitle import ru.fromchat.ui.components.BrandTitle
@@ -91,13 +93,13 @@ fun AboutScreen() {
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box(Modifier.padding(bottom = 12.dp)) { Box(Modifier.padding(bottom = 12.dp)) {
AsyncImage( Image(
model = Res.getUri("drawable/logo_square.png"), painter = painterResource(Res.drawable.logo_square),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
.size(88.dp) .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.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.currentDeviceInfo import com.pr0gramm3r101.utils.currentDeviceInfo
@@ -81,8 +84,10 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res 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.ApiClient
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.schema.user.devices.DeviceSessionInfo 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" 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()) { return when (resolveDeviceOsName(d)?.lowercase()) {
"windows", "windows nt" -> "drawable/os_windows.svg" "windows", "windows nt" -> Res.drawable.os_windows
"mac", "mac os", "macos" -> "drawable/os_macos.svg" "mac", "mac os", "macos" -> Res.drawable.os_macos
"linux" -> "drawable/os_linux.svg" "linux" -> Res.drawable.os_linux
else -> null else -> null
} }
} }
@@ -537,8 +542,8 @@ fun DevicesScreen(onBack: () -> Unit) {
), ),
leadingContent = { leadingContent = {
if (osLogo != null) { if (osLogo != null) {
AsyncImage( Image(
model = Res.getUri(osLogo), painter = painterResource(osLogo),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
+2
View File
@@ -6,6 +6,8 @@ plugins {
} }
kotlin { kotlin {
jvmToolchain(17)
compilerOptions { compilerOptions {
freeCompilerArgs.addAll("-Xexpect-actual-classes") freeCompilerArgs.addAll("-Xexpect-actual-classes")
} }