diff --git a/CLAUDE.md b/CLAUDE.md index 6891785..46b7a5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ - `src/storage.rs` — сессия: атомарная запись, `.bak`, автослияние внешних изменений - `src/app.rs` — экраны и UI; `src/widgets.rs`, `src/theme.rs` — виджеты и стиль - `src/devshots.rs` — dev-сценарий со скриншотами (feature `dev-shots`) +- `build.rs` — вшивает иконку в exe; `scripts/` — установка для Windows и Linux ## Команды - `cargo test` — все тесты; `cargo run` — запуск; `cargo build --release` — релиз diff --git a/Cargo.lock b/Cargo.lock index 5b5cfc2..227be43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2244,6 +2244,7 @@ dependencies = [ "sha1", "sha2", "uuid", + "winresource", "zeroize", ] @@ -2514,6 +2515,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sha1" version = "0.10.7" @@ -2791,6 +2801,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2821,6 +2846,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tracing" version = "0.1.44" @@ -3631,6 +3662,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 5068366..3d576e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,9 @@ strip = true [target.'cfg(target_os = "linux")'.dependencies] arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } +[build-dependencies] +winresource = "0.1" + [dependencies.png] version = "0.17" optional = true diff --git a/README.md b/README.md index 92833e8..44788b3 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,22 @@ Qulyp каждые 2 секунды проверяет, не изменился | `Ctrl+L` | заблокировать | | `↑` `↓`, `Del`, `Esc` | навигация, удаление, отмена | -## Сборка +## Установка +Windows (без прав администратора; exe → `%LOCALAPPDATA%Programsulyp\`\,\ ярлык\ в\ \«Пуске\»\)\:\ +\ +\`\`\`bash\ +powershell\ \-ExecutionPolicy\ Bypass\ \-File\ scripts\/install\-windows\.ps1\ +\`\`\`\ +\ +Флаги\:\ \`\-Desktop\`\ \—\ ещё\ ярлык\ на\ рабочем\ столе\,\ \`\-Uninstall\`\ \—\ удалить\.\ Linux\ \(\`\~\/\.local\/bin\`\ \+\ пункт\ в\ меню\ приложений\)\:\ +\ +\`\`\`bash\ +sh\ scripts\/install\-linux\.sh\ +\`\`\`\ +\ +Удаление\ \—\ \`\-\-uninstall\`\.\ Файлы\ хранилищ\ при\ удалении\ не\ трогаются\.\ Для\ обновления\ запустите\ скрипт\ ещё\ раз\.\ +\ +\#\#\ Сборка\ вручную\ Нужен Rust 1.85+ (). ```bash diff --git a/assets/qulyp.svg b/assets/qulyp.svg new file mode 100644 index 0000000..1e4cb15 --- /dev/null +++ b/assets/qulyp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..86d423d --- /dev/null +++ b/build.rs @@ -0,0 +1,71 @@ +//! На Windows вшивает в exe иконку и описание — их видят проводник, «Пуск» и поиск. + +#[allow(dead_code)] +#[path = "src/icon_pixels.rs"] +mod icon_pixels; + +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=src/icon_pixels.rs"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let ico = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("qulyp.ico"); + std::fs::write(&ico, make_ico(&[16, 24, 32, 48, 64, 256])).expect("write ico"); + + let mut res = winresource::WindowsResource::new(); + res.set_icon(ico.to_str().unwrap()) + .set("ProductName", "Qulyp") + .set("FileDescription", "Qulyp — менеджер паролей") + .set("LegalCopyright", "MIT"); + if let Err(e) = res.compile() { + // Без Windows SDK exe всё равно соберётся, просто без иконки. + println!("cargo:warning=иконка не вшита: {e}"); + } +} + +/// ICO из нескольких 32-битных BMP-образов. +fn make_ico(sizes: &[usize]) -> Vec { + let images: Vec> = sizes.iter().map(|&n| bmp_image(n)).collect(); + let mut out = Vec::new(); + out.extend_from_slice(&[0, 0, 1, 0]); + out.extend_from_slice(&(sizes.len() as u16).to_le_bytes()); + let mut offset = 6 + 16 * sizes.len(); + for (&n, img) in sizes.iter().zip(&images) { + let b = if n >= 256 { 0 } else { n as u8 }; + out.extend_from_slice(&[b, b, 0, 0]); + out.extend_from_slice(&1u16.to_le_bytes()); // planes + out.extend_from_slice(&32u16.to_le_bytes()); // bpp + out.extend_from_slice(&(img.len() as u32).to_le_bytes()); + out.extend_from_slice(&(offset as u32).to_le_bytes()); + offset += img.len(); + } + for img in images { + out.extend_from_slice(&img); + } + out +} + +fn bmp_image(n: usize) -> Vec { + let rgba = icon_pixels::rgba(n); + let mask_row = n.div_ceil(32) * 4; + let mut out = Vec::new(); + // BITMAPINFOHEADER: высота удвоена (XOR-картинка + AND-маска) + for v in [40u32, n as u32, (n * 2) as u32] { + out.extend_from_slice(&v.to_le_bytes()); + } + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&32u16.to_le_bytes()); + for v in [0u32, (n * n * 4 + mask_row * n) as u32, 0, 0, 0, 0] { + out.extend_from_slice(&v.to_le_bytes()); + } + for y in (0..n).rev() { + for x in 0..n { + let i = (y * n + x) * 4; + out.extend_from_slice(&[rgba[i + 2], rgba[i + 1], rgba[i], rgba[i + 3]]); + } + } + out.extend(std::iter::repeat_n(0u8, mask_row * n)); // прозрачность берётся из альфы + out +} diff --git a/notes/LOG.md b/notes/LOG.md index d168a38..59d7959 100644 --- a/notes/LOG.md +++ b/notes/LOG.md @@ -1,6 +1,10 @@ # Журнал (новые сверху) +## 2026-09-30 (установка) +- Скрипты установки scripts/install-windows.ps1 (ярлык в «Пуске») и install-linux.sh (.desktop) +- Иконка и описание вшиты в exe (build.rs + winresource), общий растр в src/icon_pixels.rs + ## 2026-09-30 (дизайн) - Строгий стиль: графитовая палитра, один приглушённый акцент, скругления 4–6 px, без теней на панелях - Свои векторные иконки (src/icons.rs) вместо эмодзи; нейтральные монограммы вместо цветных аватаров diff --git a/notes/PLAN.md b/notes/PLAN.md index b7ae59b..4fc2c3b 100644 --- a/notes/PLAN.md +++ b/notes/PLAN.md @@ -7,7 +7,7 @@ ## Дальше - [ ] Импорт/экспорт CSV (форматы Bitwarden, Chrome, KeePass) -- [ ] Сборка дистрибутивов: .msi/.exe-установщик, AppImage/.deb +- [ ] Дистрибутивы: .msi-установщик, AppImage/.deb (пока — скрипты в scripts/) - [ ] Ассоциация файлов `.qulyp` (двойной клик открывает хранилище) - [ ] Автоподхват конфликтных копий облака (`… (conflicted copy).qulyp`) diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh new file mode 100755 index 0000000..18c2455 --- /dev/null +++ b/scripts/install-linux.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env sh +# Установка Qulyp для текущего пользователя (без sudo): +# бинарник -> ~/.local/bin/qulyp +# ярлык -> ~/.local/share/applications/qulyp.desktop (виден в меню и поиске приложений) +# иконка -> ~/.local/share/icons/hicolor/scalable/apps/qulyp.svg +# +# sh scripts/install-linux.sh # установить / обновить +# sh scripts/install-linux.sh --uninstall # удалить (хранилища .qulyp не трогаются) +set -eu + +root=$(cd "$(dirname "$0")/.." && pwd) +bin="$HOME/.local/bin" +apps="$HOME/.local/share/applications" +icons="$HOME/.local/share/icons/hicolor/scalable/apps" + +if [ "${1:-}" = "--uninstall" ]; then + rm -f "$bin/qulyp" "$apps/qulyp.desktop" "$icons/qulyp.svg" + command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" || true + echo "Qulyp удалён. Файлы хранилищ (.qulyp) и настройки не тронуты." + exit 0 +fi + +echo "Сборка release…" +cargo build --release --manifest-path "$root/Cargo.toml" + +mkdir -p "$bin" "$apps" "$icons" +install -m 755 "$root/target/release/qulyp" "$bin/qulyp" +install -m 644 "$root/assets/qulyp.svg" "$icons/qulyp.svg" + +cat > "$apps/qulyp.desktop" </dev/null 2>&1 && update-desktop-database "$apps" || true +command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache -q "$HOME/.local/share/icons/hicolor" || true + +echo "Готово: $bin/qulyp" +echo "Найти программу: меню приложений -> поиск «Qulyp»." diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 new file mode 100644 index 0000000..dd32519 --- /dev/null +++ b/scripts/install-windows.ps1 @@ -0,0 +1,48 @@ +# Установка Qulyp для текущего пользователя (права администратора не нужны): +# exe -> %LOCALAPPDATA%\Programs\Qulyp\qulyp.exe +# ярлык в меню «Пуск» -> находится поиском Windows по слову «Qulyp» +# +# powershell -ExecutionPolicy Bypass -File scripts\install-windows.ps1 # установить / обновить +# powershell -ExecutionPolicy Bypass -File scripts\install-windows.ps1 -Desktop # + ярлык на рабочем столе +# powershell -ExecutionPolicy Bypass -File scripts\install-windows.ps1 -Uninstall # удалить программу (хранилища не трогаются) +param([switch]$Desktop, [switch]$Uninstall) +$ErrorActionPreference = 'Stop' + +$root = Split-Path $PSScriptRoot -Parent +$dest = Join-Path $env:LOCALAPPDATA 'Programs\Qulyp' +$startLnk = Join-Path ([Environment]::GetFolderPath('Programs')) 'Qulyp.lnk' +$deskLnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Qulyp.lnk' + +if ($Uninstall) { + Get-Process qulyp -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$dest*" } | Stop-Process -Force + foreach ($p in @($startLnk, $deskLnk)) { if (Test-Path $p) { Remove-Item $p -Force } } + if (Test-Path $dest) { Remove-Item $dest -Recurse -Force } + Write-Host 'Qulyp удалён. Файлы хранилищ (.qulyp) и настройки не тронуты.' + exit 0 +} + +Write-Host 'Сборка release…' +cargo build --release --manifest-path (Join-Path $root 'Cargo.toml') +if ($LASTEXITCODE -ne 0) { throw 'Сборка не удалась' } + +$running = Get-Process qulyp -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$dest*" } +if ($running) { throw 'Закройте Qulyp перед обновлением' } + +New-Item -ItemType Directory -Force $dest | Out-Null +$exe = Join-Path $dest 'qulyp.exe' +Copy-Item (Join-Path $root 'target\release\qulyp.exe') $exe -Force + +$shell = New-Object -ComObject WScript.Shell +$links = @($startLnk) +if ($Desktop) { $links += $deskLnk } +foreach ($path in $links) { + $lnk = $shell.CreateShortcut($path) + $lnk.TargetPath = $exe + $lnk.WorkingDirectory = $dest + $lnk.IconLocation = "$exe,0" + $lnk.Description = 'Qulyp — менеджер паролей' + $lnk.Save() +} + +Write-Host "Готово: $exe" +Write-Host 'Найти программу: Пуск -> поиск «Qulyp».' diff --git a/src/icon_pixels.rs b/src/icon_pixels.rs new file mode 100644 index 0000000..e40dd55 --- /dev/null +++ b/src/icon_pixels.rs @@ -0,0 +1,59 @@ +//! Растровая иконка приложения (квадрат с замком). Общий код для окна +//! (`theme::app_icon`) и `build.rs`, который вшивает .ico в exe на Windows. + +/// Цвет фона иконки — акцент интерфейса. +pub const BG: [u8; 3] = [0x44, 0x6F, 0xB0]; + +fn rrect(x: f32, y: f32, x0: f32, y0: f32, x1: f32, y1: f32, r: f32) -> bool { + let cx = x.clamp(x0 + r, x1 - r); + let cy = y.clamp(y0 + r, y1 - r); + (x - cx).powi(2) + (y - cy).powi(2) <= r * r +} + +/// Что находится в точке (координаты в сетке 64×64): 0 — пусто, 1 — фон, 2 — замок. +fn sample(x: f32, y: f32) -> u8 { + if !rrect(x, y, 2.0, 2.0, 62.0, 62.0, 10.0) { + return 0; + } + let d = ((x - 32.0).powi(2) + (y - 27.0).powi(2)).sqrt(); + let arc = y <= 27.0 && (8.5..=12.5).contains(&d); + let legs = ((19.5..=23.5).contains(&x) || (40.5..=44.5).contains(&x)) && (27.0..=32.0).contains(&y); + let body = rrect(x, y, 16.0, 30.0, 48.0, 53.0, 4.0); + let hole = ((x - 32.0).powi(2) + (y - 39.5).powi(2)).sqrt() <= 3.4 + || ((30.4..=33.6).contains(&x) && (39.5..=46.5).contains(&y)); + if (arc || legs || body) && !hole { 2 } else { 1 } +} + +/// RGBA-пиксели иконки размера `n×n` со сглаживанием 4×4. +pub fn rgba(n: usize) -> Vec { + const SS: usize = 4; + let mut out = vec![0u8; n * n * 4]; + let k = 64.0 / n as f32; + for py in 0..n { + for px in 0..n { + let (mut bg, mut fg) = (0u32, 0u32); + for sy in 0..SS { + for sx in 0..SS { + let x = (px as f32 + (sx as f32 + 0.5) / SS as f32) * k; + let y = (py as f32 + (sy as f32 + 0.5) / SS as f32) * k; + match sample(x, y) { + 1 => bg += 1, + 2 => fg += 1, + _ => {} + } + } + } + let covered = bg + fg; + if covered == 0 { + continue; + } + let t = fg as f32 / covered as f32; // доля белого внутри покрытой области + let i = (py * n + px) * 4; + for c in 0..3 { + out[i + c] = (BG[c] as f32 * (1.0 - t) + 255.0 * t).round() as u8; + } + out[i + 3] = (255 * covered / (SS * SS) as u32) as u8; + } + } + out +} diff --git a/src/main.rs b/src/main.rs index 3ab6d3f..8b3302e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod crypto; #[cfg(feature = "dev-shots")] mod devshots; mod generator; +mod icon_pixels; mod icons; mod storage; mod theme; diff --git a/src/theme.rs b/src/theme.rs index 7522a93..8817693 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -210,38 +210,8 @@ fn install_fonts(ctx: &egui::Context) { ctx.set_fonts(fonts); } -/// Иконка окна: скруглённый квадрат с замком, рисуется попиксельно. +/// Иконка окна (та же, что вшита в exe). pub fn app_icon() -> IconData { const N: usize = 64; - let mut rgba = vec![0u8; N * N * 4]; - let inside_rrect = |x: f32, y: f32, x0: f32, y0: f32, x1: f32, y1: f32, r: f32| { - let cx = x.clamp(x0 + r, x1 - r); - let cy = y.clamp(y0 + r, y1 - r); - (x - cx).powi(2) + (y - cy).powi(2) <= r * r - }; - for py in 0..N { - for px in 0..N { - let (x, y) = (px as f32 + 0.5, py as f32 + 0.5); - let i = (py * N + px) * 4; - if !inside_rrect(x, y, 2.0, 2.0, 62.0, 62.0, 10.0) { - continue; - } - let [r, g, b, _] = ACCENT.to_array(); - let mut c = [r, g, b, 255]; - // Дужка замка - let d = ((x - 32.0).powi(2) + (y - 27.0).powi(2)).sqrt(); - let shackle = y <= 30.0 && (8.5..=13.0).contains(&d); - let legs = (19.0..=23.5).contains(&x) || (40.5..=45.0).contains(&x); - let shackle = shackle || (legs && (27.0..=32.0).contains(&y)); - // Корпус с замочной скважиной - let body = inside_rrect(x, y, 15.0, 30.0, 49.0, 54.0, 5.0); - let hole = ((x - 32.0).powi(2) + (y - 39.5).powi(2)).sqrt() <= 3.5 - || ((30.5..=33.5).contains(&x) && (39.5..=47.0).contains(&y)); - if (shackle || body) && !hole { - c = [255, 255, 255, 255]; - } - rgba[i..i + 4].copy_from_slice(&c); - } - } - IconData { rgba, width: N as u32, height: N as u32 } + IconData { rgba: crate::icon_pixels::rgba(N), width: N as u32, height: N as u32 } }