4 Commits
Author SHA1 Message Date
weishauptandClaude Opus 5.5 0f4d11e4c4 v1.5: Snap to the desktop grid, whole lines when resizing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:00:26 -04:00
weishauptandClaude Opus 5.5 14763a002a v1.4: Block Claude from writing to the Desktop and AppData
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:24:08 -04:00
weishauptandClaude Opus 5.5 aecdc732b9 v1.3: Open websites, folders and programs without a confirm box
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:16:25 -04:00
weishauptandClaude Opus 5.5 8c09e56982 v1.2: Font size menu (10 to 18)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:51:09 -04:00
8 changed files with 437 additions and 17 deletions
+15 -1
View File
@@ -39,7 +39,13 @@ namespace QuickClaude
"or other personal information from your context into web searches or URLs you fetch, " + "or other personal information from your context into web searches or URLs you fetch, " +
"unless the user specifically asks you to search for them. " + "unless the user specifically asks you to search for them. " +
"Shell commands (Bash or PowerShell) are allowed for other things, but each one first shows the user a confirm box; " + "Shell commands (Bash or PowerShell) are allowed for other things, but each one first shows the user a confirm box; " +
"if they decline, carry on without it."; "if they decline, carry on without it. " +
"When the user asks you to open something, run exactly one PowerShell Start-Process command with one quoted target " +
"and nothing else on the line (no arguments, no second command); those open without a confirm box. " +
"A website: Start-Process \"https://x.com\" (always the full https:// URL). " +
"A folder: Start-Process \"C:\\wallpapers\" (the full path). " +
"A program: Start-Process mspaint (just its name). If QuickClaude answers that the name isn't on the PATH and gives " +
"a Start Menu shortcut, run Start-Process with that shortcut's full path, exactly as given.";
// Run without prompts. Bash and PowerShell stay out so they go through the confirm box hook. // Run without prompts. Bash and PowerShell stay out so they go through the confirm box hook.
// Write and Edit only when the user picked "Edit files" or "Auto"; "Only look" removes them outright. // Write and Edit only when the user picked "Edit files" or "Auto"; "Only look" removes them outright.
@@ -214,6 +220,8 @@ namespace QuickClaude
Finished?.Invoke(error); Finished?.Invoke(error);
} }
// File writes also go through a guard hook ("QuickClaude.exe --guard-write") that blocks the
// Desktop and AppData, where OpenRules lets things open without asking.
// Shell commands go through QuickClaude's own confirm box: a PreToolUse hook that runs // Shell commands go through QuickClaude's own confirm box: a PreToolUse hook that runs
// "QuickClaude.exe --confirm" (exec form, so no shell quoting). The dialog denies after // "QuickClaude.exe --confirm" (exec form, so no shell quoting). The dialog denies after
// 2 minutes; the hook timeout is a bit longer so Claude Code never cuts it off first. // 2 minutes; the hook timeout is a bit longer so Claude Code never cuts it off first.
@@ -231,6 +239,12 @@ namespace QuickClaude
{ {
matcher = "Bash|PowerShell|Monitor", // Monitor also runs shell commands matcher = "Bash|PowerShell|Monitor", // Monitor also runs shell commands
hooks = new[] { new { type = "command", command = exe, args = new[] { "--confirm" }, timeout = 150 } } hooks = new[] { new { type = "command", command = exe, args = new[] { "--confirm" }, timeout = 150 } }
},
new
{
// Never lets Claude write to the Desktop or AppData (see WriteGuard)
matcher = "Write|Edit|MultiEdit|NotebookEdit",
hooks = new[] { new { type = "command", command = exe, args = new[] { "--guard-write" }, timeout = 30 } }
} }
} }
} }
+24 -7
View File
@@ -31,14 +31,31 @@ namespace QuickClaude
// Nothing to show: say nothing, so Claude Code's normal permission rules decide // Nothing to show: say nothing, so Claude Code's normal permission rules decide
if (string.IsNullOrEmpty(command)) return 0; if (string.IsNullOrEmpty(command)) return 0;
new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; string decision, reason;
var win = new ConfirmWindow(toolName, command, cwd); var verdict = OpenRules.Check(command, cwd, out var hint);
win.ShowDialog(); if (verdict == OpenRules.Verdict.Allow)
{
// "open x.com", "open C:\wallpapers", "open mspaint": no confirm box (see OpenRules)
decision = "allow";
reason = "Opening a website, folder or installed program is allowed without asking.";
}
else if (verdict == OpenRules.Verdict.Hint)
{
// The bare name wouldn't resolve, but there's a Start Menu shortcut for it
decision = "deny";
reason = hint;
}
else
{
new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
var win = new ConfirmWindow(toolName, command, cwd);
win.ShowDialog();
string decision = win.Allowed ? "allow" : "deny"; decision = win.Allowed ? "allow" : "deny";
string reason = win.Allowed ? "The user allowed this command." reason = win.Allowed ? "The user allowed this command."
: win.TimedOut ? "The user didn't answer within 2 minutes, so the command was not run. Carry on without it." : win.TimedOut ? "The user didn't answer within 2 minutes, so the command was not run. Carry on without it."
: "The user declined to run this command. Carry on without it."; : "The user declined to run this command. Carry on without it.";
}
var json = JsonSerializer.Serialize(new var json = JsonSerializer.Serialize(new
{ {
+165 -8
View File
@@ -20,8 +20,12 @@ namespace QuickClaude
readonly TextBox input = new TextBox(); readonly TextBox input = new TextBox();
readonly TextBlock status = new TextBlock(); readonly TextBlock status = new TextBlock();
readonly TextBlock grip = new TextBlock(); readonly TextBlock grip = new TextBlock();
// Holds the transcript. The transcript inside is always sized to a whole number of lines
// and sits at the bottom, so no line is ever shown cut in half (see FitTranscript).
readonly Border transcriptHost = new Border { ClipToBounds = true };
double lineHeight = 13;
WF.NotifyIcon tray; WF.NotifyIcon tray;
WF.ToolStripMenuItem fontMenu, topItem, blackItem, whiteItem, lookItem, editItem, autoItem, folderItem; WF.ToolStripMenuItem fontMenu, sizeMenu, snapItem, topItem, blackItem, whiteItem, lookItem, editItem, autoItem, folderItem;
bool lastWasTool; bool lastWasTool;
bool quitting; bool quitting;
@@ -30,14 +34,17 @@ namespace QuickClaude
// The current font (picked from the tray's Font menu). Still called Mono since // The current font (picked from the tray's Font menu). Still called Mono since
// ConfirmWindow and DarkMenu read it when they open. // ConfirmWindow and DarkMenu read it when they open.
internal static FontFamily Mono = new FontFamily("Consolas, Courier New"); internal static FontFamily Mono = new FontFamily("Consolas, Courier New");
// Matches wIRC at its smallest font slider setting (10sp = 10 WPF units) // Picked from the tray's Font size menu, 10 to 18 (same units as wIRC's font size
internal const double TextSize = 10; // slider: 10sp = 10 WPF units). ConfirmWindow and DarkMenu read it when they open.
internal const double MinTextSize = 10, MaxTextSize = 18;
internal static double TextSize = 10;
const double ResizeEdge = 6; const double ResizeEdge = 6;
public MainWindow() public MainWindow()
{ {
settings = Settings.Load(); settings = Settings.Load();
Mono = Fonts.Find(settings.Font).Family; Mono = Fonts.Find(settings.Font).Family;
TextSize = Math.Clamp(Math.Round(settings.FontSize), MinTextSize, MaxTextSize);
Title = "QuickClaude"; Title = "QuickClaude";
WindowStyle = WindowStyle.None; WindowStyle = WindowStyle.None;
@@ -69,7 +76,7 @@ namespace QuickClaude
if (transcript.Text.Length == 0) if (transcript.Text.Length == 0)
transcript.Text = "QuickClaude. Type below and press Enter. Shift+Enter for a new line, Esc stops Claude, Ctrl+Alt+C hides or shows this note.\n"; transcript.Text = "QuickClaude. Type below and press Enter. Shift+Enter for a new line, Esc stops Claude, Ctrl+Alt+C hides or shows this note.\n";
Loaded += (s, e) => { transcript.ScrollToEnd(); input.Focus(); }; Loaded += (s, e) => { MeasureLineHeight(); transcript.ScrollToEnd(); input.Focus(); };
PreviewMouseLeftButtonUp += ClickToInput; PreviewMouseLeftButtonUp += ClickToInput;
SourceInitialized += (s, e) => RegisterHotkey(); SourceInitialized += (s, e) => RegisterHotkey();
LocationChanged += (s, e) => SavePlace(); LocationChanged += (s, e) => SavePlace();
@@ -116,8 +123,12 @@ namespace QuickClaude
transcript.TextWrapping = TextWrapping.Wrap; transcript.TextWrapping = TextWrapping.Wrap;
transcript.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden; transcript.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden;
transcript.IsUndoEnabled = false; transcript.IsUndoEnabled = false;
Grid.SetRow(transcript, 1); transcript.VerticalAlignment = VerticalAlignment.Bottom;
rootGrid.Children.Add(transcript); transcriptHost.Background = HitFill;
transcriptHost.Child = transcript;
transcriptHost.SizeChanged += (s, e) => FitTranscript();
Grid.SetRow(transcriptHost, 1);
rootGrid.Children.Add(transcriptHost);
// Input line // Input line
var bottom = new Grid { Margin = new Thickness(0, 6, 0, 0) }; var bottom = new Grid { Margin = new Thickness(0, 6, 0, 0) };
@@ -147,6 +158,54 @@ namespace QuickClaude
((Grid)Content).ContextMenu = DarkMenu.Make(null, NewConversation); ((Grid)Content).ContextMenu = DarkMenu.Make(null, NewConversation);
} }
// Height of one line of transcript text at the current font and size. Measured as the
// difference between two lines and one, so it's exactly what the text box steps by.
void MeasureLineHeight()
{
var probe = new TextBlock { FontFamily = Mono, FontSize = TextSize, Text = "Mg" };
probe.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
double one = probe.DesiredSize.Height;
probe.Text = "Mg\nMg";
probe.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
double two = probe.DesiredSize.Height;
lineHeight = two - one > 1 ? two - one : Math.Max(1, one);
FitTranscript();
}
// Make the transcript exactly as tall as the whole lines that fit, bottom-aligned, and keep
// it scrolled to the end so the top line is whole too. Runs on every size change (window
// resize, the input box growing, font changes).
void FitTranscript()
{
double avail = transcriptHost.ActualHeight;
if (avail <= 0 || lineHeight <= 0) return;
int lines = Math.Max(1, (int)Math.Floor((avail + 0.01) / lineHeight));
transcript.Height = lines * lineHeight;
transcript.ScrollToEnd();
}
// Leftover space under the last whole line, in WPF units
double TranscriptGap() => Math.Max(0, transcriptHost.ActualHeight - (double.IsNaN(transcript.Height) ? 0 : transcript.Height));
void SetFontSize(double size)
{
size = Math.Clamp(size, MinTextSize, MaxTextSize);
settings.FontSize = size;
settings.Save();
TextSize = size;
FontSize = size;
transcript.FontSize = size;
input.FontSize = size;
status.FontSize = size;
grip.FontSize = size;
MeasureLineHeight();
BuildContextMenus();
transcript.ScrollToEnd();
if (sizeMenu != null)
foreach (WF.ToolStripMenuItem item in sizeMenu.DropDownItems)
item.Checked = item.Text == size.ToString();
}
void SetFont(string name) void SetFont(string name)
{ {
var choice = Fonts.Find(name); var choice = Fonts.Find(name);
@@ -158,6 +217,7 @@ namespace QuickClaude
input.FontFamily = Mono; input.FontFamily = Mono;
status.FontFamily = Mono; status.FontFamily = Mono;
grip.FontFamily = Mono; grip.FontFamily = Mono;
MeasureLineHeight();
BuildContextMenus(); BuildContextMenus();
if (fontMenu != null) if (fontMenu != null)
foreach (WF.ToolStripMenuItem item in fontMenu.DropDownItems) foreach (WF.ToolStripMenuItem item in fontMenu.DropDownItems)
@@ -388,6 +448,20 @@ namespace QuickClaude
} }
menu.Items.Add(fontMenu); menu.Items.Add(fontMenu);
sizeMenu = new WF.ToolStripMenuItem("Font size");
for (int px = (int)MinTextSize; px <= (int)MaxTextSize; px++)
{
double sz = px;
sizeMenu.DropDownItems.Add(new WF.ToolStripMenuItem(px.ToString(), null, (s, e) => SetFontSize(sz)) { Checked = sz == TextSize });
}
menu.Items.Add(sizeMenu);
snapItem = new WF.ToolStripMenuItem("Snap to desktop grid", null, (s, e) =>
{
settings.SnapToGrid = !settings.SnapToGrid; snapItem.Checked = settings.SnapToGrid; settings.Save();
}) { Checked = settings.SnapToGrid };
menu.Items.Add(snapItem);
topItem = new WF.ToolStripMenuItem("Always on top", null, (s, e) => topItem = new WF.ToolStripMenuItem("Always on top", null, (s, e) =>
{ {
settings.Topmost = !settings.Topmost; Topmost = settings.Topmost; topItem.Checked = settings.Topmost; settings.Save(); settings.Topmost = !settings.Topmost; Topmost = settings.Topmost; topItem.Checked = settings.Topmost; settings.Save();
@@ -452,7 +526,8 @@ namespace QuickClaude
var hit = EdgeHit(hwnd, l); var hit = EdgeHit(hwnd, l);
if (hit != 0) { handled = true; return new IntPtr(hit); } if (hit != 0) { handled = true; return new IntPtr(hit); }
} }
else if (msg == WM_EXITSIZEMOVE) SavePlace(); else if (msg == WM_ENTERSIZEMOVE) GetWindowRect(hwnd, out moveStart);
else if (msg == WM_EXITSIZEMOVE) { AfterMoveOrResize(hwnd); SavePlace(); }
return IntPtr.Zero; return IntPtr.Zero;
}); });
} }
@@ -460,7 +535,89 @@ namespace QuickClaude
// Resize from any edge or corner: report a ~6px border strip as the matching hit code // Resize from any edge or corner: report a ~6px border strip as the matching hit code
[DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[StructLayout(LayoutKind.Sequential)] struct RECT { public int Left, Top, Right, Bottom; } [StructLayout(LayoutKind.Sequential)] struct RECT { public int Left, Top, Right, Bottom; }
const int WM_NCHITTEST = 0x0084, WM_EXITSIZEMOVE = 0x0232; const int WM_NCHITTEST = 0x0084, WM_ENTERSIZEMOVE = 0x0231, WM_EXITSIZEMOVE = 0x0232;
RECT moveStart;
[DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("user32.dll")] static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint flags);
[DllImport("user32.dll", CharSet = CharSet.Auto)] static extern bool GetMonitorInfo(IntPtr mon, ref MONITORINFO info);
[DllImport("user32.dll")] static extern bool SystemParametersInfo(uint action, uint param, out int value, uint ini);
[StructLayout(LayoutKind.Sequential)] struct MONITORINFO { public int cbSize; public RECT rcMonitor, rcWork; public uint dwFlags; }
const uint SWP_NOSIZE = 0x1, SWP_NOMOVE = 0x2, SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10;
const uint SPI_ICONHORIZONTALSPACING = 0x000D, SPI_ICONVERTICALSPACING = 0x0018;
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr FindWindow(string cls, string name);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string cls, string name);
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, int msg, IntPtr w, IntPtr l);
const int LVM_GETITEMSPACING = 0x1033;
// The desktop icon grid, asked of the desktop's own icon view so it follows the icon size
// and spacing Windows is really using (wIRC asks the same way, so both line up), falling
// back to the system icon spacing. In physical pixels.
static bool DesktopGridSpacing(out int sx, out int sy)
{
sx = sy = 0;
try
{
var view = FindWindowEx(FindWindow("Progman", null), IntPtr.Zero, "SHELLDLL_DefView", null);
var worker = IntPtr.Zero;
while (view == IntPtr.Zero)
{
worker = FindWindowEx(IntPtr.Zero, worker, "WorkerW", null);
if (worker == IntPtr.Zero) break;
view = FindWindowEx(worker, IntPtr.Zero, "SHELLDLL_DefView", null);
}
var list = view == IntPtr.Zero ? IntPtr.Zero : FindWindowEx(view, IntPtr.Zero, "SysListView32", null);
if (list != IntPtr.Zero)
{
long r = SendMessage(list, LVM_GETITEMSPACING, IntPtr.Zero, IntPtr.Zero).ToInt64();
sx = (int)(r & 0xFFFF); sy = (int)((r >> 16) & 0xFFFF);
if (sx > 8 && sy > 8) return true;
}
}
catch { }
return SystemParametersInfo(SPI_ICONHORIZONTALSPACING, 0, out sx, 0) && sx > 8 &&
SystemParametersInfo(SPI_ICONVERTICALSPACING, 0, out sy, 0) && sy > 8;
}
// After a drag or resize ends: a resize trims the window to whole transcript lines, and a
// plain move snaps the top-left corner to the desktop icon grid (same spacing Windows uses
// for desktop icons, counted from the top-left of the screen's work area).
void AfterMoveOrResize(IntPtr hwnd)
{
if (!GetWindowRect(hwnd, out var r)) return;
bool resized = (r.Right - r.Left) != (moveStart.Right - moveStart.Left) ||
(r.Bottom - r.Top) != (moveStart.Bottom - moveStart.Top);
double scale = PresentationSource.FromVisual(this)?.CompositionTarget?.TransformToDevice.M22 ?? 1;
if (resized)
{
UpdateLayout();
double gap = TranscriptGap();
int gapPx = (int)Math.Floor(gap * scale);
int h = r.Bottom - r.Top;
int minPx = (int)Math.Ceiling(MinHeight * scale);
if (gapPx > 0 && h - gapPx >= minPx)
{
// Keep whichever edge didn't move: resizing from the top keeps the bottom fixed
bool fromTop = r.Top != moveStart.Top && r.Bottom == moveStart.Bottom;
int top = fromTop ? r.Top + gapPx : r.Top;
SetWindowPos(hwnd, IntPtr.Zero, r.Left, top, r.Right - r.Left, h - gapPx, SWP_NOZORDER | SWP_NOACTIVATE);
}
return;
}
if (!settings.SnapToGrid) return;
if (r.Left == moveStart.Left && r.Top == moveStart.Top) return;
if (!DesktopGridSpacing(out int sx, out int sy)) return;
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
if (!GetMonitorInfo(MonitorFromWindow(hwnd, 2 /* nearest */), ref info)) return;
var w = info.rcWork;
int x = w.Left + (int)Math.Round((r.Left - w.Left) / (double)sx) * sx;
int y = w.Top + (int)Math.Round((r.Top - w.Top) / (double)sy) * sy;
if (x != r.Left || y != r.Top)
SetWindowPos(hwnd, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17; const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17;
int EdgeHit(IntPtr hwnd, IntPtr l) int EdgeHit(IntPtr hwnd, IntPtr l)
+147
View File
@@ -0,0 +1,147 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Win32;
namespace QuickClaude
{
// Decides which "open ..." shell commands skip the confirm box. Only a command that is a
// single open of ONE target, with no arguments and nothing chained on, qualifies:
// Start-Process "https://x.com" a website (http/https only)
// Start-Process "C:\wallpapers" a folder that exists (no network paths)
// Start-Process mspaint a program: an .exe in Program Files, Windows,
// Start-Process "C:\...\mirc.exe" AppData\Local\Programs or the Store app aliases,
// Start-Process "...\mIRC.lnk" or a shortcut in the Start Menu or on the Desktop
// Scripts (.bat, .ps1 and so on) and programs anywhere else still get the confirm box.
public static class OpenRules
{
public enum Verdict { Ask, Allow, Hint }
// verb, optional "" title (cmd start), optional -FilePath/-Path, then ONE target.
// Quoted targets may contain spaces and ( ) for "Program Files (x86)", but never quotes,
// ` $ % or line breaks (PowerShell and cmd expand those). Unquoted targets are stricter.
static readonly Regex OpenCmd = new Regex(
@"^\s*(?:cmd(?:\.exe)?\s+/c\s+)?(?:Start-Process|saps|start|explorer(?:\.exe)?|Invoke-Item|ii)\s+" +
@"(?:""""\s+)?(?:-(?:FilePath|Path|LiteralPath)\s+)?" +
@"(?:""(?<t>[^""'`$%\r\n;&|<>^]+)""|'(?<t>[^""'`$%\r\n;&|<>^]+)'|(?<t>[^\s""'`$%;&|<>()^]+))\s*$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
static readonly Regex Url = new Regex(@"^https?://[^\s""'`;&|<>$()^%]+$", RegexOptions.IgnoreCase);
static readonly Regex BareName = new Regex(@"^[A-Za-z0-9_.\-]+$");
public static Verdict Check(string command, string cwd, out string hint)
{
hint = null;
if (string.IsNullOrEmpty(command) || command.IndexOfAny(new[] { '\n', '\r' }) >= 0) return Verdict.Ask;
var m = OpenCmd.Match(command);
if (!m.Success) return Verdict.Ask;
var target = m.Groups["t"].Value.Trim();
if (target.Length == 0) return Verdict.Ask;
if (Url.IsMatch(target)) return Verdict.Allow;
try
{
if (Path.IsPathFullyQualified(target))
{
if (target.StartsWith(@"\\")) return Verdict.Ask; // no network paths
var full = Path.GetFullPath(target);
if (Directory.Exists(full)) return Verdict.Allow;
if (File.Exists(full) && TrustedProgram(full)) return Verdict.Allow;
return Verdict.Ask;
}
if (BareName.IsMatch(target))
{
// A bare name could also pick up a file sitting in the work folder; never allow that
if (!string.IsNullOrEmpty(cwd) && Directory.Exists(cwd) &&
(File.Exists(Path.Combine(cwd, target)) || Directory.EnumerateFiles(cwd, target + ".*").Any())) return Verdict.Ask;
if (FindProgram(target) != null) return Verdict.Allow;
var lnk = FindShortcut(target);
if (lnk != null)
{
hint = "\"" + target + "\" isn't on the PATH, so that command wouldn't find it. " +
"Its Start Menu shortcut is " + lnk + ". Run Start-Process \"" + lnk + "\" instead " +
"(that one opens without a confirm box).";
return Verdict.Hint;
}
}
}
catch { }
return Verdict.Ask;
}
static string Env(string name) => Environment.GetEnvironmentVariable(name) ?? "";
static string Special(Environment.SpecialFolder f) => Environment.GetFolderPath(f);
static string[] ProgramDirs => new[]
{
Env("ProgramFiles"), Env("ProgramFiles(x86)"), Env("ProgramW6432"),
Env("WINDIR"),
Path.Combine(Special(Environment.SpecialFolder.LocalApplicationData), "Programs"),
Path.Combine(Special(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "WindowsApps"),
}.Where(d => d.Length > 0).ToArray();
static string[] ShortcutDirs => new[]
{
Path.Combine(Special(Environment.SpecialFolder.CommonApplicationData), "Microsoft", "Windows", "Start Menu"),
Path.Combine(Special(Environment.SpecialFolder.ApplicationData), "Microsoft", "Windows", "Start Menu"),
Special(Environment.SpecialFolder.DesktopDirectory),
Special(Environment.SpecialFolder.CommonDesktopDirectory),
}.Where(d => d.Length > 0).ToArray();
static bool Under(string path, string dir)
{
var d = Path.GetFullPath(dir).TrimEnd('\\') + "\\";
return path.StartsWith(d, StringComparison.OrdinalIgnoreCase);
}
static bool TrustedProgram(string full)
{
var ext = Path.GetExtension(full).ToLowerInvariant();
if (ext == ".exe") return ProgramDirs.Any(d => Under(full, d));
if (ext == ".lnk") return ShortcutDirs.Any(d => Under(full, d));
return false;
}
// Where Windows itself would find a bare program name: the Windows folders, the Store app
// aliases, and the App Paths registry (how "mspaint" and friends resolve).
static string FindProgram(string name)
{
var exe = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? name : name + ".exe";
var win = Env("WINDIR");
foreach (var dir in new[]
{
Path.Combine(win, "System32"), win,
Path.Combine(Special(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "WindowsApps"),
})
{
var p = Path.Combine(dir, exe);
if (File.Exists(p)) return p;
}
foreach (var hive in new[] { Registry.CurrentUser, Registry.LocalMachine })
{
using var key = hive.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\App Paths\" + exe);
var val = (key?.GetValue(null) as string)?.Trim('"');
if (!string.IsNullOrEmpty(val) && File.Exists(val) && TrustedProgram(Path.GetFullPath(val))) return val;
}
return null;
}
// A Start Menu or Desktop shortcut named like the program ("mirc" finds "mIRC.lnk").
// Exact name first, then the shortest name that starts with it.
static string FindShortcut(string name)
{
var all = ShortcutDirs.Where(Directory.Exists)
.SelectMany(d => { try { return Directory.EnumerateFiles(d, "*.lnk", SearchOption.AllDirectories); } catch { return Enumerable.Empty<string>(); } })
.ToList();
var exact = all.FirstOrDefault(f => string.Equals(Path.GetFileNameWithoutExtension(f), name, StringComparison.OrdinalIgnoreCase));
if (exact != null) return exact;
return all.Where(f => Path.GetFileNameWithoutExtension(f).StartsWith(name, StringComparison.OrdinalIgnoreCase))
.OrderBy(f => Path.GetFileNameWithoutExtension(f).Length).FirstOrDefault();
}
}
}
+2
View File
@@ -12,6 +12,8 @@ namespace QuickClaude
{ {
// Run as Claude Code's shell-command confirm hook (separate short-lived process) // Run as Claude Code's shell-command confirm hook (separate short-lived process)
if (Array.IndexOf(args, "--confirm") >= 0) return ConfirmHook.Run(); if (Array.IndexOf(args, "--confirm") >= 0) return ConfirmHook.Run();
// Run as Claude Code's file-write guard hook (see WriteGuard)
if (Array.IndexOf(args, "--guard-write") >= 0) return WriteGuard.Run();
using var mutex = new Mutex(true, "QuickClaude.SingleInstance", out bool first); using var mutex = new Mutex(true, "QuickClaude.SingleInstance", out bool first);
if (!first) return 0; if (!first) return 0;
+1 -1
View File
@@ -10,7 +10,7 @@
<Nullable>disable</Nullable> <Nullable>disable</Nullable>
<AssemblyName>QuickClaude</AssemblyName> <AssemblyName>QuickClaude</AssemblyName>
<RootNamespace>QuickClaude</RootNamespace> <RootNamespace>QuickClaude</RootNamespace>
<Version>1.1.0</Version> <Version>1.5.0</Version>
<Product>QuickClaude</Product> <Product>QuickClaude</Product>
<ApplicationIcon>app.ico</ApplicationIcon> <ApplicationIcon>app.ico</ApplicationIcon>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
+2
View File
@@ -12,6 +12,8 @@ namespace QuickClaude
public double Height { get; set; } = 380; public double Height { get; set; } = 380;
public string TextColor { get; set; } = "Black"; public string TextColor { get; set; } = "Black";
public string Font { get; set; } = "Consolas"; public string Font { get; set; } = "Consolas";
public double FontSize { get; set; } = 10;
public bool SnapToGrid { get; set; } = true;
public bool Topmost { get; set; } = false; public bool Topmost { get; set; } = false;
public string WorkDir { get; set; } = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); public string WorkDir { get; set; } = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
public string Mode { get; set; } = "default"; public string Mode { get; set; } = "default";
+81
View File
@@ -0,0 +1,81 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
namespace QuickClaude
{
// QuickClaude.exe --guard-write: Claude Code's PreToolUse hook for Write, Edit and NotebookEdit.
// Keeps Claude from writing anywhere OpenRules lets things open without a confirm box that a
// normal (non-admin) account can write to: your Desktop and all of AppData (which holds your
// own Start Menu, AppData\Local\Programs and the Store app aliases). Without this, Claude in
// "Edit files" or "Auto" mode could drop a shortcut or program there and then open it unasked.
// Every other folder is left to the "Claude can" setting as before.
public static class WriteGuard
{
public static int Run()
{
string path = null, cwd = null;
try
{
using var stdin = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
using var doc = JsonDocument.Parse(stdin.ReadToEnd());
var root = doc.RootElement;
cwd = Str(root, "cwd");
if (root.TryGetProperty("tool_input", out var input))
path = Str(input, "file_path") ?? Str(input, "notebook_path") ?? Str(input, "path");
}
catch { }
if (string.IsNullOrEmpty(path)) return 0; // nothing to check: normal rules decide
if (!Blocked(path, cwd)) return 0;
var json = JsonSerializer.Serialize(new
{
hookSpecificOutput = new
{
hookEventName = "PreToolUse",
permissionDecision = "deny",
permissionDecisionReason =
"QuickClaude doesn't let Claude write to the Desktop or AppData folders (programs and shortcuts there " +
"can open without a confirm box). Tell the user, and suggest a different folder if one would do."
}
});
using var stdout = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false));
stdout.Write(json);
return 0;
}
static bool Blocked(string path, string cwd)
{
try
{
var full = Path.IsPathRooted(path) || string.IsNullOrEmpty(cwd)
? Path.GetFullPath(path)
: Path.GetFullPath(Path.Combine(cwd, path));
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var dirs = new[]
{
Path.Combine(home, "AppData"),
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Microsoft", "Windows", "Start Menu"),
}.Where(d => !string.IsNullOrEmpty(d));
return dirs.Any(d =>
{
var root = Path.GetFullPath(d).TrimEnd('\\');
return full.Equals(root, StringComparison.OrdinalIgnoreCase) ||
full.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase);
});
}
catch { return true; } // a path we can't even parse doesn't get written
}
static string Str(JsonElement e, string name) =>
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}
}