diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..c42281f --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,70 @@ +name: Windows build + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: windows-2022 + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Configure x64 Release + run: cmake -S . -B build/release -G "Visual Studio 17 2022" -A x64 + + - name: Build + run: cmake --build build/release --config Release --parallel + + - name: Run self-test + shell: pwsh + run: powershell.exe -NoProfile -ExecutionPolicy Bypass -File ./scripts/ci-smoke.ps1 -Exe ./build/release/bin/Release/Peek.exe + + - name: Prepare artifact + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path artifact | Out-Null + Copy-Item build/release/bin/Release/Peek.exe artifact/Peek.exe + $hash = (Get-FileHash artifact/Peek.exe -Algorithm SHA256).Hash + "$hash Peek.exe" | Set-Content artifact/SHA256SUMS.txt -Encoding ascii + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: Peek-windows-x64 + path: artifact/* + if-no-files-found: error + + publish: + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: windows-2022 + permissions: + contents: write + steps: + - name: Download verified build + uses: actions/download-artifact@v4 + with: + name: Peek-windows-x64 + path: artifact + + - name: Create or update GitHub Release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "${{ github.ref_name }}" 2>$null + if ($LASTEXITCODE -eq 0) { + gh release upload "${{ github.ref_name }}" artifact/Peek.exe artifact/SHA256SUMS.txt --clobber + } else { + gh release create "${{ github.ref_name }}" artifact/Peek.exe artifact/SHA256SUMS.txt --generate-notes --title "${{ github.ref_name }}" + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/README.md b/README.md index 8bef5f9..7b818df 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ [![License: MIT](https://img.shields.io/github/license/HaBaDEDE/Peek?style=flat-square)](LICENSE) ![Windows 10/11](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D4?style=flat-square&logo=windows) +**[官方网站 / Official website](https://habadede.github.io/Peek/)** + [简体中文](#readme-zh) · [English](#readme-en) **[下载最新版本](https://github.com/HaBaDEDE/Peek/releases/latest/download/Peek.exe) · [Download latest release](https://github.com/HaBaDEDE/Peek/releases/latest/download/Peek.exe)** diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/.nojekyll @@ -0,0 +1 @@ + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..d461412 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,140 @@ + + + + + + + + + + + + + Peek — Tiny native Windows utility + + + + + +
+ + + Peek + + +
+ +
+
+
+

NATIVE WINDOWS UTILITY

+

Three answers. One tiny Windows tool.

+

Find what flashed past, who locked a file, and which Windows UI is under the pointer.

+ +

Windows 10/11 · x64 · Portable · No account · No telemetry

+
+
+ Peek Ghost showing recent process events +
+
+ +
+
+ +

The answer is one hotkey away.

+
+ +
+ +
+
+ +

Only what you need, when you need it.

+
+ +
+
+ 01 +

Ghost

+

What just flashed past?

+

Keeps the latest short-lived process and top-level window events in memory, including lifetime, path, command line, and parent process when Windows makes them available.

+ Ctrl + Shift + G +
+ Ghost feature +
+ +
+
+ 02 +

Unlock

+

Who is using this file?

+

Drop a file or choose one. Peek uses Windows Restart Manager to identify likely owners and lets you reveal the executable or end the process after confirmation.

+
+ Unlock feature +
+ +
+
+ 03 +

Focus

+

What Windows UI is under the pointer?

+

Highlight a window or control and read its HWND, process, class, title, rectangle, and available UI Automation properties.

+ Ctrl + Shift + F +
+ Focus feature +
+
+ +
+
+ +

Native, offline, and understandable.

+
+ +
+ +
+ Peek icon +
+

Keep the answer one action away.

+

Download the portable Windows x64 release.

+
+ Download +
+
+ + + + + diff --git a/docs/media/peek-ghost-demo.mp4 b/docs/media/peek-ghost-demo.mp4 new file mode 100644 index 0000000..e402a5c Binary files /dev/null and b/docs/media/peek-ghost-demo.mp4 differ diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..79476d9 --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://habadede.github.io/Peek/sitemap.xml diff --git a/docs/site.css b/docs/site.css new file mode 100644 index 0000000..b8c7502 --- /dev/null +++ b/docs/site.css @@ -0,0 +1,131 @@ +:root { + color-scheme: light; + --ink: #10213a; + --muted: #5e6f87; + --blue: #2869e8; + --blue-dark: #174fbd; + --line: #dce4ef; + --panel: #f5f8fc; + --navy: #0d1b2f; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + color: var(--ink); + background: #fff; + font-family: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; + font-size: 17px; + line-height: 1.65; +} +a { color: inherit; } +img, video { display: block; max-width: 100%; } + +.topbar { + position: sticky; + z-index: 10; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + height: 68px; + padding: 0 max(24px, calc((100vw - 1180px) / 2)); + border-bottom: 1px solid rgba(220, 228, 239, .85); + background: rgba(255, 255, 255, .92); + backdrop-filter: blur(14px); +} +.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; font-size: 21px; font-weight: 650; } +.brand img { border-radius: 9px; } +nav { display: flex; align-items: center; gap: 27px; } +nav a { color: #42536b; text-decoration: none; font-size: 15px; } +nav a:hover { color: var(--blue); } +.language { min-width: 62px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 7px; color: var(--ink); background: #fff; font: inherit; font-size: 14px; cursor: pointer; } +.language:hover { border-color: #aab8cc; } + +main { overflow: hidden; } +.hero { + display: grid; + grid-template-columns: minmax(0, .9fr) minmax(420px, 1.1fr); + gap: 74px; + align-items: center; + max-width: 1180px; + min-height: 690px; + margin: 0 auto; + padding: 80px 24px 92px; +} +.eyebrow, .section-label { margin: 0 0 14px; color: var(--blue); font-size: 13px; font-weight: 700; letter-spacing: .14em; } +h1, h2, h3 { margin-top: 0; line-height: 1.13; letter-spacing: -.025em; } +h1 { max-width: 650px; margin-bottom: 23px; font-size: clamp(48px, 5.1vw, 74px); font-weight: 690; } +.lead { max-width: 590px; margin: 0 0 34px; color: var(--muted); font-size: 21px; line-height: 1.6; } +.actions { display: flex; flex-wrap: wrap; gap: 12px; } +.button { display: inline-flex; align-items: center; justify-content: center; min-height: 48px; padding: 0 20px; border: 1px solid transparent; border-radius: 8px; text-decoration: none; font-size: 15px; font-weight: 650; } +.primary { color: #fff; background: var(--blue); box-shadow: 0 8px 24px rgba(40, 105, 232, .2); } +.primary:hover { background: var(--blue-dark); } +.secondary { border-color: var(--line); background: #fff; } +.secondary:hover { border-color: #b8c5d6; background: #f9fbfd; } +.requirements { margin: 20px 0 0; color: #708199; font-size: 13px; } +.hero-visual { position: relative; padding: 26px; border-radius: 30px; background: linear-gradient(140deg, #eaf2ff, #f7f9fc); } +.hero-visual::after { position: absolute; inset: 8% -12% auto auto; z-index: -1; width: 360px; height: 360px; border-radius: 50%; background: #d8e6ff; filter: blur(60px); content: ""; } +.hero-visual img { width: min(100%, 520px); margin: auto; border: 1px solid #cbd5e2; border-radius: 12px; box-shadow: 0 25px 70px rgba(23, 43, 74, .18); } + +.demo { display: grid; grid-template-columns: .65fr 1.35fr; gap: 64px; align-items: start; max-width: 1180px; margin: 0 auto; padding: 104px 24px; border-top: 1px solid var(--line); } +.demo h2, .section-heading h2, .principles h2 { margin-bottom: 0; font-size: clamp(34px, 4vw, 50px); font-weight: 660; } +.demo video { width: 100%; border: 1px solid #cbd5e2; border-radius: 14px; background: var(--navy); box-shadow: 0 22px 60px rgba(23, 43, 74, .15); } + +.features { padding: 110px 24px; background: var(--panel); } +.section-heading { max-width: 1180px; margin: 0 auto 72px; } +.feature-card { display: grid; grid-template-columns: .9fr 1.1fr; gap: 72px; align-items: center; max-width: 1080px; margin: 0 auto 110px; } +.feature-card:last-child { margin-bottom: 0; } +.feature-card.reverse .feature-copy { order: 2; } +.feature-card.reverse img { order: 1; } +.feature-copy { max-width: 470px; } +.number { display: block; margin-bottom: 22px; color: #8ca0bb; font-size: 13px; font-weight: 700; letter-spacing: .13em; } +.feature-card h3 { margin-bottom: 8px; font-size: 42px; font-weight: 680; } +.feature-card .question { margin: 0 0 19px; color: var(--blue); font-size: 21px; font-weight: 600; } +.feature-card p:not(.question) { margin: 0 0 25px; color: var(--muted); } +kbd { display: inline-block; padding: 8px 13px; border: 1px solid #cbd6e4; border-bottom-width: 2px; border-radius: 7px; background: #fff; color: #354962; font: 600 13px "Segoe UI", sans-serif; } +.feature-card img { max-height: 630px; margin: auto; border: 1px solid #cbd5e2; border-radius: 12px; box-shadow: 0 20px 50px rgba(23, 43, 74, .13); } + +.principles { display: grid; grid-template-columns: .8fr 1.2fr; gap: 90px; max-width: 1180px; margin: 0 auto; padding: 116px 24px; } +.principles ul { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; padding: 0; background: var(--line); border: 1px solid var(--line); list-style: none; } +.principles li { min-height: 170px; padding: 29px; background: #fff; } +.principles strong, .principles span { display: block; } +.principles strong { margin-bottom: 8px; font-size: 18px; } +.principles span { color: var(--muted); font-size: 15px; } + +.download { display: grid; grid-template-columns: auto 1fr auto; gap: 25px; align-items: center; max-width: 1132px; margin: 0 auto 105px; padding: 38px; border-radius: 18px; color: #fff; background: var(--navy); } +.download img { border-radius: 18px; } +.download h2 { margin: 0 0 4px; font-size: 27px; } +.download p { margin: 0; color: #b9c6d8; } +.download .button { min-width: 130px; } + +footer { display: flex; justify-content: space-between; max-width: 1180px; margin: 0 auto; padding: 28px 24px 40px; border-top: 1px solid var(--line); color: #74859b; font-size: 13px; } + +@media (max-width: 850px) { + nav a { display: none; } + .hero, .demo, .principles { grid-template-columns: 1fr; } + .hero { gap: 52px; min-height: 0; padding-top: 64px; } + .hero-visual { max-width: 590px; } + .demo { gap: 36px; } + .feature-card { grid-template-columns: 1fr; gap: 36px; } + .feature-card.reverse .feature-copy, .feature-card.reverse img { order: initial; } + .feature-copy { max-width: 650px; } + .principles { gap: 45px; } +} + +@media (max-width: 560px) { + body { font-size: 16px; } + h1 { font-size: 45px; } + .topbar { padding: 0 18px; } + .hero, .demo, .features, .principles { padding-left: 18px; padding-right: 18px; } + .principles ul { grid-template-columns: 1fr; } + .download { grid-template-columns: auto 1fr; margin: 0 18px 70px; padding: 26px; } + .download img { width: 58px; height: 58px; } + .download .button { grid-column: 1 / -1; } + footer { flex-direction: column; gap: 8px; padding-left: 18px; padding-right: 18px; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } +} diff --git a/docs/site.js b/docs/site.js new file mode 100644 index 0000000..ac4d863 --- /dev/null +++ b/docs/site.js @@ -0,0 +1,24 @@ +(() => { + const button = document.querySelector("#language"); + const translatable = document.querySelectorAll("[data-en][data-zh]"); + const stored = localStorage.getItem("peek-site-language"); + const preferred = navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en"; + + function setLanguage(language) { + const next = language === "zh" ? "zh" : "en"; + document.documentElement.lang = next === "zh" ? "zh-CN" : "en"; + document.title = next === "zh" ? "Peek — 极小的原生 Windows 工具" : "Peek — Tiny native Windows utility"; + translatable.forEach((element) => { + element.textContent = element.dataset[next]; + }); + button.textContent = next === "zh" ? "EN" : "中文"; + button.dataset.language = next; + localStorage.setItem("peek-site-language", next); + } + + button.addEventListener("click", () => { + setLanguage(button.dataset.language === "zh" ? "en" : "zh"); + }); + + setLanguage(stored || preferred); +})(); diff --git a/docs/sitemap.xml b/docs/sitemap.xml new file mode 100644 index 0000000..f926f77 --- /dev/null +++ b/docs/sitemap.xml @@ -0,0 +1,8 @@ + + + + https://habadede.github.io/Peek/ + monthly + 1.0 + + diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 index c900881..2bdb6e1 100644 --- a/scripts/build-release.ps1 +++ b/scripts/build-release.ps1 @@ -18,7 +18,9 @@ if (-not $cmake) { throw "CMake 3.24+ was not found in PATH or Visual Studio." } $cmakePath = if ($cmake.Source) { $cmake.Source } else { $cmake.FullName } & $cmakePath -S $root -B $build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release +if ($LASTEXITCODE -ne 0) { throw "CMake configure failed with code $LASTEXITCODE." } & $cmakePath --build $build --config Release --parallel +if ($LASTEXITCODE -ne 0) { throw "Release build failed with code $LASTEXITCODE." } $exe = Join-Path $build "bin/Release/Peek.exe" if (-not (Test-Path $exe)) { diff --git a/scripts/ci-smoke.ps1 b/scripts/ci-smoke.ps1 new file mode 100644 index 0000000..1082046 --- /dev/null +++ b/scripts/ci-smoke.ps1 @@ -0,0 +1,29 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Exe) + +$ErrorActionPreference = "Stop" +$exePath = (Resolve-Path $Exe).Path +$report = Join-Path $env:TEMP "Peek-ci-selftest-$PID.txt" +$peek = $null + +try { + $selfTest = Start-Process $exePath -ArgumentList '--self-test',('"' + $report + '"') -PassThru -Wait + if ($selfTest.ExitCode -ne 0) { throw "Peek self-test failed with code $($selfTest.ExitCode)." } + if (-not (Test-Path $report)) { throw "Peek did not write its self-test report." } + + $contents = Get-Content $report + $contents + if ($contents -match '^FAIL') { throw "Peek self-test report contains a failure." } + + $peek = Start-Process $exePath -PassThru + Start-Sleep -Milliseconds 1000 + if ($peek.HasExited) { throw "Peek exited during startup with code $($peek.ExitCode)." } + Write-Host "Startup smoke test passed (PID $($peek.Id))." +} +finally { + if ($peek -and -not $peek.HasExited) { + Stop-Process -Id $peek.Id -Force -ErrorAction SilentlyContinue + $peek.WaitForExit(5000) | Out-Null + } + Remove-Item -LiteralPath $report -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/record-demo.py b/scripts/record-demo.py new file mode 100644 index 0000000..0de643f --- /dev/null +++ b/scripts/record-demo.py @@ -0,0 +1,135 @@ +"""Record the real Peek window into a privacy-safe 1280x720 product demo.""" + +from argparse import ArgumentParser +from ctypes import Structure, byref, sizeof, windll +from ctypes.wintypes import DWORD, HWND, LONG, RECT +from pathlib import Path +import sys +from time import monotonic, sleep + +from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageGrab + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "build" / "video-tools")) +import imageio_ffmpeg # noqa: E402 + + +DWMWA_EXTENDED_FRAME_BOUNDS = 9 + + +class DwmRect(Structure): + _fields_ = [("left", LONG), ("top", LONG), ("right", LONG), ("bottom", LONG)] + + +def capture_window(hwnd: HWND) -> Image.Image: + visible = DwmRect() + result = windll.dwmapi.DwmGetWindowAttribute( + hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, byref(visible), DWORD(sizeof(visible))) + if result != 0: + fallback = RECT() + if not windll.user32.GetWindowRect(hwnd, byref(fallback)): + raise RuntimeError("Unable to read Peek window bounds") + visible = DwmRect(fallback.left, fallback.top, fallback.right, fallback.bottom) + return ImageGrab.grab( + bbox=(visible.left, visible.top, visible.right, visible.bottom), + all_screens=True, + ).convert("RGB") + + +def make_frame(window: Image.Image, elapsed: float, icon: Image.Image, + empty_window: Image.Image) -> Image.Image: + canvas = Image.new("RGB", (1280, 720), (11, 23, 40)) + draw = ImageDraw.Draw(canvas) + regular = ImageFont.truetype("C:/Windows/Fonts/segoeui.ttf", 24) + semibold = ImageFont.truetype("C:/Windows/Fonts/seguisb.ttf", 48) + small = ImageFont.truetype("C:/Windows/Fonts/segoeui.ttf", 18) + shortcut = ImageFont.truetype("C:/Windows/Fonts/seguisb.ttf", 26) + + canvas.paste(icon, (58, 51), icon) + draw.text((145, 58), "Peek", font=semibold, fill=(245, 248, 252)) + draw.text((59, 151), "Ghost", font=small, fill=(92, 153, 255)) + + if elapsed < 4.3: + title, detail = "A window flashes.", "It is gone before you can read it." + step = "01" + elif elapsed < 8.0: + title, detail = "Press one hotkey.", "Open the latest Ghost event immediately." + step = "02" + elif elapsed < 13.0: + title, detail = "Peek identifies it.", "Process, lifetime, path, and parent." + step = "03" + else: + title, detail = "One action. One answer.", "Native Win32 · Offline · Portable" + step = "04" + + draw.text((59, 222), step, font=small, fill=(111, 145, 190)) + draw.multiline_text((59, 267), title, font=semibold, fill=(245, 248, 252), spacing=8) + draw.multiline_text((59, 344), detail, font=regular, fill=(161, 178, 202), spacing=8) + if 4.3 <= elapsed < 13.0: + draw.rounded_rectangle((59, 447, 416, 513), radius=9, + fill=(24, 43, 69), outline=(61, 91, 130)) + draw.text((237, 480), "Ctrl + Shift + G", font=shortcut, + fill=(245, 248, 252), anchor="mm") + + display_window = empty_window if elapsed < 4.3 else window + target_height = 664 + target_width = round(display_window.width * target_height / display_window.height) + rendered = display_window.resize((target_width, target_height), Image.Resampling.LANCZOS) + shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow) + x, y = 1280 - target_width - 38, 28 + shadow_draw.rounded_rectangle((x + 8, y + 12, x + target_width + 8, y + target_height + 12), + radius=18, fill=(0, 0, 0, 115)) + shadow = shadow.filter(ImageFilter.GaussianBlur(17)) + canvas = Image.alpha_composite(canvas.convert("RGBA"), shadow).convert("RGB") + canvas.paste(rendered, (x, y)) + return canvas + + +def main() -> None: + parser = ArgumentParser() + parser.add_argument("output", type=Path) + parser.add_argument("--title", default="Peek") + parser.add_argument("--duration", type=float, default=17.0) + parser.add_argument("--fps", type=int, default=10) + args = parser.parse_args() + + hwnd = windll.user32.FindWindowW(None, args.title) + deadline = monotonic() + 30 + while not hwnd and monotonic() < deadline: + sleep(.1) + hwnd = windll.user32.FindWindowW(None, args.title) + if not hwnd: + raise SystemExit("Peek window not found") + + icon = Image.open(ROOT / "docs/images/peek-icon.png").convert("RGBA") + icon.thumbnail((70, 70), Image.Resampling.LANCZOS) + empty_window = Image.open(ROOT / "docs/images/peek-demo-ghost-hd.png").convert("RGB") + args.output.parent.mkdir(parents=True, exist_ok=True) + writer = imageio_ffmpeg.write_frames( + str(args.output), (1280, 720), fps=args.fps, codec="libx264", + pix_fmt_in="rgb24", pix_fmt_out="yuv420p", quality=5, + output_params=["-movflags", "+faststart", "-preset", "slow"]) + writer.send(None) + started = monotonic() + frame_interval = 1 / args.fps + next_frame = started + try: + while True: + now = monotonic() + elapsed = now - started + if elapsed >= args.duration: + break + if now < next_frame: + sleep(next_frame - now) + window = capture_window(hwnd) + writer.send(make_frame(window, elapsed, icon, empty_window).tobytes()) + next_frame += frame_interval + finally: + writer.close() + print(f"Recorded {args.output} ({args.duration:.1f}s, {args.fps} fps)") + + +if __name__ == "__main__": + main()