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
9 changes: 9 additions & 0 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ Assert-Contract ($appSource -match
$appSource -match 'if \(!envFlag\("GRAPHCODE_UIA_UPDATE_AVAILABLE"\)\) self\.requestUpdateCheck\(false\)' -and
$appSource -match 'shouldPresentOffer\(self\.update_user_initiated\)') `
"explicit and background update checks must preserve their presentation intent"
Assert-Contract ($nativeFormsSource -match 'if \(active_state\) return error\.FormAlreadyOpen;' -and
$nativeFormsSource -match 'pub fn isModalActive\(\) bool' -and
$appSource -match 'UpdateOfferPresentation\.decide\(completed_offer, self\.update_offer_pending, NativeForms\.isModalActive\(\)\)') `
"native forms must reject reentrancy and completed update offers must wait for the active modal"
Assert-Contract ($appSource -match
'const uia_gate_hook = envFlag\("GRAPHCODE_UIA_GATE"\);' -and
$appSource -match 'if \(!daemon_supervisor_test_hook and !uia_gate_hook\) GdiplusAA\.init\(\);') `
Expand Down Expand Up @@ -191,6 +195,7 @@ foreach ($path in @(
"src\InputRouter.zig",
"src\Forms.zig",
"src\NativeForms.zig",
"src\UpdateOfferPresentation.zig",
"src\WindowsOnboarding.zig",
"src\WindowsProductSettings.zig",
"src\Accessibility.zig",
Expand Down Expand Up @@ -370,6 +375,10 @@ Invoke-Native "Native dialog message-loop executable tests" {
& $zig test src\NativeForms.zig -target x86_64-windows-msvc -lc -luser32 "-I$include"
} finally { Pop-Location }
}
Invoke-Native "Update offer modal deferral executable tests" {
Push-Location $shellRoot
try { & $zig test src\UpdateOfferPresentation.zig } finally { Pop-Location }
}
Invoke-Native "Jump palette executable tests" {
$depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent
$winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT")
Expand Down
45 changes: 28 additions & 17 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const Codespaces = @import("Codespaces.zig");
const Onboarding = @import("WindowsOnboarding.zig");
const WindowsUpdates = @import("WindowsUpdates.zig");
const UpdateOfferDialog = @import("UpdateOfferDialog.zig");
const UpdateOfferPresentation = @import("UpdateOfferPresentation.zig");
const WorktreeDialog = @import("WorktreeDialog.zig");
const Accessibility = @import("Accessibility.zig");
const Navigation = @import("Navigation.zig");
Expand Down Expand Up @@ -269,6 +270,7 @@ pub const App = struct {
update_cancel: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
update_generation: u64 = 0,
update_pending: bool = false,
update_offer_pending: bool = false,
update_user_initiated: bool = false,
update_version: []u8 = &.{},
update_release_url: []u8 = &.{},
Expand Down Expand Up @@ -1524,6 +1526,7 @@ pub const App = struct {
self.update_generation += 1;
self.update_user_initiated = user_initiated;
self.update_pending = true;
self.update_offer_pending = false;
if (self.update_thread != null) {
self.update_cancel.store(true, .release);
self.update_lock.unlock();
Expand Down Expand Up @@ -1590,25 +1593,33 @@ pub const App = struct {
self.update_lock.lock();
const done = self.update_done;
self.update_lock.unlock();
if (!done) return;
if (self.update_thread) |thread| {
thread.join();
self.update_thread = null;
self.update_lock.lock();
const pending = self.update_pending;
self.update_pending = false;
const label = self.update_state.label();
const present_offer = self.update_state.shouldPresentOffer(self.update_user_initiated);
const version = self.update_version;
const release_url = self.update_release_url;
self.update_lock.unlock();
if (pending) {
self.launchUpdateCheck();
} else {
self.setStatus(label);
if (present_offer) self.showAvailableUpdate(version, release_url);
var completed_offer = false;
if (done) {
if (self.update_thread) |thread| {
thread.join();
self.update_thread = null;
self.update_lock.lock();
const pending = self.update_pending;
self.update_pending = false;
const label = self.update_state.label();
const present_offer = self.update_state.shouldPresentOffer(self.update_user_initiated);
self.update_lock.unlock();
if (pending) {
self.launchUpdateCheck();
} else {
self.setStatus(label);
completed_offer = present_offer;
}
}
}
switch (UpdateOfferPresentation.decide(completed_offer, self.update_offer_pending, NativeForms.isModalActive())) {
.none => {},
.defer_until_modal_closes => self.update_offer_pending = true,
.present => {
self.update_offer_pending = false;
self.showAvailableUpdate(self.update_version, self.update_release_url);
},
}
}

fn showAvailableUpdate(self: *App, version: []const u8, release_url: []const u8) void {
Expand Down
33 changes: 30 additions & 3 deletions graphcode-windows/src/NativeForms.zig
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,19 @@ var active_state_storage: DialogState = undefined;

const ModalCommand = enum { submit, cancel, close, destroy };

pub fn isModalActive() bool {
return active_state;
}

fn acquireModal() !void {
if (active_state) return error.FormAlreadyOpen;
active_state = true;
}

fn releaseModal() void {
active_state = false;
}

/// Loop-type teaching-tile accents, converted from the exact RGB values macOS
/// uses for the same four types (LoopTypeAppearance.swift's `accent`), so the
/// Windows tiles read as the same visual language rather than a new palette.
Expand Down Expand Up @@ -673,13 +686,14 @@ pub fn worktreeSweep(

fn show(state: *DialogState, title: []const u8, labels: []const []const u8) !bool {
_ = labels;
try acquireModal();
defer releaseModal();
registerClass() catch return error.FormClassRegistrationFailed;
const wide_title = try utf8ToWideZ(state.allocator, title);
defer state.allocator.free(wide_title);
active_state_storage = state.*;
active_state_storage.closed = false;
active_state_storage.result = false;
active_state = true;
const screen_height = c.GetSystemMetrics(c.SM_CYSCREEN);
const dialog_height: i32 = if (state.kind == .worktree_policy) 430 else @max(320, @min(700, screen_height - 96));
const hwnd = c.CreateWindowExW(
Expand All @@ -696,7 +710,6 @@ fn show(state: *DialogState, title: []const u8, labels: []const []const u8) !boo
c.GetModuleHandleW(null),
@ptrCast(state),
) orelse {
active_state = false;
return error.FormCreationFailed;
};
_ = c.EnableWindow(state.parent, 0);
Expand All @@ -722,7 +735,6 @@ fn show(state: *DialogState, title: []const u8, labels: []const []const u8) !boo
_ = c.EnableWindow(state.parent, 1);
_ = c.SetActiveWindow(state.parent);
state.* = active_state_storage;
active_state = false;
if (quit_code) |value| c.PostQuitMessage(@intCast(value));
return state.result;
}
Expand Down Expand Up @@ -2104,6 +2116,21 @@ test "modal submit and cancel transitions always terminate the loop" {
try std.testing.expect(state.result);
}

test "native forms reject reentrant modal acquisition and allow sequential dialogs" {
active_state = false;
defer active_state = false;

try acquireModal();
try std.testing.expect(isModalActive());
try std.testing.expectError(error.FormAlreadyOpen, acquireModal());

releaseModal();
try std.testing.expect(!isModalActive());
try acquireModal();
try std.testing.expect(isModalActive());
releaseModal();
}

test "jump modal result uses production query validation" {
var state = DialogState{ .allocator = undefined, .kind = .jump, .parent = null };
state.values[0] = @constCast(" \t\r\n");
Expand Down
20 changes: 20 additions & 0 deletions graphcode-windows/src/UpdateOfferPresentation.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const std = @import("std");

pub const Decision = enum {
none,
defer_until_modal_closes,
present,
};

pub fn decide(completed_offer: bool, deferred_offer: bool, modal_active: bool) Decision {
if (!completed_offer and !deferred_offer) return .none;
if (modal_active) return .defer_until_modal_closes;
return .present;
}

test "update offer remains deferred until the native modal closes" {
try std.testing.expectEqual(Decision.none, decide(false, false, false));
try std.testing.expectEqual(Decision.defer_until_modal_closes, decide(true, false, true));
try std.testing.expectEqual(Decision.defer_until_modal_closes, decide(false, true, true));
try std.testing.expectEqual(Decision.present, decide(false, true, false));
}
Loading