Improve desktop branding and NSIS assets
This commit is contained in:
parent
9439890488
commit
78030dbcdb
5 changed files with 253 additions and 12 deletions
231
apps/desktop/scripts/png_to_bmp.py
Normal file
231
apps/desktop/scripts/png_to_bmp.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Utility script to convert a PNG file (non-interlaced, 8-bit RGBA/RGB)
|
||||
into a 24-bit BMP with optional letterboxing resize.
|
||||
|
||||
The script is intentionally lightweight and relies only on Python's
|
||||
standard library so it can run in constrained build environments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def parse_png(path: Path):
|
||||
data = path.read_bytes()
|
||||
if not data.startswith(PNG_SIGNATURE):
|
||||
raise ValueError("Input is not a PNG file")
|
||||
|
||||
idx = len(PNG_SIGNATURE)
|
||||
width = height = bit_depth = color_type = None
|
||||
compressed = bytearray()
|
||||
interlaced = False
|
||||
|
||||
while idx < len(data):
|
||||
if idx + 8 > len(data):
|
||||
raise ValueError("Corrupted PNG (unexpected EOF)")
|
||||
length = struct.unpack(">I", data[idx : idx + 4])[0]
|
||||
idx += 4
|
||||
chunk_type = data[idx : idx + 4]
|
||||
idx += 4
|
||||
chunk_data = data[idx : idx + length]
|
||||
idx += length
|
||||
crc = data[idx : idx + 4] # noqa: F841 - crc skipped (validated by reader)
|
||||
idx += 4
|
||||
|
||||
if chunk_type == b"IHDR":
|
||||
width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack(
|
||||
">IIBBBBB", chunk_data
|
||||
)
|
||||
if compression != 0 or filter_method != 0:
|
||||
raise ValueError("Unsupported PNG compression/filter method")
|
||||
interlaced = interlace != 0
|
||||
elif chunk_type == b"IDAT":
|
||||
compressed.extend(chunk_data)
|
||||
elif chunk_type == b"IEND":
|
||||
break
|
||||
|
||||
if interlaced:
|
||||
raise ValueError("Interlaced PNGs are not supported by this script")
|
||||
if bit_depth != 8:
|
||||
raise ValueError(f"Unsupported bit depth: {bit_depth}")
|
||||
if color_type not in (2, 6):
|
||||
raise ValueError(f"Unsupported color type: {color_type}")
|
||||
|
||||
raw = zlib.decompress(bytes(compressed))
|
||||
bytes_per_pixel = 3 if color_type == 2 else 4
|
||||
stride = width * bytes_per_pixel
|
||||
expected = (stride + 1) * height
|
||||
if len(raw) != expected:
|
||||
raise ValueError("Corrupted PNG data")
|
||||
|
||||
# Apply PNG scanline filters
|
||||
image = bytearray(width * height * 4) # Force RGBA output
|
||||
prev_row = [0] * (stride)
|
||||
|
||||
def paeth(a, b, c):
|
||||
p = a + b - c
|
||||
pa = abs(p - a)
|
||||
pb = abs(p - b)
|
||||
pc = abs(p - c)
|
||||
if pa <= pb and pa <= pc:
|
||||
return a
|
||||
if pb <= pc:
|
||||
return b
|
||||
return c
|
||||
|
||||
out_idx = 0
|
||||
for y in range(height):
|
||||
offset = y * (stride + 1)
|
||||
filter_type = raw[offset]
|
||||
row = bytearray(raw[offset + 1 : offset + 1 + stride])
|
||||
if filter_type == 1: # Sub
|
||||
for i in range(stride):
|
||||
left = row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
|
||||
row[i] = (row[i] + left) & 0xFF
|
||||
elif filter_type == 2: # Up
|
||||
for i in range(stride):
|
||||
row[i] = (row[i] + prev_row[i]) & 0xFF
|
||||
elif filter_type == 3: # Average
|
||||
for i in range(stride):
|
||||
left = row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
|
||||
up = prev_row[i]
|
||||
row[i] = (row[i] + ((left + up) >> 1)) & 0xFF
|
||||
elif filter_type == 4: # Paeth
|
||||
for i in range(stride):
|
||||
left = row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
|
||||
up = prev_row[i]
|
||||
up_left = prev_row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
|
||||
row[i] = (row[i] + paeth(left, up, up_left)) & 0xFF
|
||||
elif filter_type != 0:
|
||||
raise ValueError(f"Unsupported PNG filter type: {filter_type}")
|
||||
|
||||
# Convert to RGBA
|
||||
for x in range(width):
|
||||
if color_type == 2:
|
||||
r, g, b = row[x * 3 : x * 3 + 3]
|
||||
a = 255
|
||||
else:
|
||||
r, g, b, a = row[x * 4 : x * 4 + 4]
|
||||
image[out_idx : out_idx + 4] = bytes((r, g, b, a))
|
||||
out_idx += 4
|
||||
|
||||
prev_row = list(row)
|
||||
|
||||
return width, height, image
|
||||
|
||||
|
||||
def resize_with_letterbox(image, width, height, target_w, target_h, background):
|
||||
if width == target_w and height == target_h:
|
||||
return image, width, height
|
||||
|
||||
bg_r, bg_g, bg_b = background
|
||||
scale = min(target_w / width, target_h / height)
|
||||
scale = max(scale, 1 / max(width, height)) # avoid zero
|
||||
scaled_w = max(1, int(round(width * scale)))
|
||||
scaled_h = max(1, int(round(height * scale)))
|
||||
|
||||
output = bytearray(target_w * target_h * 4)
|
||||
# Fill background
|
||||
for i in range(0, len(output), 4):
|
||||
output[i : i + 4] = bytes((bg_r, bg_g, bg_b, 255))
|
||||
|
||||
offset_x = (target_w - scaled_w) // 2
|
||||
offset_y = (target_h - scaled_h) // 2
|
||||
|
||||
for y in range(scaled_h):
|
||||
src_y = min(height - 1, int(round(y / scale)))
|
||||
for x in range(scaled_w):
|
||||
src_x = min(width - 1, int(round(x / scale)))
|
||||
src_idx = (src_y * width + src_x) * 4
|
||||
dst_idx = ((y + offset_y) * target_w + (x + offset_x)) * 4
|
||||
output[dst_idx : dst_idx + 4] = image[src_idx : src_idx + 4]
|
||||
|
||||
return output, target_w, target_h
|
||||
|
||||
|
||||
def blend_to_rgb(image):
|
||||
rgb = bytearray(len(image) // 4 * 3)
|
||||
for i in range(0, len(image), 4):
|
||||
r, g, b, a = image[i : i + 4]
|
||||
if a == 255:
|
||||
rgb[(i // 4) * 3 : (i // 4) * 3 + 3] = bytes((b, g, r)) # BMP stores BGR
|
||||
else:
|
||||
alpha = a / 255.0
|
||||
bg = (255, 255, 255)
|
||||
rr = int(round(r * alpha + bg[0] * (1 - alpha)))
|
||||
gg = int(round(g * alpha + bg[1] * (1 - alpha)))
|
||||
bb = int(round(b * alpha + bg[2] * (1 - alpha)))
|
||||
rgb[(i // 4) * 3 : (i // 4) * 3 + 3] = bytes((bb, gg, rr))
|
||||
return rgb
|
||||
|
||||
|
||||
def write_bmp(path: Path, width: int, height: int, rgb: bytearray):
|
||||
row_stride = (width * 3 + 3) & ~3 # align to 4 bytes
|
||||
padding = row_stride - width * 3
|
||||
pixel_data = bytearray()
|
||||
|
||||
for y in range(height - 1, -1, -1):
|
||||
start = y * width * 3
|
||||
end = start + width * 3
|
||||
pixel_data.extend(rgb[start:end])
|
||||
if padding:
|
||||
pixel_data.extend(b"\0" * padding)
|
||||
|
||||
file_size = 14 + 40 + len(pixel_data)
|
||||
header = struct.pack("<2sIHHI", b"BM", file_size, 0, 0, 14 + 40)
|
||||
dib_header = struct.pack(
|
||||
"<IIIHHIIIIII",
|
||||
40, # header size
|
||||
width,
|
||||
height,
|
||||
1, # planes
|
||||
24, # bits per pixel
|
||||
0, # compression
|
||||
len(pixel_data),
|
||||
2835, # horizontal resolution (px/m ~72dpi)
|
||||
2835, # vertical resolution
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
path.write_bytes(header + dib_header + pixel_data)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--width", type=int, help="Target width (px)")
|
||||
parser.add_argument("--height", type=int, help="Target height (px)")
|
||||
parser.add_argument(
|
||||
"--background",
|
||||
type=str,
|
||||
default="FFFFFF",
|
||||
help="Background hex color used for transparent pixels (default: FFFFFF)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
width, height, image = parse_png(args.input)
|
||||
if args.width and args.height:
|
||||
bg = tuple(int(args.background[i : i + 2], 16) for i in (0, 2, 4))
|
||||
image, width, height = resize_with_letterbox(image, width, height, args.width, args.height, bg)
|
||||
rgb = blend_to_rgb(image)
|
||||
write_bmp(args.output, width, height, rgb)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
BIN
apps/desktop/src-tauri/icons/nsis-header.bmp
Normal file
BIN
apps/desktop/src-tauri/icons/nsis-header.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/desktop/src-tauri/icons/nsis-sidebar.bmp
Normal file
BIN
apps/desktop/src-tauri/icons/nsis-sidebar.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
|
|
@ -53,6 +53,9 @@
|
|||
"nsis": {
|
||||
"displayLanguageSelector": true,
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"uninstallerIcon": "icons/icon.ico",
|
||||
"headerImage": "icons/nsis-header.bmp",
|
||||
"sidebarImage": "icons/nsis-sidebar.bmp",
|
||||
"installMode": "perMachine",
|
||||
"languages": ["PortugueseBR"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -751,7 +751,7 @@ function App() {
|
|||
<DeactivationScreen companyName={companyName} />
|
||||
) : (
|
||||
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="mb-6 flex flex-col items-center gap-4 text-center">
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Logotipo Raven"
|
||||
|
|
@ -764,18 +764,20 @@ function App() {
|
|||
setLogoSrc(`${appUrl}/raven.png`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-8 items-center justify-center rounded-lg bg-neutral-900 text-white"><GalleryVerticalEnd className="size-4" /></span>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-base font-semibold text-neutral-900">Raven</span>
|
||||
<span className="text-xs font-medium text-neutral-500">Portal do Cliente</span>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-neutral-500">Portal do Cliente</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-xl bg-neutral-900 text-white shadow-sm">
|
||||
<GalleryVerticalEnd className="size-5" />
|
||||
</span>
|
||||
<span className="text-2xl font-semibold text-neutral-900">Raven</span>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<h1 className="text-lg font-semibold text-slate-800">Agente Desktop</h1>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-base font-semibold text-slate-800">Agente Desktop</h1>
|
||||
{error ? <p className="mt-3 rounded-md bg-rose-50 p-2 text-sm text-rose-700">{error}</p> : null}
|
||||
{!token ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
|
|
@ -971,14 +973,19 @@ function App() {
|
|||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string | null }) {
|
||||
function StatusBadge({ status, className }: { status: string | null; className?: string }) {
|
||||
const s = (status ?? "").toLowerCase()
|
||||
const label = s === "online" ? "Online" : s === "offline" ? "Offline" : s === "maintenance" ? "Manutenção" : "Sem status"
|
||||
const dot = s === "online" ? "bg-emerald-500" : s === "offline" ? "bg-rose-500" : s === "maintenance" ? "bg-amber-500" : "bg-slate-400"
|
||||
const ring = s === "online" ? "bg-emerald-400/30" : s === "offline" ? "bg-rose-400/30" : s === "maintenance" ? "bg-amber-400/30" : "bg-slate-300/30"
|
||||
const isOnline = s === "online"
|
||||
return (
|
||||
<span className="inline-flex h-8 items-center gap-4 rounded-full border border-slate-200 px-3 text-sm font-semibold">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-3 rounded-full border border-slate-200 bg-white/80 px-3 text-sm font-medium text-neutral-600 shadow-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="relative inline-flex items-center">
|
||||
<span className={cn("size-2 rounded-full", dot)} />
|
||||
{isOnline ? <span className={cn("absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full animate-ping [animation-duration:2s]", ring)} /> : null}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue