mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Prepare app build
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru> # Conflicts: # .gitignore
This commit is contained in:
@@ -34,3 +34,6 @@ premortem-transcript-*.md
|
|||||||
premortem-report-*.html
|
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.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
-2
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user