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
+3
View File
@@ -34,3 +34,6 @@ premortem-transcript-*.md
premortem-report-*.html 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.rememberTrayState
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import com.pr0gramm3r101.utils.UtilsLibrary 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.skia.Image as SkiaImage
import ru.fromchat.AppBuildInfo import ru.fromchat.AppBuildInfo
import ru.fromchat.AppForeground import ru.fromchat.AppForeground
import ru.fromchat.Logger import ru.fromchat.Logger
@@ -102,6 +75,34 @@ import ru.fromchat.status_disconnected
import ru.fromchat.ui.App import ru.fromchat.ui.App
import ru.fromchat.ui.LocalExtraStatusBarTop import ru.fromchat.ui.LocalExtraStatusBarTop
import ru.fromchat.ui.Theme 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 object DesktopApplicationBootstrap {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -173,7 +174,7 @@ private object DesktopSingleInstance {
private object DesktopProtocolRegistration { private object DesktopProtocolRegistration {
private const val SCHEME = "fromchat" private const val SCHEME = "fromchat"
fun registerBestEffort() { fun register() {
runCatching { runCatching {
when { when {
isMacOs() -> Unit // Packaged macOS builds declare CFBundleURLTypes in Info.plist. isMacOs() -> Unit // Packaged macOS builds declare CFBundleURLTypes in Info.plist.
@@ -211,10 +212,15 @@ private object DesktopProtocolRegistration {
} }
private fun registerLinux() { private fun registerLinux() {
val appsDir = java.io.File(System.getProperty("user.home"), ".local/share/applications") val appsDir = File(
appsDir.mkdirs() System.getProperty("user.home"),
val desktop = java.io.File(appsDir, "fromchat-url-handler.desktop") ".local/share/applications"
desktop.writeText( ).also {
it.mkdirs()
}
val desktop = File(appsDir, "fromchat-url-handler.desktop").also {
it.writeText(
""" """
|[Desktop Entry] |[Desktop Entry]
|Type=Application |Type=Application
@@ -225,10 +231,13 @@ private object DesktopProtocolRegistration {
|NoDisplay=true |NoDisplay=true
""".trimMargin(), """.trimMargin(),
) )
}
ProcessBuilder("xdg-mime", "default", desktop.name, "x-scheme-handler/$SCHEME") ProcessBuilder("xdg-mime", "default", desktop.name, "x-scheme-handler/$SCHEME")
.redirectErrorStream(true) .redirectErrorStream(true)
.start() .start()
.waitFor() .waitFor()
ProcessBuilder("update-desktop-database", appsDir.absolutePath) ProcessBuilder("update-desktop-database", appsDir.absolutePath)
.redirectErrorStream(true) .redirectErrorStream(true)
.start() .start()
@@ -253,7 +262,7 @@ fun main(args: Array<String>) {
System.setProperty("apple.awt.enableTemplateImages", "true") System.setProperty("apple.awt.enableTemplateImages", "true")
} }
if (!DesktopSingleInstance.acquireOrForward(args)) return if (!DesktopSingleInstance.acquireOrForward(args)) return
DesktopProtocolRegistration.registerBestEffort() DesktopProtocolRegistration.register()
// Close-to-tray keeps the process + tray; no SMAppService / Login Items registration. // Close-to-tray keeps the process + tray; no SMAppService / Login Items registration.
args.filter { DesktopDeepLinkBus.isFromChatUri(it) } args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
.forEach { DesktopDeepLinkBus.handleUri(it) } .forEach { DesktopDeepLinkBus.handleUri(it) }
@@ -296,7 +305,7 @@ fun main(args: Array<String>) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
wsLinked = WebSocketManager.isConnected wsLinked = WebSocketManager.isConnected
delay(400) delay(400.milliseconds)
} }
} }
// Mirror ConnectionStateStore + WebSocketManager.isConnected (+ logged-out → disconnected). // Mirror ConnectionStateStore + WebSocketManager.isConnected (+ logged-out → disconnected).
@@ -307,16 +316,17 @@ fun main(args: Array<String>) {
ApiClient.token.isNullOrEmpty() -> stringResource(Res.string.status_disconnected) ApiClient.token.isNullOrEmpty() -> stringResource(Res.string.status_disconnected)
else -> stringResource(Res.string.status_connecting) else -> stringResource(Res.string.status_connecting)
} }
val windowChrome = remember { desktopThemeBackgroundCompose() } val windowChrome = remember { desktopThemeBackgroundCompose() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Safety: never leave the window stuck hidden if bootstrap hangs. // Safety: never leave the window stuck hidden if bootstrap hangs.
delay(8_000) delay(8_000.milliseconds)
contentReady = true contentReady = true
} }
LaunchedEffect(windowState.size, windowState.position) { LaunchedEffect(windowState.size, windowState.position) {
delay(3_000) delay(3_000.milliseconds)
DesktopWindowPrefs.save(windowState.size, windowState.position) DesktopWindowPrefs.save(windowState.size, windowState.position)
} }
@@ -448,18 +458,21 @@ fun main(args: Array<String>) {
) { ) {
LaunchedEffect(window, appName, windowChrome) { LaunchedEffect(window, appName, windowChrome) {
window.title = appName window.title = appName
val awt = windowChrome.toAwtColor() windowChrome.toAwtColor().also {
window.background = awt window.background = it
window.contentPane.background = awt window.contentPane.background = it
}
enableEdgeToEdgeTitleBar(window.rootPane) enableEdgeToEdgeTitleBar(window.rootPane)
dockIconImage?.let { image -> dockIconImage?.let { image ->
window.iconImages = listOf(image) window.iconImages = listOf(image)
applyDockIcon(image) applyDockIcon(image)
} }
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
AppForeground.setForeground(true) AppForeground.setForeground(true)
onDispose { } onDispose {}
} }
if (mac) { if (mac) {
@@ -537,54 +550,67 @@ private fun FrameWindowScope.FromChatMenuBar(
shortcut = KeyShortcut(Key.N, meta = true), shortcut = KeyShortcut(Key.N, meta = true),
onClick = onNewChat, onClick = onNewChat,
) )
Item( Item(
searchConversations, searchConversations,
shortcut = KeyShortcut(Key.F, meta = true), shortcut = KeyShortcut(Key.F, meta = true),
onClick = onSearchConversations, onClick = onSearchConversations,
) )
Separator() Separator()
Item( Item(
quit, quit,
shortcut = KeyShortcut(Key.Q, meta = true), shortcut = KeyShortcut(Key.Q, meta = true),
onClick = onQuit, onClick = onQuit,
) )
} }
Menu(edit, mnemonic = 'E') { Menu(edit, mnemonic = 'E') {
Item( Item(
cut, cut,
shortcut = KeyShortcut(Key.X, meta = true), shortcut = KeyShortcut(Key.X, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.cutAction) }, onClick = { performAwtEditAction(DefaultEditorKit.cutAction) },
) )
Item( Item(
copy, copy,
shortcut = KeyShortcut(Key.C, meta = true), shortcut = KeyShortcut(Key.C, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.copyAction) }, onClick = { performAwtEditAction(DefaultEditorKit.copyAction) },
) )
Item( Item(
paste, paste,
shortcut = KeyShortcut(Key.V, meta = true), shortcut = KeyShortcut(Key.V, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.pasteAction) }, onClick = { performAwtEditAction(DefaultEditorKit.pasteAction) },
) )
Separator() Separator()
Item( Item(
selectAll, selectAll,
shortcut = KeyShortcut(Key.A, meta = true), shortcut = KeyShortcut(Key.A, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.selectAllAction) }, onClick = { performAwtEditAction(DefaultEditorKit.selectAllAction) },
) )
Item( Item(
select, select,
shortcut = KeyShortcut(Key.S, meta = true, shift = true), shortcut = KeyShortcut(Key.S, meta = true, shift = true),
onClick = onSelectChats, onClick = onSelectChats,
) )
} }
Menu(windowMenu, mnemonic = 'W') { Menu(windowMenu, mnemonic = 'W') {
Item( Item(
minimize, minimize,
shortcut = KeyShortcut(Key.M, meta = true), shortcut = KeyShortcut(Key.M, meta = true),
onClick = onMinimize, onClick = onMinimize,
) )
Item(zoom, onClick = onZoom) Item(zoom, onClick = onZoom)
Separator() Separator()
Item( Item(
showApp, showApp,
shortcut = KeyShortcut(Key.N, meta = true, shift = true), shortcut = KeyShortcut(Key.N, meta = true, shift = true),
@@ -620,28 +646,30 @@ private fun applyDesktopWindowChromeBackground() {
UIManager.put("control", ColorUIResource(desktopThemeBackgroundCompose().toAwtColor())) UIManager.put("control", ColorUIResource(desktopThemeBackgroundCompose().toAwtColor()))
} }
private fun desktopThemeBackgroundCompose(): Color { private fun desktopThemeBackgroundCompose() =
val dark = when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) { if (
when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) {
Theme.Dark -> true Theme.Dark -> true
Theme.Light -> false Theme.Light -> false
Theme.AsSystem -> isSystemAppearanceDark() Theme.AsSystem -> isSystemAppearanceDark()
} }
// M3 default scheme backgrounds (desktop module has no material3 dependency). ) Color(0xFF1C1B1F) else Color(0xFFFFFBFE)
return if (dark) Color(0xFF1C1B1F) else Color(0xFFFFFBFE)
}
private fun Color.toAwtColor(): java.awt.Color = private fun Color.toAwtColor() =
java.awt.Color(red, green, blue, alpha) java.awt.Color(red, green, blue, alpha)
private fun isSystemAppearanceDark(): Boolean { private fun isSystemAppearanceDark() = runCatching {
if (!isMacOs()) return false if (isMacOs()) {
return runCatching { ProcessBuilder("defaults", "read", "-g", "AppleInterfaceStyle")
val process = ProcessBuilder("defaults", "read", "-g", "AppleInterfaceStyle")
.redirectErrorStream(true) .redirectErrorStream(true)
.start() .start()
process.inputStream.bufferedReader().readText().trim().equals("Dark", ignoreCase = true) .inputStream
}.getOrDefault(false) .bufferedReader()
} .readText()
.trim()
.equals("Dark", ignoreCase = true)
} else false
}.getOrDefault(false)
private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) { private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
if (!isMacOs()) return if (!isMacOs()) return
@@ -655,11 +683,13 @@ private fun applyDockIcon(image: BufferedImage?) {
Logger.w("DesktopIcon", "applyDockIcon skipped — image is null") Logger.w("DesktopIcon", "applyDockIcon skipped — image is null")
return return
} }
runCatching { runCatching {
if (!Taskbar.isTaskbarSupported()) { if (!Taskbar.isTaskbarSupported()) {
Logger.w("DesktopIcon", "Taskbar unsupported; dock icon not set") Logger.w("DesktopIcon", "Taskbar unsupported; dock icon not set")
return return
} }
val taskbar = Taskbar.getTaskbar() val taskbar = Taskbar.getTaskbar()
if (taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) { if (taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) {
taskbar.iconImage = image taskbar.iconImage = image
@@ -692,10 +722,12 @@ private fun loadAppIconPainter(tray: Boolean): Painter {
) )
return solidFallbackIcon().toPainter() return solidFallbackIcon().toPainter()
} }
Logger.i( Logger.i(
"DesktopIcon", "DesktopIcon",
"loadAppIconPainter ok tray=$tray size=${image.width}x${image.height} type=${image.type}", "loadAppIconPainter ok tray=$tray size=${image.width}x${image.height} type=${image.type}",
) )
return image.toPainter() return image.toPainter()
} }
@@ -705,28 +737,38 @@ private fun loadAppIconBufferedImage(tray: Boolean): BufferedImage? {
} else { } else {
listOf("app_window_icon.png", "app_window_icon.webp", "app_icon.png", "app_icon.webp") listOf("app_window_icon.png", "app_window_icon.webp", "app_icon.png", "app_icon.webp")
} }
val loaders = listOfNotNull(
for (name in names) {
return ensureArgb(
(
decodeImageBytes(
(readClasspathBytes(
name,
listOfNotNull(
DesktopApplicationBootstrap::class.java.classLoader, DesktopApplicationBootstrap::class.java.classLoader,
Thread.currentThread().contextClassLoader, Thread.currentThread().contextClassLoader,
ClassLoader.getSystemClassLoader(), ClassLoader.getSystemClassLoader(),
).distinct() ).distinct()
) ?: readFileFallbackBytes(name) ?: continue)
for (name in names) { .also { if (it.isEmpty()) continue },
val bytes = readClasspathBytes(name, loaders) name
?: readFileFallbackBytes(name) ) ?: continue
?: continue ).also {
if (bytes.isEmpty()) continue if (it.width < 16 || it.height < 16) {
val decoded = decodeImageBytes(bytes, name) ?: continue Logger.w(
if (decoded.width < 16 || decoded.height < 16) { "DesktopIcon",
Logger.w("DesktopIcon", "Skipping $name — decoded size ${decoded.width}x${decoded.height}") "Skipping $name — decoded size ${it.width}x${it.height}"
)
continue 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
} }
)
.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)") Logger.e("DesktopIcon", "No icon resource found for tray=$tray (tried $names)")
return null return null
} }
@@ -734,41 +776,49 @@ private fun loadAppIconBufferedImage(tray: Boolean): BufferedImage? {
private fun readClasspathBytes(name: String, loaders: List<ClassLoader>): ByteArray? { private fun readClasspathBytes(name: String, loaders: List<ClassLoader>): ByteArray? {
val paths = listOf(name, "/$name") val paths = listOf(name, "/$name")
for (loader in loaders) { for (loader in loaders) {
for (path in paths) { loop@ for (path in paths) {
val stream = loader.getResourceAsStream(path) ?: continue val bytes = runCatching {
val bytes = runCatching { stream.use { it.readBytes() } } (loader.getResourceAsStream(path) ?: continue@loop)
.onFailure { error -> .use { it.readBytes() }
}.onFailure { error ->
Logger.w("DesktopIcon", "Failed reading $path from $loader: ${error.message}") Logger.w("DesktopIcon", "Failed reading $path from $loader: ${error.message}")
} }
.getOrNull() .getOrNull()
?: continue ?: continue
if (bytes.isNotEmpty()) { if (bytes.isNotEmpty()) {
Logger.i("DesktopIcon", "Classpath hit $path via $loader (${bytes.size} bytes)") Logger.i("DesktopIcon", "Classpath hit $path via $loader (${bytes.size} bytes)")
return bytes return bytes
} }
} }
} }
// Explicit class resource (works when context CL is a filtered compose run CL). // Explicit class resource (works when context CL is a filtered compose run CL).
for (path in paths) { loop@ for (path in paths) {
val stream = DesktopApplicationBootstrap::class.java.getResourceAsStream(path) ?: continue val bytes = runCatching {
val bytes = runCatching { stream.use { it.readBytes() } }.getOrNull() ?: continue (DesktopApplicationBootstrap::class.java.getResourceAsStream(path) ?: continue@loop)
.use { it.readBytes() }
}.getOrNull() ?: continue
if (bytes.isNotEmpty()) { if (bytes.isNotEmpty()) {
Logger.i("DesktopIcon", "Class resource hit $path (${bytes.size} bytes)") Logger.i("DesktopIcon", "Class resource hit $path (${bytes.size} bytes)")
return bytes return bytes
} }
} }
return null return null
} }
/** Last resort when classpath packaging under `:run` omits resources. */ /** Last resort when classpath packaging under `:run` omits resources. */
private fun readFileFallbackBytes(name: String): ByteArray? { private fun readFileFallbackBytes(name: String): ByteArray? {
val candidates = listOf( for (
file in listOf(
File("app/desktop/src/main/resources", name), File("app/desktop/src/main/resources", name),
File("src/main/resources", name), File("src/main/resources", name),
File(System.getProperty("user.dir"), "src/main/resources/$name"), File(System.getProperty("user.dir"), "src/main/resources/$name"),
File(System.getProperty("user.dir"), "app/desktop/src/main/resources/$name"), File(System.getProperty("user.dir"), "app/desktop/src/main/resources/$name"),
) )
for (file in candidates) { ) {
if (!file.isFile) continue if (!file.isFile) continue
val bytes = runCatching { file.readBytes() }.getOrNull() ?: continue val bytes = runCatching { file.readBytes() }.getOrNull() ?: continue
if (bytes.isNotEmpty()) { if (bytes.isNotEmpty()) {
@@ -776,6 +826,7 @@ private fun readFileFallbackBytes(name: String): ByteArray? {
return bytes return bytes
} }
} }
return null return null
} }
@@ -785,11 +836,11 @@ private fun decodeImageBytes(bytes: ByteArray, name: String): BufferedImage? {
}.onFailure { error -> }.onFailure { error ->
Logger.w("DesktopIcon", "ImageIO failed for $name: ${error.message}") Logger.w("DesktopIcon", "ImageIO failed for $name: ${error.message}")
}.getOrNull() }.getOrNull()
if (viaImageIo != null) return viaImageIo if (viaImageIo != null) return viaImageIo
return runCatching { return runCatching {
val skia = SkiaImage.makeFromEncoded(bytes) bufferedImageFromComposeArgb(SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap())
bufferedImageFromComposeArgb(skia.toComposeImageBitmap())
}.onFailure { error -> }.onFailure { error ->
Logger.w("DesktopIcon", "Skia decode failed for $name: ${error.message}") Logger.w("DesktopIcon", "Skia decode failed for $name: ${error.message}")
}.getOrNull() }.getOrNull()
@@ -797,10 +848,13 @@ private fun decodeImageBytes(bytes: ByteArray, name: String): BufferedImage? {
private fun ensureArgb(source: BufferedImage): BufferedImage { private fun ensureArgb(source: BufferedImage): BufferedImage {
if (source.type == BufferedImage.TYPE_INT_ARGB) return source if (source.type == BufferedImage.TYPE_INT_ARGB) return source
val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_ARGB) val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_ARGB)
val graphics = out.createGraphics() out.createGraphics().apply {
graphics.drawImage(source, 0, 0, null) drawImage(source, 0, 0, null)
graphics.dispose() dispose()
}
return out return out
} }
@@ -811,34 +865,43 @@ private fun bufferedImageFromComposeArgb(
val h = bitmap.height val h = bitmap.height
val pixels = IntArray(w * h) val pixels = IntArray(w * h)
bitmap.readPixels(pixels) bitmap.readPixels(pixels)
val out = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) val out = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
out.setRGB(0, 0, w, h, pixels, 0, w) out.setRGB(0, 0, w, h, pixels, 0, w)
return out return out
} }
private fun scaleBufferedImage(source: BufferedImage, size: Int): BufferedImage { private fun scaleBufferedImage(source: BufferedImage, size: Int): BufferedImage {
if (source.width == size && source.height == size) return source if (source.width == size && source.height == size) return source
val out = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB) val out = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
val graphics = out.createGraphics()
graphics.setRenderingHint( out.createGraphics().apply {
setRenderingHint(
RenderingHints.KEY_INTERPOLATION, RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR, RenderingHints.VALUE_INTERPOLATION_BILINEAR,
) )
graphics.setRenderingHint( setRenderingHint(
RenderingHints.KEY_RENDERING, RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY, RenderingHints.VALUE_RENDER_QUALITY,
) )
graphics.drawImage(source.getScaledInstance(size, size, Image.SCALE_SMOOTH), 0, 0, null) drawImage(source.getScaledInstance(size, size, Image.SCALE_SMOOTH), 0, 0, null)
graphics.dispose()
dispose()
}
return out return out
} }
/** Opaque mark so Tray never substitutes the Compose default for a blank painter. */ /** Opaque mark so Tray never substitutes the Compose default for a blank painter. */
private fun solidFallbackIcon(): BufferedImage { private fun solidFallbackIcon(): BufferedImage {
val out = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB) val out = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB)
val graphics = out.createGraphics() out.createGraphics().apply {
graphics.color = java.awt.Color(0x1A, 0x73, 0xE8) color = java.awt.Color(0x1A, 0x73, 0xE8)
graphics.fillRoundRect(2, 2, 28, 28, 8, 8) fillRoundRect(2, 2, 28, 28, 8, 8)
graphics.dispose()
dispose()
}
return out return out
} }
@@ -11,9 +11,9 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Brush
import androidx.compose.material.icons.filled.DarkMode 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.LightMode
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Wallpaper 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import ru.fromchat.ui.components.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -51,6 +50,7 @@ import ru.fromchat.materialYou_d
import ru.fromchat.settings_category_appearance import ru.fromchat.settings_category_appearance
import ru.fromchat.theme import ru.fromchat.theme
import ru.fromchat.ui.Theme import ru.fromchat.ui.Theme
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.dynamicThemeEnabled import ru.fromchat.ui.dynamicThemeEnabled
import ru.fromchat.ui.theme import ru.fromchat.ui.theme
@@ -114,7 +114,7 @@ fun AppearanceScreen(onBack: () -> Unit) {
}, },
divider = true, divider = true,
leadingContent = { leadingContent = {
Icon(Icons.Filled.KeyboardReturn, null) Icon(Icons.AutoMirrored.Filled.KeyboardReturn, null)
} }
) )
ListItem( ListItem(
@@ -2,11 +2,11 @@ package ru.fromchat.api.local.download
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import java.io.File
import org.jetbrains.skia.Bitmap import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import org.jetbrains.skia.ImageInfo import org.jetbrains.skia.ImageInfo
import org.jetbrains.skia.SamplingMode import org.jetbrains.skia.SamplingMode
import java.io.File
actual object PlatformDecodedBitmapCache { actual object PlatformDecodedBitmapCache {
private val cache = mutableMapOf<String, ImageBitmap>() 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? = actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? =
runCatching { runCatching {
val image = Image.makeFromEncoded(bytes) ?: return@runCatching null val image = Image.makeFromEncoded(bytes)
scaleSkiaImageToFitWithin(image, reqWidthPx, reqHeightPx) scaleSkiaImageToFitWithin(image, reqWidthPx, reqHeightPx)
}.getOrNull() }.getOrNull()