mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Prepare app build
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru> # Conflicts: # .gitignore
This commit is contained in:
+4
-1
@@ -33,4 +33,7 @@ google-services.json
|
||||
premortem-transcript-*.md
|
||||
premortem-report-*.html
|
||||
|
||||
target/
|
||||
target/
|
||||
|
||||
node_modules
|
||||
preview-dist
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"appLabel": "FromChat",
|
||||
"applicationsLabel": "Программы",
|
||||
"primaryColor": "#dabaf9"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "fromchat-dmg-background-tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"playwright": "1.52.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
-2
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user