Prepare app build

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>

# Conflicts:
#	.gitignore
This commit is contained in:
2026-07-27 21:01:49 +03:00
Unverified
parent 846c1db3d1
commit 8a1b661235
23 changed files with 859 additions and 130 deletions
+4 -1
View File
@@ -33,4 +33,7 @@ google-services.json
premortem-transcript-*.md
premortem-report-*.html
target/
target/
node_modules
preview-dist
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -0,0 +1,6 @@
{
"appLabel": "FromChat",
"applicationsLabel": "Программы",
"primaryColor": "#dabaf9"
}
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env node
/**
* Export the DMG artboard to PNG + icon slot positions for create-dmg.
*
* Args: <stageDir> <distDir>
*
* Outputs under distDir:
* dmg-background.png
* dmg-background@2x.png
* icon-positions.json
*/
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { readFile as readFileSync } from "node:fs/promises";
const [stageArg, distArg] = process.argv.slice(2);
if (!stageArg || !distArg) {
console.error("Usage: node export.mjs <stageDir> <distDir>");
process.exit(1);
}
const ROOT = path.resolve(stageArg);
const DIST = path.resolve(distArg);
const CONFIG_PATH = path.join(ROOT, "dmg-config.json");
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".ttf": "font/ttf",
};
function startStaticServer() {
return new Promise((resolve, reject) => {
const server = createServer(async (req, res) => {
try {
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
const rel = urlPath === "/" ? "/index.html" : urlPath;
const filePath = path.normalize(path.join(ROOT, rel));
if (!filePath.startsWith(ROOT)) {
res.writeHead(403).end("Forbidden");
return;
}
const data = await readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, {
"Content-Type": MIME[ext] ?? "application/octet-stream",
"Cache-Control": "no-store",
});
res.end(data);
} catch {
res.writeHead(404).end("Not found");
}
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (!addr || typeof addr === "string") {
reject(new Error("Failed to bind static server"));
return;
}
resolve({ server, port: addr.port });
});
server.on("error", reject);
});
}
function round(n) {
return Math.round(n * 100) / 100;
}
async function measureSlots(page) {
return page.evaluate(() => {
const artboard = document.querySelector("#dmg");
if (!artboard) throw new Error("#dmg not found");
const artRect = artboard.getBoundingClientRect();
const measureSlot = (id) => {
const el = document.querySelector("#" + id);
if (!el) throw new Error("#" + id + " not found");
const r = el.getBoundingClientRect();
const width = r.width;
const height = r.height;
const x = r.left - artRect.left;
const y = r.top - artRect.top;
return {
id,
x,
y,
width,
height,
centerX: x + width / 2,
centerY: y + height / 2,
};
};
return {
artboard: {
width: artboard.clientWidth,
height: artboard.clientHeight,
},
slots: {
app: measureSlot("slot-app"),
applications: measureSlot("slot-applications"),
},
};
});
}
function buildPositionsJson(measured) {
const { artboard, slots } = measured;
const app = slots.app;
const applications = slots.applications;
return {
canvas: {
width: round(artboard.width),
height: round(artboard.height),
background: "dist/dmg-background.png",
background2x: "dist/dmg-background@2x.png",
},
// Include both the standard "Applications" name and the localized "Программы"
// so create-dmg (Finder) receives the expected English link while we also
// keep the localized name for our records and packaging logic.
icons: {
"FromChat.app": {
x: round(app.centerX),
y: round(app.centerY),
width: round(app.width),
height: round(app.height),
slotId: "slot-app",
},
"Applications": {
x: round(applications.centerX),
y: round(applications.centerY),
width: round(applications.width),
height: round(applications.height),
slotId: "slot-applications",
},
"Программы": {
x: round(applications.centerX),
y: round(applications.centerY),
width: round(applications.width),
height: round(applications.height),
slotId: "slot-applications",
},
},
createDmg: {
windowSize: [round(artboard.width), round(artboard.height)],
iconSize: Math.round(Math.min(app.width, app.height) * 0.86),
icons: [
["FromChat.app", round(app.centerX), round(app.centerY)],
["Applications", round(applications.centerX), round(applications.centerY)],
["Программы", round(applications.centerX), round(applications.centerY)],
],
},
};
}
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.
return page.evaluate(
({ cfg, measured }) => {
document.documentElement.style.setProperty("--primary-pill", cfg.primaryColor || "#7e22ce");
const setLabel = (selector, text) => {
const el = document.querySelector(selector);
if (!el) return null;
el.textContent = text;
return el;
};
const appLabel = setLabel('.slot__label[data-for="slot-app"]', cfg.appLabel || "FromChat");
const appsLabel = setLabel('.slot__label[data-for="slot-applications"]', cfg.applicationsLabel || "Программы");
const measureTextWidth = (el) => {
if (!el) return 0;
const span = document.createElement("span");
span.style.visibility = "hidden";
span.style.position = "absolute";
span.style.whiteSpace = "nowrap";
span.style.font = window.getComputedStyle(el).font;
span.textContent = el.textContent;
document.body.appendChild(span);
const w = Math.ceil(span.getBoundingClientRect().width);
document.body.removeChild(span);
return w;
};
const horizPad = 18;
const appW = measureTextWidth(appLabel);
const appsW = measureTextWidth(appsLabel);
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);
};
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)`;
}
const rectFor = (el) => {
if (!el) return null;
const r = el.getBoundingClientRect();
const bg = getComputedStyle(el, "::before").backgroundColor;
return { left: Math.round(r.left), top: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height), bg };
};
return {
labels: {
app: { text: appLabel?.textContent || "", width: appW + horizPad, rect: rectFor(appLabel) },
applications: { text: appsLabel?.textContent || "", width: appsW + horizPad, rect: rectFor(appsLabel) },
},
};
},
{ cfg: config, measured },
);
}
async function screenshotArtboard(page, outPath, deviceScaleFactor) {
const dmg = page.locator("#dmg");
await dmg.waitFor({ state: "visible" });
await page.evaluate(async () => {
if (document.fonts?.ready) await document.fonts.ready;
});
await page.waitForTimeout(150);
await dmg.screenshot({
path: outPath,
type: "png",
omitBackground: false,
scale: deviceScaleFactor === 2 ? "device" : "css",
});
}
async function main() {
await mkdir(DIST, { recursive: true });
const { server, port } = await startStaticServer();
const baseUrl = `http://127.0.0.1:${port}/`;
let browser;
try {
browser = await chromium.launch({ headless: true });
{
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 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@2x.png")}`);
console.log(`wrote ${path.join(DIST, "icon-positions.json")}`);
} finally {
if (browser) await browser.close();
server.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FromChat — фон DMG</title>
<!-- Montserrat for brand wordmark; Google Sans kept for the rest (local fonts) -->
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<!--
Icon slots (#slot-app, #slot-applications) mark create-dmg / appdmg drop targets.
Empty outlines only — Finder draws the real icons and labels.
-->
<div id="dmg" role="img" aria-label="Фон установки FromChat (DMG)">
<header class="brand">
<div class="brand__logo-wrap">
<div class="brand__logo" aria-hidden="true"></div>
</div>
<div class="brand__wordmark">FromChat</div>
<div class="brand__tagline">Перетащите в «Программы», чтобы установить</div>
</header>
<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">
<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>
<p class="notice">
Если macOS блокирует приложение, откройте
<strong>Системные настройки → Конфиденциальность и безопасность</strong>
и разрешите его — или нажмите
<strong>правой кнопкой → Открыть</strong>.
</p>
</div>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{
"name": "fromchat-dmg-background-tools",
"private": true,
"type": "module",
"dependencies": {
"playwright": "1.52.0"
}
}
+269
View File
@@ -0,0 +1,269 @@
/* Fonts and logo are staged by :app:desktop:stageDmgBackground from composeResources. */
@font-face {
font-family: "Google Sans";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("assets/fonts/google_sans_regular.ttf") format("truetype");
}
@font-face {
font-family: "Google Sans";
font-style: normal;
font-weight: 500;
font-display: block;
src: url("assets/fonts/google_sans_medium.ttf") format("truetype");
}
@font-face {
font-family: "Google Sans";
font-style: normal;
font-weight: 600;
font-display: block;
src: url("assets/fonts/google_sans_semibold.ttf") format("truetype");
}
@font-face {
font-family: "Google Sans";
font-style: normal;
font-weight: 700;
font-display: block;
src: url("assets/fonts/google_sans_bold.ttf") format("truetype");
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
width: 100%;
height: 100%;
background: rgb(21, 18, 24);
overflow: hidden;
}
:root {
--brand-gradient: linear-gradient(
45deg,
#9333ea,
#6366f1,
#3b82f6,
#a855f7,
#d946ef,
#ec4899,
#7e22ce
);
--brand-conic: conic-gradient(
from 0deg,
rgba(147, 51, 234, 0.5) 0%,
rgba(99, 102, 241, 0.6) 12.5%,
rgba(59, 130, 246, 0.55) 25%,
rgba(168, 85, 247, 0.5) 37.5%,
rgba(217, 70, 239, 0.6) 50%,
rgba(236, 72, 153, 0.55) 62.5%,
rgba(192, 132, 252, 0.5) 75%,
rgba(126, 34, 206, 0.6) 87.5%,
rgba(147, 51, 234, 0.5) 100%
);
--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 */
--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;
overflow: hidden;
color: var(--on-surface);
font-family: "Google Sans", system-ui, sans-serif;
background-color: var(--surface);
isolation: isolate;
}
.brand {
position: absolute;
/* move brand further down to avoid title-bar overlap in Finder window */
top: 44px;
left: 0;
right: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.brand__logo-wrap {
position: relative;
width: 100%;
overflow-x: clip;
display: flex;
justify-content: center;
}
.brand__logo-wrap::before {
content: "";
background: var(--brand-conic);
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);
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
z-index: 0;
}
.brand__logo {
width: 52px;
height: 52px;
background-image: url("assets/logo_square.png");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 13px;
position: relative;
z-index: 1;
}
.brand__wordmark {
font-family: "Montserrat", "Google Sans", sans-serif;
font-weight: 700;
/* slightly larger for Retina */
font-size: 24px;
letter-spacing: -0.03em;
line-height: 1;
background: var(--brand-gradient);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
position: relative;
z-index: 1;
}
.brand__tagline {
font-family: "Google Sans", sans-serif;
font-weight: 500;
font-size: 11px;
letter-spacing: 0.01em;
/* ensure legible light text on dark background (avoid fallback black) */
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;
left: 0;
right: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
}
.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;
flex-shrink: 0;
}
.arrow {
width: 36px;
height: 36px;
flex-shrink: 0;
color: rgba(230, 224, 233, 0.88);
display: flex;
align-items: center;
justify-content: center;
}
.arrow svg {
display: block;
width: 36px;
height: 36px;
}
.notice {
position: absolute;
left: 20px;
right: 20px;
/* increase bottom padding to avoid cropping */
bottom: 20px;
z-index: 1;
text-align: center;
font-family: "Google Sans", sans-serif;
font-weight: 400;
font-size: 10px;
line-height: 1.4;
color: var(--on-surface-muted);
text-wrap: balance;
}
.notice strong {
font-weight: 600;
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;
font-family: "Google Sans", system-ui, sans-serif;
font-size: 12px;
color: white; /* white app names as requested */
text-align: center;
width: auto;
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 */
}
/* 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;
}
@@ -35,39 +35,12 @@ import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
import com.pr0gramm3r101.utils.UtilsLibrary
import java.awt.Desktop
import java.awt.Image
import java.awt.KeyboardFocusManager
import java.awt.RenderingHints
import java.awt.Taskbar
import java.awt.desktop.AppReopenedListener
import java.awt.event.ActionEvent
import java.awt.image.BufferedImage
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicBoolean
import javax.imageio.ImageIO
import javax.swing.JComponent
import javax.swing.JRootPane
import javax.swing.SwingUtilities
import javax.swing.UIManager
import javax.swing.plaf.ColorUIResource
import javax.swing.text.DefaultEditorKit
import kotlin.concurrent.thread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.skia.Image as SkiaImage
import ru.fromchat.AppBuildInfo
import ru.fromchat.AppForeground
import ru.fromchat.Logger
@@ -102,6 +75,34 @@ import ru.fromchat.status_disconnected
import ru.fromchat.ui.App
import ru.fromchat.ui.LocalExtraStatusBarTop
import ru.fromchat.ui.Theme
import java.awt.Desktop
import java.awt.Image
import java.awt.KeyboardFocusManager
import java.awt.RenderingHints
import java.awt.Taskbar
import java.awt.desktop.AppReopenedListener
import java.awt.event.ActionEvent
import java.awt.image.BufferedImage
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicBoolean
import javax.imageio.ImageIO
import javax.swing.JComponent
import javax.swing.JRootPane
import javax.swing.SwingUtilities
import javax.swing.UIManager
import javax.swing.plaf.ColorUIResource
import javax.swing.text.DefaultEditorKit
import kotlin.concurrent.thread
import kotlin.time.Duration.Companion.milliseconds
import org.jetbrains.skia.Image as SkiaImage
private object DesktopApplicationBootstrap {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -173,7 +174,7 @@ private object DesktopSingleInstance {
private object DesktopProtocolRegistration {
private const val SCHEME = "fromchat"
fun registerBestEffort() {
fun register() {
runCatching {
when {
isMacOs() -> Unit // Packaged macOS builds declare CFBundleURLTypes in Info.plist.
@@ -211,24 +212,32 @@ private object DesktopProtocolRegistration {
}
private fun registerLinux() {
val appsDir = java.io.File(System.getProperty("user.home"), ".local/share/applications")
appsDir.mkdirs()
val desktop = java.io.File(appsDir, "fromchat-url-handler.desktop")
desktop.writeText(
"""
|[Desktop Entry]
|Type=Application
|Name=FromChat URL Handler
|Exec=${javaLauncherCommand()} %u
|StartupNotify=false
|MimeType=x-scheme-handler/$SCHEME;
|NoDisplay=true
""".trimMargin(),
)
val appsDir = File(
System.getProperty("user.home"),
".local/share/applications"
).also {
it.mkdirs()
}
val desktop = File(appsDir, "fromchat-url-handler.desktop").also {
it.writeText(
"""
|[Desktop Entry]
|Type=Application
|Name=FromChat URL Handler
|Exec=${javaLauncherCommand()} %u
|StartupNotify=false
|MimeType=x-scheme-handler/$SCHEME;
|NoDisplay=true
""".trimMargin(),
)
}
ProcessBuilder("xdg-mime", "default", desktop.name, "x-scheme-handler/$SCHEME")
.redirectErrorStream(true)
.start()
.waitFor()
ProcessBuilder("update-desktop-database", appsDir.absolutePath)
.redirectErrorStream(true)
.start()
@@ -253,7 +262,7 @@ fun main(args: Array<String>) {
System.setProperty("apple.awt.enableTemplateImages", "true")
}
if (!DesktopSingleInstance.acquireOrForward(args)) return
DesktopProtocolRegistration.registerBestEffort()
DesktopProtocolRegistration.register()
// Close-to-tray keeps the process + tray; no SMAppService / Login Items registration.
args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
.forEach { DesktopDeepLinkBus.handleUri(it) }
@@ -296,7 +305,7 @@ fun main(args: Array<String>) {
LaunchedEffect(Unit) {
while (true) {
wsLinked = WebSocketManager.isConnected
delay(400)
delay(400.milliseconds)
}
}
// Mirror ConnectionStateStore + WebSocketManager.isConnected (+ logged-out → disconnected).
@@ -307,16 +316,17 @@ fun main(args: Array<String>) {
ApiClient.token.isNullOrEmpty() -> stringResource(Res.string.status_disconnected)
else -> stringResource(Res.string.status_connecting)
}
val windowChrome = remember { desktopThemeBackgroundCompose() }
LaunchedEffect(Unit) {
// Safety: never leave the window stuck hidden if bootstrap hangs.
delay(8_000)
delay(8_000.milliseconds)
contentReady = true
}
LaunchedEffect(windowState.size, windowState.position) {
delay(3_000)
delay(3_000.milliseconds)
DesktopWindowPrefs.save(windowState.size, windowState.position)
}
@@ -448,18 +458,21 @@ fun main(args: Array<String>) {
) {
LaunchedEffect(window, appName, windowChrome) {
window.title = appName
val awt = windowChrome.toAwtColor()
window.background = awt
window.contentPane.background = awt
windowChrome.toAwtColor().also {
window.background = it
window.contentPane.background = it
}
enableEdgeToEdgeTitleBar(window.rootPane)
dockIconImage?.let { image ->
window.iconImages = listOf(image)
applyDockIcon(image)
}
}
DisposableEffect(Unit) {
AppForeground.setForeground(true)
onDispose { }
onDispose {}
}
if (mac) {
@@ -537,54 +550,67 @@ private fun FrameWindowScope.FromChatMenuBar(
shortcut = KeyShortcut(Key.N, meta = true),
onClick = onNewChat,
)
Item(
searchConversations,
shortcut = KeyShortcut(Key.F, meta = true),
onClick = onSearchConversations,
)
Separator()
Item(
quit,
shortcut = KeyShortcut(Key.Q, meta = true),
onClick = onQuit,
)
}
Menu(edit, mnemonic = 'E') {
Item(
cut,
shortcut = KeyShortcut(Key.X, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.cutAction) },
)
Item(
copy,
shortcut = KeyShortcut(Key.C, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.copyAction) },
)
Item(
paste,
shortcut = KeyShortcut(Key.V, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.pasteAction) },
)
Separator()
Item(
selectAll,
shortcut = KeyShortcut(Key.A, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.selectAllAction) },
)
Item(
select,
shortcut = KeyShortcut(Key.S, meta = true, shift = true),
onClick = onSelectChats,
)
}
Menu(windowMenu, mnemonic = 'W') {
Item(
minimize,
shortcut = KeyShortcut(Key.M, meta = true),
onClick = onMinimize,
)
Item(zoom, onClick = onZoom)
Separator()
Item(
showApp,
shortcut = KeyShortcut(Key.N, meta = true, shift = true),
@@ -620,28 +646,30 @@ private fun applyDesktopWindowChromeBackground() {
UIManager.put("control", ColorUIResource(desktopThemeBackgroundCompose().toAwtColor()))
}
private fun desktopThemeBackgroundCompose(): Color {
val dark = when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) {
Theme.Dark -> true
Theme.Light -> false
Theme.AsSystem -> isSystemAppearanceDark()
}
// M3 default scheme backgrounds (desktop module has no material3 dependency).
return if (dark) Color(0xFF1C1B1F) else Color(0xFFFFFBFE)
}
private fun desktopThemeBackgroundCompose() =
if (
when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) {
Theme.Dark -> true
Theme.Light -> false
Theme.AsSystem -> isSystemAppearanceDark()
}
) Color(0xFF1C1B1F) else Color(0xFFFFFBFE)
private fun Color.toAwtColor(): java.awt.Color =
private fun Color.toAwtColor() =
java.awt.Color(red, green, blue, alpha)
private fun isSystemAppearanceDark(): Boolean {
if (!isMacOs()) return false
return runCatching {
val process = ProcessBuilder("defaults", "read", "-g", "AppleInterfaceStyle")
private fun isSystemAppearanceDark() = runCatching {
if (isMacOs()) {
ProcessBuilder("defaults", "read", "-g", "AppleInterfaceStyle")
.redirectErrorStream(true)
.start()
process.inputStream.bufferedReader().readText().trim().equals("Dark", ignoreCase = true)
}.getOrDefault(false)
}
.inputStream
.bufferedReader()
.readText()
.trim()
.equals("Dark", ignoreCase = true)
} else false
}.getOrDefault(false)
private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
if (!isMacOs()) return
@@ -655,11 +683,13 @@ private fun applyDockIcon(image: BufferedImage?) {
Logger.w("DesktopIcon", "applyDockIcon skipped — image is null")
return
}
runCatching {
if (!Taskbar.isTaskbarSupported()) {
Logger.w("DesktopIcon", "Taskbar unsupported; dock icon not set")
return
}
val taskbar = Taskbar.getTaskbar()
if (taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) {
taskbar.iconImage = image
@@ -692,10 +722,12 @@ private fun loadAppIconPainter(tray: Boolean): Painter {
)
return solidFallbackIcon().toPainter()
}
Logger.i(
"DesktopIcon",
"loadAppIconPainter ok tray=$tray size=${image.width}x${image.height} type=${image.type}",
)
return image.toPainter()
}
@@ -705,28 +737,38 @@ private fun loadAppIconBufferedImage(tray: Boolean): BufferedImage? {
} else {
listOf("app_window_icon.png", "app_window_icon.webp", "app_icon.png", "app_icon.webp")
}
val loaders = listOfNotNull(
DesktopApplicationBootstrap::class.java.classLoader,
Thread.currentThread().contextClassLoader,
ClassLoader.getSystemClassLoader(),
).distinct()
for (name in names) {
val bytes = readClasspathBytes(name, loaders)
?: readFileFallbackBytes(name)
?: continue
if (bytes.isEmpty()) continue
val decoded = decodeImageBytes(bytes, name) ?: continue
if (decoded.width < 16 || decoded.height < 16) {
Logger.w("DesktopIcon", "Skipping $name — decoded size ${decoded.width}x${decoded.height}")
continue
}
// Ensure TYPE_INT_ARGB so AWT Tray/Taskbar get a predictable raster.
val argb = ensureArgb(decoded)
val image = if (tray) scaleBufferedImage(argb, 64) else argb
Logger.i("DesktopIcon", "Loaded $name (${image.width}x${image.height}) tray=$tray")
return image
return ensureArgb(
(
decodeImageBytes(
(readClasspathBytes(
name,
listOfNotNull(
DesktopApplicationBootstrap::class.java.classLoader,
Thread.currentThread().contextClassLoader,
ClassLoader.getSystemClassLoader(),
).distinct()
) ?: readFileFallbackBytes(name) ?: continue)
.also { if (it.isEmpty()) continue },
name
) ?: continue
).also {
if (it.width < 16 || it.height < 16) {
Logger.w(
"DesktopIcon",
"Skipping $name — decoded size ${it.width}x${it.height}"
)
continue
}
}
)
.let { if (tray) scaleBufferedImage(it, 64) else it }
.also {
Logger.i("DesktopIcon", "Loaded $name (${it.width}x${it.height}) tray=$tray")
}
}
Logger.e("DesktopIcon", "No icon resource found for tray=$tray (tried $names)")
return null
}
@@ -734,41 +776,49 @@ private fun loadAppIconBufferedImage(tray: Boolean): BufferedImage? {
private fun readClasspathBytes(name: String, loaders: List<ClassLoader>): ByteArray? {
val paths = listOf(name, "/$name")
for (loader in loaders) {
for (path in paths) {
val stream = loader.getResourceAsStream(path) ?: continue
val bytes = runCatching { stream.use { it.readBytes() } }
.onFailure { error ->
Logger.w("DesktopIcon", "Failed reading $path from $loader: ${error.message}")
}
.getOrNull()
?: continue
loop@ for (path in paths) {
val bytes = runCatching {
(loader.getResourceAsStream(path) ?: continue@loop)
.use { it.readBytes() }
}.onFailure { error ->
Logger.w("DesktopIcon", "Failed reading $path from $loader: ${error.message}")
}
.getOrNull()
?: continue
if (bytes.isNotEmpty()) {
Logger.i("DesktopIcon", "Classpath hit $path via $loader (${bytes.size} bytes)")
return bytes
}
}
}
// Explicit class resource (works when context CL is a filtered compose run CL).
for (path in paths) {
val stream = DesktopApplicationBootstrap::class.java.getResourceAsStream(path) ?: continue
val bytes = runCatching { stream.use { it.readBytes() } }.getOrNull() ?: continue
loop@ for (path in paths) {
val bytes = runCatching {
(DesktopApplicationBootstrap::class.java.getResourceAsStream(path) ?: continue@loop)
.use { it.readBytes() }
}.getOrNull() ?: continue
if (bytes.isNotEmpty()) {
Logger.i("DesktopIcon", "Class resource hit $path (${bytes.size} bytes)")
return bytes
}
}
return null
}
/** Last resort when classpath packaging under `:run` omits resources. */
private fun readFileFallbackBytes(name: String): ByteArray? {
val candidates = listOf(
File("app/desktop/src/main/resources", name),
File("src/main/resources", name),
File(System.getProperty("user.dir"), "src/main/resources/$name"),
File(System.getProperty("user.dir"), "app/desktop/src/main/resources/$name"),
)
for (file in candidates) {
for (
file in listOf(
File("app/desktop/src/main/resources", name),
File("src/main/resources", name),
File(System.getProperty("user.dir"), "src/main/resources/$name"),
File(System.getProperty("user.dir"), "app/desktop/src/main/resources/$name"),
)
) {
if (!file.isFile) continue
val bytes = runCatching { file.readBytes() }.getOrNull() ?: continue
if (bytes.isNotEmpty()) {
@@ -776,6 +826,7 @@ private fun readFileFallbackBytes(name: String): ByteArray? {
return bytes
}
}
return null
}
@@ -785,11 +836,11 @@ private fun decodeImageBytes(bytes: ByteArray, name: String): BufferedImage? {
}.onFailure { error ->
Logger.w("DesktopIcon", "ImageIO failed for $name: ${error.message}")
}.getOrNull()
if (viaImageIo != null) return viaImageIo
return runCatching {
val skia = SkiaImage.makeFromEncoded(bytes)
bufferedImageFromComposeArgb(skia.toComposeImageBitmap())
bufferedImageFromComposeArgb(SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap())
}.onFailure { error ->
Logger.w("DesktopIcon", "Skia decode failed for $name: ${error.message}")
}.getOrNull()
@@ -797,10 +848,13 @@ private fun decodeImageBytes(bytes: ByteArray, name: String): BufferedImage? {
private fun ensureArgb(source: BufferedImage): BufferedImage {
if (source.type == BufferedImage.TYPE_INT_ARGB) return source
val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_ARGB)
val graphics = out.createGraphics()
graphics.drawImage(source, 0, 0, null)
graphics.dispose()
out.createGraphics().apply {
drawImage(source, 0, 0, null)
dispose()
}
return out
}
@@ -811,34 +865,43 @@ private fun bufferedImageFromComposeArgb(
val h = bitmap.height
val pixels = IntArray(w * h)
bitmap.readPixels(pixels)
val out = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
out.setRGB(0, 0, w, h, pixels, 0, w)
return out
}
private fun scaleBufferedImage(source: BufferedImage, size: Int): BufferedImage {
if (source.width == size && source.height == size) return source
val out = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
val graphics = out.createGraphics()
graphics.setRenderingHint(
RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR,
)
graphics.setRenderingHint(
RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY,
)
graphics.drawImage(source.getScaledInstance(size, size, Image.SCALE_SMOOTH), 0, 0, null)
graphics.dispose()
out.createGraphics().apply {
setRenderingHint(
RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR,
)
setRenderingHint(
RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY,
)
drawImage(source.getScaledInstance(size, size, Image.SCALE_SMOOTH), 0, 0, null)
dispose()
}
return out
}
/** Opaque mark so Tray never substitutes the Compose default for a blank painter. */
private fun solidFallbackIcon(): BufferedImage {
val out = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB)
val graphics = out.createGraphics()
graphics.color = java.awt.Color(0x1A, 0x73, 0xE8)
graphics.fillRoundRect(2, 2, 28, 28, 8, 8)
graphics.dispose()
out.createGraphics().apply {
color = java.awt.Color(0x1A, 0x73, 0xE8)
fillRoundRect(2, 2, 28, 28, 8, 8)
dispose()
}
return out
}
@@ -11,9 +11,9 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardReturn
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.KeyboardReturn
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Wallpaper
@@ -22,7 +22,6 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import ru.fromchat.ui.components.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -51,6 +50,7 @@ import ru.fromchat.materialYou_d
import ru.fromchat.settings_category_appearance
import ru.fromchat.theme
import ru.fromchat.ui.Theme
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.dynamicThemeEnabled
import ru.fromchat.ui.theme
@@ -114,7 +114,7 @@ fun AppearanceScreen(onBack: () -> Unit) {
},
divider = true,
leadingContent = {
Icon(Icons.Filled.KeyboardReturn, null)
Icon(Icons.AutoMirrored.Filled.KeyboardReturn, null)
}
)
ListItem(
@@ -2,11 +2,11 @@ package ru.fromchat.api.local.download
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import java.io.File
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Image
import org.jetbrains.skia.ImageInfo
import org.jetbrains.skia.SamplingMode
import java.io.File
actual object PlatformDecodedBitmapCache {
private val cache = mutableMapOf<String, ImageBitmap>()
@@ -33,7 +33,7 @@ actual fun decodeLocalImageFile(absolutePath: String, reqWidthPx: Int, reqHeight
actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? =
runCatching {
val image = Image.makeFromEncoded(bytes) ?: return@runCatching null
val image = Image.makeFromEncoded(bytes)
scaleSkiaImageToFitWithin(image, reqWidthPx, reqHeightPx)
}.getOrNull()