Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Install clang-format
- name: Install clang-format 18
run: |
sudo apt-get update
sudo apt-get install -y clang-format
sudo apt-get install -y clang-format-18

- name: Check formatting
env:
CLANG_FORMAT: clang-format-18
run: |
chmod +x scripts/format.sh
./scripts/format.sh --check
Expand Down Expand Up @@ -122,7 +124,17 @@ jobs:
if (Test-Path "README.md") { Copy-Item "README.md" "$dir/" }
if (Test-Path "LICENSE") { Copy-Item "LICENSE" "$dir/" }
if (Test-Path "config") { Copy-Item "config" "$dir/config" -Recurse }
if (Test-Path "extension") { Copy-Item "extension" "$dir/extension" -Recurse }
# Prefer POST_BUILD-stamped companion; fall back to repo then stamp to tag X.Y.Z.
if (Test-Path "build/RelWithDebInfo/extension") {
Copy-Item "build/RelWithDebInfo/extension" "$dir/extension" -Recurse
} elseif (Test-Path "extension") {
Copy-Item "extension" "$dir/extension" -Recurse
}
if (Test-Path "$dir/extension/manifest.json") {
$ver = "${{ steps.ver.outputs.version }}"
& cmake "-DQP_EXT_DIR=$dir/extension" "-DQP_EXT_VERSION=$ver" -P "cmake/stamp_extension_version.cmake"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
Compress-Archive -Path "$dir/*" -DestinationPath "$name.zip" -Force
Get-Item "$name.zip" | Format-List Name, Length, FullName

Expand Down
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ Pure **Win32 + C++17**. No third-party libraries. Built to debug, trace, and ext
- **Fire on key release** so modifiers are up before automation runs
- Send-to-AI workflow:
1. Select-all + copy from the focused editor
2. Activate Chrome (Dev/Beta/stable) / Edge
3. New tab → open AI URL (default [meta.ai](https://www.meta.ai/))
2. Prefer **Chrome MV3 companion** (DOM paste into the composer)
3. Fallback: activate Chrome (Dev/Beta/stable) / Edge → new tab → AI URL
4. Adaptive wait (window title + **UI Automation** chat input)
5. Paste `prompt + editor text`
6. Restore clipboard
- Insert-only mode (paste template into the current field)
- File + debugger logging; optional `titles.log` for mining real window titles
- Templates / hotkeys in `include/config.hpp` (file config planned later)
- Bindings live in `%LOCALAPPDATA%\QiuckPrompts\qiuckprompts.ini` (seed: `config/qiuckprompts.ini`)
- Binding / update URLs must be `https://`


## Configuration file
Expand All @@ -31,7 +32,8 @@ Pure **Win32 + C++17**. No third-party libraries. Built to debug, trace, and ext
Seeded on first run from the install template (`<exe>\config\qiuckprompts.ini`).
Updates never overwrite the user file. Backups live in `%LOCALAPPDATA%\QiuckPrompts\backups\`.

**Logs:** `%LOCALAPPDATA%\QiuckPrompts\logs\` (`qiuckprompts.log`, `titles.log`; rotated ~5 MiB × 4 files).
**Logs:** `%LOCALAPPDATA%\QiuckPrompts\logs\` (`qiuckprompts.log`, `titles.log`; rotated ~5 MiB × 4 files).
Default `log_level=info` (length only). Payload / clipboard / editor previews are **debug/trace** — they can contain secrets.

Tray → **Open config** / **Open data folder**. Override path: `--config=D:\path\qiuckprompts.ini`

Expand Down Expand Up @@ -124,12 +126,13 @@ build\Debug\qiuckprompts.exe --self-test
| Flag | Meaning |
|------|---------|
| `--console` | Live logs on a console |
| `--log-level=LEVEL` | `trace` \| `debug` \| `info` \| `warn` \| `error` |
| `--log-level=LEVEL` | `trace` \| `debug` \| `info` \| `warn` \| `error` (default **info**) |
| `--log-file=PATH` | Override log path |
| `--ai-url=URL` | Default AI chat URL |
| `--ai-url=URL` | Default AI chat URL (**https:// only**) |
| `--update-url=URL` | Velopack feed directory (**https:// only**) |
| `--browser-hint=TEXT` | Prefer matching window/path (default `Chrome Dev`) |
| `--page-title-hint=TEXT` | Title must contain this (auto from URL if empty) |
| `--page-ready-timeout=MS` | Max wait for page/input (default 15000) |
| `--page-ready-timeout=MS` | Max wait for page/input (default 10000) |
| `--page-ready-min=MS` | Min wait after navigate (default 500) |
| `--no-uia` | Title-only wait (disable UI Automation) |
| `--no-extension` | Skip Chrome companion; UIA-only path |
Expand Down Expand Up @@ -227,7 +230,9 @@ Use feature branches and squash-merge PRs into `master` — details in [CONTRIBU

| Path | Role |
|------|------|
| `include/config.hpp` | Templates, hotkeys, workflow knobs |
| `config/qiuckprompts.ini` | Shipped template (hotkeys + prompts). Live copy is AppData. |
| `%LOCALAPPDATA%\QiuckPrompts\` | User config, logs, backups, NM host, stable extension |
| `include/config.hpp` | Built-in fallback bindings + workflow knobs |
| `src/workflow.cpp` | Send-to-AI pipeline (extension → UIA fallback) |
| `src/ext_bridge.cpp` | Named pipe + native-messaging host relay |
| `extension/` | MV3 companion (DOM composer paste) |
Expand All @@ -247,7 +252,7 @@ For tuning `pageTitleHint` / browser matching:
1. Run the app
2. Open AI tabs in Chrome
3. Tray → **Sample window titles now**
4. Tray → **Open titles.log** → `build\Debug\logs\titles.log`
4. Tray → **Open titles.log** → `%LOCALAPPDATA%\QiuckPrompts\logs\titles.log`

Lines are tagged `TITLE_SAMPLE` with stable `where=` fields.

Expand All @@ -256,7 +261,8 @@ Lines are tagged `TITLE_SAMPLE` with stable `where=` fields.
- User data lives under **`%LOCALAPPDATA%\QiuckPrompts`** so installers/updates cannot clobber config or logs.
- Chrome does **not** expose the chat box as a Win32 `HWND`. Prefer the **MV3 companion** (DOM); readiness otherwise uses the **UI Automation** tree plus the tab title.
- Hotkeys **arm on press** and **run on release** so Ctrl/Alt are up before Select-all/Copy/Paste.
- No Qt/WPF/Electron — message-only window + tray only.
- Hidden top-level HWND (not `HWND_MESSAGE`) so a second launch can FindWindow + take over.
- Binding `url=` and `update_url=` must be `https://`. The updater refuses HTTPS→HTTP redirects.

## License

Expand Down
4 changes: 3 additions & 1 deletion config/qiuckprompts.ini
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@

[settings]
browser_hint=Chrome Dev
log_level=debug
log_level=info
fence_editor_text=1
; Prefer MV3 companion (DOM paste). Falls back to UIA if extension not connected.
prefer_extension=1
; Stable id from extension/manifest.json "key". Change only if you load without that key.
; extension_id=aodehlngahndannepofbddnacfaldmih
; Velopack update feed (directory with releases.win.json). Empty = GitHub latest/download.
; update_url=https://github.com/summeroff/qiuckprompts/releases/latest/download
; Binding url= and update_url= must be https:// (http / javascript / file rejected).
; log_level=info (default) logs sizes only. debug/trace may preview clipboard/editor text.
; Start tray app at logon via HKCU Run when set to 1 (applied on next launch).
; Tray → Start with Windows toggles the Run key only (does not rewrite this ini).
; start_with_windows=0 (default) does not clear a Run key you enabled from the tray.
Expand Down
30 changes: 30 additions & 0 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

const HOST = 'com.qiuckprompts.host';

// Keep in sync with extension/manifest.json host_permissions.
const ALLOWED_AI_ORIGINS = new Set([
'https://www.meta.ai',
'https://meta.ai',
'https://gemini.google.com',
'https://grok.com',
'https://x.com',
'https://chatgpt.com',
'https://claude.ai',
'https://www.perplexity.ai',
'https://copilot.microsoft.com',
]);

function isAllowedAiUrl(url) {
try {
const u = new URL(String(url || ''));
return u.protocol === 'https:' && ALLOWED_AI_ORIGINS.has(u.origin);
} catch {
return false;
}
}

let port = null;
let reconnectTimer = null;

Expand Down Expand Up @@ -230,6 +252,10 @@ async function onNativeMessage(msg) {
reply(msg, { ok: false, error: 'missing url' });
return;
}
if (!isAllowedAiUrl(url)) {
reply(msg, { ok: false, error: 'url must be https on a known AI origin' });
return;
}
let wantOrigin = '';
try {
wantOrigin = new URL(url).origin;
Expand Down Expand Up @@ -292,6 +318,10 @@ async function onNativeMessage(msg) {
const url = String(msg.url || '');
const timeoutMs = Math.min(10000, Math.max(1000, Number(msg.timeoutMs) || 10000));
const cancelOnFocusSwitch = msg.cancelOnFocusSwitch !== false;
if (!isAllowedAiUrl(url)) {
reply(msg, { ok: false, error: 'url must be https on a known AI origin' });
return;
}
let wantOrigin = '';
try {
wantOrigin = new URL(url).origin;
Expand Down
2 changes: 1 addition & 1 deletion include/config.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ struct WorkflowConfig
struct AppConfig
{
std::wstring logPath;
LogLevel logLevel = LogLevel::Debug;
LogLevel logLevel = LogLevel::Info;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f5d5bc7 — after LoadConfigFile we re-apply --log-level (CLI wins) then Logger::SetLevel so AppData log_level is actually used.

bool console = false;
int pasteDelayMs = 200;
WorkflowConfig workflow;
Expand Down
6 changes: 6 additions & 0 deletions include/util.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ std::string WideToUtf8(const std::wstring& wide);
std::wstring Trim(const std::wstring& s);
std::wstring ToLower(const std::wstring& s);

// True iff url is already-trimmed https:// with a non-empty hostname.
// No DNS. Rejects http, file, javascript, outer whitespace, and empty hosts
// (e.g. https://:443/path). Callers that accept padded input must Trim first
// and store the trimmed value.
bool IsHttpsUrl(const std::wstring& url);

// Hotkey
struct HotkeySpec
{
Expand Down
7 changes: 4 additions & 3 deletions scripts/format.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ case "${1:-}" in
--check|-n|--dry-run) CHECK=1 ;;
esac

CF="${CLANG_FORMAT:-clang-format}"
if ! command -v "$CF" >/dev/null 2>&1; then
for c in clang-format-18 clang-format-17 clang-format-16 clang-format-15; do
CF="${CLANG_FORMAT:-}"
if [[ -z "$CF" ]] || ! command -v "$CF" >/dev/null 2>&1; then
CF=""
for c in clang-format-18 clang-format clang-format-17 clang-format-16 clang-format-15; do
if command -v "$c" >/dev/null 2>&1; then CF="$c"; break; fi
done
fi
Expand Down
46 changes: 45 additions & 1 deletion src/app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -512,8 +512,19 @@ int App::Run(HINSTANCE instance, int argc, wchar_t** argv)
cfg_.startWithWindows = false;
cliForceAutostartOff = true;
}
const std::wstring arg = argv[i];
const std::wstring logPref = L"--log-level=";
if (arg.rfind(logPref, 0) == 0)
{
cfg_.logLevel = Logger::ParseLevel(arg.substr(logPref.size()));
} else if (arg == L"--log-level" && i + 1 < argc && argv[i + 1])
{
cfg_.logLevel = Logger::ParseLevel(argv[++i]);
}
}
}
Logger::Instance().SetLevel(cfg_.logLevel);
QP_LOG_INFO(L"log level effective=%s", Logger::LevelName(cfg_.logLevel));

// Autostart (HKCU Run):
// - CLI --start-with-windows / --no-start-with-windows always apply
Expand Down Expand Up @@ -588,7 +599,7 @@ int App::Run(HINSTANCE instance, int argc, wchar_t** argv)
MessageBoxW(nullptr,
(L"Failed to register hotkeys:\n" + err +
L"\n\nAnother app may own these chords. "
L"Edit GetBuiltInBindings() in config.hpp and rebuild.")
L"Edit %LOCALAPPDATA%\\QiuckPrompts\\qiuckprompts.ini and restart.")
.c_str(),
QP_APP_DISPLAY_W, MB_OK | MB_ICONWARNING);
// Continue running so user can still open About / Exit from tray.
Expand Down Expand Up @@ -659,9 +670,42 @@ int App::RunSelfTest()
AppConfig ac;
expect(ac.hotkeyTrigger == HotkeyTriggerMode::OnRelease, L"default OnRelease");
expect(ac.hotkeyReleaseTimeoutMs > 0, L"release timeout default");
expect(ac.logLevel == LogLevel::Info, L"default log level Info");

expect(IsHttpsUrl(L"https://www.meta.ai/"), L"IsHttpsUrl meta");
expect(IsHttpsUrl(L"HTTPS://gemini.google.com/app"), L"IsHttpsUrl case");
expect(!IsHttpsUrl(L"http://www.meta.ai/"), L"IsHttpsUrl rejects http");
expect(!IsHttpsUrl(L"javascript:alert(1)"), L"IsHttpsUrl rejects javascript");
expect(!IsHttpsUrl(L"file:///c:/x"), L"IsHttpsUrl rejects file");
expect(!IsHttpsUrl(L"https://"), L"IsHttpsUrl rejects empty host");
expect(!IsHttpsUrl(L"https://:443/path"), L"IsHttpsUrl rejects empty host :443");
expect(!IsHttpsUrl(L" https://www.meta.ai/"), L"IsHttpsUrl rejects leading space");
expect(!IsHttpsUrl(L"https://www.meta.ai/ "), L"IsHttpsUrl rejects trailing space");
expect(IsHttpsUrl(L"https://[::1]/"), L"IsHttpsUrl ipv6");
expect(!IsHttpsUrl(L""), L"IsHttpsUrl empty");

{
AppConfig cli;
std::wstring e;
wchar_t* fakeHttp[] = {const_cast<wchar_t*>(L"qiuckprompts.exe"),
const_cast<wchar_t*>(L"--ai-url=http://evil.example/")};
expect(!ParseCommandLine(2, fakeHttp, cli, &e), L"ParseCommandLine rejects http --ai-url");
e.clear();
wchar_t* fakeOk[] = {const_cast<wchar_t*>(L"qiuckprompts.exe"),
const_cast<wchar_t*>(L"--ai-url=https://gemini.google.com/app")};
expect(ParseCommandLine(2, fakeOk, cli, &e) && IsHttpsUrl(cli.workflow.defaultAiUrl),
L"ParseCommandLine accepts https --ai-url");
e.clear();
wchar_t* fakePad[] = {const_cast<wchar_t*>(L"qiuckprompts.exe"),
const_cast<wchar_t*>(L"--ai-url= https://gemini.google.com/app")};
expect(ParseCommandLine(2, fakePad, cli, &e) &&
cli.workflow.defaultAiUrl == L"https://gemini.google.com/app",
L"ParseCommandLine trims padded --ai-url");
}

WorkflowConfig wc;
expect(!wc.defaultAiUrl.empty(), L"default AI URL set");
expect(IsHttpsUrl(wc.defaultAiUrl), L"default AI URL is https");
expect(!wc.browserTitleHint.empty(), L"browser hint set");
expect(wc.pageReadyTimeoutMs > 0, L"pageReadyTimeoutMs > 0");
expect(wc.pageReadyTimeoutMs <= 10000, L"default pageReadyTimeoutMs <= 10s");
Expand Down
41 changes: 36 additions & 5 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,13 @@ bool LoadConfigFile(const std::wstring& pathOrEmpty, AppConfig& cfg, std::wstrin
if (!get(L"fence_editor_text").empty())
cfg.workflow.fenceEditorText = get(L"fence_editor_text") != L"0";
if (!get(L"default_ai_url").empty())
cfg.workflow.defaultAiUrl = get(L"default_ai_url");
{
const std::wstring u = Trim(get(L"default_ai_url"));
if (IsHttpsUrl(u))
cfg.workflow.defaultAiUrl = u;
else
QP_LOG_WARN(L"config: default_ai_url is not https — ignored");
}
if (!get(L"prefer_extension").empty())
cfg.workflow.preferExtension = get(L"prefer_extension") != L"0";
if (!get(L"paste_even_if_not_ready").empty())
Expand All @@ -471,7 +477,13 @@ bool LoadConfigFile(const std::wstring& pathOrEmpty, AppConfig& cfg, std::wstrin
if (!get(L"extension_id").empty())
cfg.extensionId = Trim(get(L"extension_id"));
if (!get(L"update_url").empty())
cfg.updateUrl = Trim(get(L"update_url"));
{
const std::wstring u = Trim(get(L"update_url"));
if (IsHttpsUrl(u))
cfg.updateUrl = u;
else
QP_LOG_WARN(L"config: update_url is not https — ignored");
}
if (!get(L"start_with_windows").empty())
cfg.startWithWindows = get(L"start_with_windows") != L"0";
}
Expand All @@ -493,7 +505,7 @@ bool LoadConfigFile(const std::wstring& pathOrEmpty, AppConfig& cfg, std::wstrin
b.templateId = name;
b.label = get(L"label").empty() ? name : get(L"label");
b.service = get(L"service");
b.aiUrl = get(L"url");
b.aiUrl = Trim(get(L"url"));
b.pageTitleHint = get(L"title_hint");
b.action = ActionKind::SendToAi;

Expand Down Expand Up @@ -574,6 +586,11 @@ bool LoadConfigFile(const std::wstring& pathOrEmpty, AppConfig& cfg, std::wstrin

if (b.aiUrl.empty())
b.aiUrl = cfg.workflow.defaultAiUrl;
if (!IsHttpsUrl(b.aiUrl))
{
QP_LOG_WARN(L"config: [%s] url is not https — skip", name.c_str());
continue;
}

QP_LOG_INFO(
L"config: binding [%s] %s service=%s url=%s image=%d capture=%d prompt=%zu wchar",
Expand Down Expand Up @@ -675,7 +692,14 @@ bool ParseCommandLine(int argc, wchar_t** argv, AppConfig& cfg, std::wstring* er
}
if (TakeEqValue(i, argc, argv, arg, L"--ai-url", v))
{
cfg.workflow.defaultAiUrl = v;
const std::wstring u = Trim(v);
if (!IsHttpsUrl(u))
{
if (error)
*error = L"--ai-url must be https://...";
return false;
}
cfg.workflow.defaultAiUrl = u;
continue;
}
if (TakeEqValue(i, argc, argv, arg, L"--browser-hint", v))
Expand Down Expand Up @@ -711,7 +735,14 @@ bool ParseCommandLine(int argc, wchar_t** argv, AppConfig& cfg, std::wstring* er
}
if (TakeEqValue(i, argc, argv, arg, L"--update-url", v))
{
cfg.updateUrl = v;
const std::wstring u = Trim(v);
if (!IsHttpsUrl(u))
{
if (error)
*error = L"--update-url must be https://...";
return false;
}
cfg.updateUrl = u;
continue;
}
if (arg == L"--start-with-windows")
Expand Down
Loading
Loading