diff --git a/.gitignore b/.gitignore index 487badc..58c4f3e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ google-services.json premortem-transcript-*.md premortem-report-*.html -target/ \ No newline at end of file +target/ + +node_modules +preview-dist \ No newline at end of file diff --git a/app/desktop/dmg-background/assets/fonts/google_sans_bold.ttf b/app/desktop/dmg-background/assets/fonts/google_sans_bold.ttf new file mode 100644 index 0000000..71b847f Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/google_sans_bold.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/google_sans_bold_italic.ttf b/app/desktop/dmg-background/assets/fonts/google_sans_bold_italic.ttf new file mode 100644 index 0000000..82e0e6a Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/google_sans_bold_italic.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/google_sans_italic.ttf b/app/desktop/dmg-background/assets/fonts/google_sans_italic.ttf new file mode 100644 index 0000000..1f9059a Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/google_sans_italic.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/google_sans_medium.ttf b/app/desktop/dmg-background/assets/fonts/google_sans_medium.ttf new file mode 100644 index 0000000..8b9aebc Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/google_sans_medium.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/google_sans_medium_italic.ttf b/app/desktop/dmg-background/assets/fonts/google_sans_medium_italic.ttf new file mode 100644 index 0000000..407b297 Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/google_sans_medium_italic.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/google_sans_regular.ttf b/app/desktop/dmg-background/assets/fonts/google_sans_regular.ttf new file mode 100644 index 0000000..cc37c3f Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/google_sans_regular.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/google_sans_semibold.ttf b/app/desktop/dmg-background/assets/fonts/google_sans_semibold.ttf new file mode 100644 index 0000000..b80284d Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/google_sans_semibold.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/google_sans_semibold_italic.ttf b/app/desktop/dmg-background/assets/fonts/google_sans_semibold_italic.ttf new file mode 100644 index 0000000..4d34849 Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/google_sans_semibold_italic.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/montserrat_cyrillic.ttf b/app/desktop/dmg-background/assets/fonts/montserrat_cyrillic.ttf new file mode 100644 index 0000000..57728fe Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/montserrat_cyrillic.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/montserrat_cyrillic_ext.ttf b/app/desktop/dmg-background/assets/fonts/montserrat_cyrillic_ext.ttf new file mode 100644 index 0000000..a7044c1 Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/montserrat_cyrillic_ext.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/montserrat_latin.ttf b/app/desktop/dmg-background/assets/fonts/montserrat_latin.ttf new file mode 100644 index 0000000..b99369a Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/montserrat_latin.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/montserrat_latin_ext.ttf b/app/desktop/dmg-background/assets/fonts/montserrat_latin_ext.ttf new file mode 100644 index 0000000..6cef0bb Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/montserrat_latin_ext.ttf differ diff --git a/app/desktop/dmg-background/assets/fonts/montserrat_vietnamese.ttf b/app/desktop/dmg-background/assets/fonts/montserrat_vietnamese.ttf new file mode 100644 index 0000000..4a22655 Binary files /dev/null and b/app/desktop/dmg-background/assets/fonts/montserrat_vietnamese.ttf differ diff --git a/app/desktop/dmg-background/assets/logo_square.png b/app/desktop/dmg-background/assets/logo_square.png new file mode 100644 index 0000000..691e96d Binary files /dev/null and b/app/desktop/dmg-background/assets/logo_square.png differ diff --git a/app/desktop/dmg-background/dmg-config.json b/app/desktop/dmg-background/dmg-config.json new file mode 100644 index 0000000..4f0f5c4 --- /dev/null +++ b/app/desktop/dmg-background/dmg-config.json @@ -0,0 +1,6 @@ +{ + "appLabel": "FromChat", + "applicationsLabel": "Программы", + "primaryColor": "#dabaf9" +} + diff --git a/app/desktop/dmg-background/export.mjs b/app/desktop/dmg-background/export.mjs new file mode 100644 index 0000000..c27bbd2 --- /dev/null +++ b/app/desktop/dmg-background/export.mjs @@ -0,0 +1,332 @@ +#!/usr/bin/env node +/** + * Export the DMG artboard to PNG + icon slot positions for create-dmg. + * + * Args: + * + * 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 "); + 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); +}); diff --git a/app/desktop/dmg-background/index.html b/app/desktop/dmg-background/index.html new file mode 100644 index 0000000..42d6414 --- /dev/null +++ b/app/desktop/dmg-background/index.html @@ -0,0 +1,47 @@ + + + + + + FromChat — фон DMG + + + + + + + + + + diff --git a/app/desktop/dmg-background/package.json b/app/desktop/dmg-background/package.json new file mode 100644 index 0000000..44751ca --- /dev/null +++ b/app/desktop/dmg-background/package.json @@ -0,0 +1,9 @@ +{ + "name": "fromchat-dmg-background-tools", + "private": true, + "type": "module", + "dependencies": { + "playwright": "1.52.0" + } +} + diff --git a/app/desktop/dmg-background/styles.css b/app/desktop/dmg-background/styles.css new file mode 100644 index 0000000..519461e --- /dev/null +++ b/app/desktop/dmg-background/styles.css @@ -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; +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index 8fad21d..8727bd6 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -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) { 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) { 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) { 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) { ) { 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): 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 } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt index d09e84a..9ac4501 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt @@ -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( diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.jvm.kt index 4e730a8..3d75963 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.jvm.kt @@ -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() @@ -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()