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
14 changes: 9 additions & 5 deletions .github/workflows/windows-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: windows-latest
strategy:
matrix:
go-version: ['1.23', '1.24', '1.25']
go-version: ['1.24', '1.25', '1.26']
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand All @@ -34,7 +34,7 @@ jobs:
run: go tool cover -func=coverage.txt

- name: Upload coverage to Codecov
if: matrix.go-version == '1.23'
if: matrix.go-version == '1.24'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
Expand All @@ -48,11 +48,15 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version: '1.24'

- name: Run go vet
shell: bash
run: go vet ./...
# -unsafeptr=false: COM vtable callbacks in olecallback.go inherently
# require uintptr -> unsafe.Pointer conversions (mandated by
# syscall.NewCallback). These are safe because the uintptr values are
# COM interface pointers passed by the Windows runtime.
run: go vet -unsafeptr=false ./...

- name: Check formatting
run: |
Expand All @@ -72,7 +76,7 @@ jobs:
runs-on: windows-latest
strategy:
matrix:
go-version: ['1.23', '1.24', '1.25']
go-version: ['1.24', '1.25', '1.26']
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion examples/install_updates/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/ceshihao/windowsupdate/examples/install_updates

go 1.23
go 1.24

replace github.com/ceshihao/windowsupdate => ../../

Expand Down
2 changes: 1 addition & 1 deletion examples/query_update_history/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/ceshihao/windowsupdate/examples/query_update_history

go 1.23
go 1.24

replace github.com/ceshihao/windowsupdate => ../../

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/ceshihao/windowsupdate

go 1.23
go 1.24

require github.com/go-ole/go-ole v1.3.0

Expand Down
2 changes: 1 addition & 1 deletion iupdatedownloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (iUpdateDownloader *IUpdateDownloader) BeginDownload(updates []*IUpdate) (*
return nil, err
}

jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateDownloader.disp, "BeginDownload", nil, nil, nil))
jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateDownloader.disp, "BeginDownload", newNoopDispatch(), newNoopDispatch(), nil))
if err != nil {
return nil, err
}
Expand Down
4 changes: 2 additions & 2 deletions iupdateinstaller.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ func (iUpdateInstaller *IUpdateInstaller) BeginInstall(updates []*IUpdate) (*IIn
return nil, err
}

jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateInstaller.disp, "BeginInstall", nil, nil, nil))
jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateInstaller.disp, "BeginInstall", newNoopDispatch(), newNoopDispatch(), nil))
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -183,7 +183,7 @@ func (iUpdateInstaller *IUpdateInstaller) BeginUninstall(updates []*IUpdate) (*I
return nil, err
}

jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateInstaller.disp, "BeginUninstall", nil, nil, nil))
jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateInstaller.disp, "BeginUninstall", newNoopDispatch(), newNoopDispatch(), nil))
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion iupdatesearcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func (iUpdateSearcher *IUpdateSearcher) QueryHistoryAll() ([]*IUpdateHistoryEntr
// BeginSearch begins an asynchronous search for updates.
// https://learn.microsoft.com/en-us/windows/win32/api/wuapi/nf-wuapi-iupdatesearcher-beginsearch
func (iUpdateSearcher *IUpdateSearcher) BeginSearch(criteria string) (*ISearchJob, error) {
jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateSearcher.disp, "BeginSearch", criteria, nil, nil))
jobDisp, err := toIDispatchErr(oleutil.CallMethod(iUpdateSearcher.disp, "BeginSearch", criteria, newNoopDispatch(), nil))
if err != nil {
return nil, err
}
Expand Down
142 changes: 142 additions & 0 deletions olecallback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//go:build windows
Comment thread
ceshihao marked this conversation as resolved.

/*
Copyright 2026 Zheng Dayu
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package windowsupdate

import (
"sync"
"sync/atomic"
"syscall"
"unsafe"

"github.com/go-ole/go-ole"
)

// The asynchronous WUA methods (BeginSearch, BeginDownload, BeginInstall) require
// a non-NULL IUnknown* callback argument. Passing NULL (VT_NULL) makes them fail
// with DISP_E_TYPEMISMATCH (0x80020005). newNoopDispatch returns a minimal
// IDispatch whose Invoke does nothing (returns S_OK): completion is obtained
// through the blocking EndXxx methods and progress through IXxxJob.GetProgress().
//
// The handler signatures are 100% uintptr because that is required by
// syscall.NewCallback.

// noopCallbackVtbl is the COM virtual function table layout for IDispatch.
// The order of fields MUST match the IUnknown + IDispatch v-table layout.
type noopCallbackVtbl struct {
pQueryInterface uintptr
pAddRef uintptr
pRelease uintptr
pGetTypeInfoCount uintptr
pGetTypeInfo uintptr
pGetIDsOfNames uintptr
pInvoke uintptr
}

// noopCallback is a stateless dummy IDispatch implementation. lpVtbl MUST be
// the first field because the COM interface pointer points directly to it.
type noopCallback struct {
lpVtbl *noopCallbackVtbl
ref int32
}

// HRESULT values as uintptr (only the low 32 bits are significant).
const (
hrSOK = uintptr(0x00000000)
hrEPointer = uintptr(0x80004003)
hrENoInterface = uintptr(0x80004002)
hrENotImpl = uintptr(0x80004001)
)
Comment on lines +56 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To comply with COM standards, QueryInterface should return E_POINTER (0x80004003) if the output pointer argument ppvObject is null. Let's define hrEPointer to support this check.

Suggested change
const (
hrSOK = uintptr(0x00000000)
hrENoInterface = uintptr(0x80004002)
hrENotImpl = uintptr(0x80004001)
)
const (
hrSOK = uintptr(0x00000000)
hrEPointer = uintptr(0x80004003)
hrENoInterface = uintptr(0x80004002)
hrENotImpl = uintptr(0x80004001)
)


func ncQueryInterface(this, iid, ppvObject uintptr) uintptr {
if ppvObject == 0 {
return hrEPointer
}
out := (*uintptr)(unsafe.Pointer(ppvObject))
if iid == 0 {
*out = 0
return hrENoInterface
}
guid := (*ole.GUID)(unsafe.Pointer(iid))
if ole.IsEqualGUID(guid, ole.IID_IUnknown) || ole.IsEqualGUID(guid, ole.IID_IDispatch) {
atomic.AddInt32(&globalNoop.ref, 1)
*out = this
return hrSOK
}
Comment thread
ceshihao marked this conversation as resolved.
*out = 0
return hrENoInterface
}
Comment on lines +63 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

In COM, QueryInterface must return E_POINTER if ppvObject is null. Additionally, instead of referencing the global globalNoop singleton directly, it is more idiomatic and robust to cast the this pointer to *noopCallback to access the instance's reference counter.

func ncQueryInterface(this, iid, ppvObject uintptr) uintptr {
	if ppvObject == 0 {
		return hrEPointer
	}
	if iid == 0 {
		*(*uintptr)(unsafe.Pointer(ppvObject)) = 0
		return hrENoInterface
	}
	guid := (*ole.GUID)(unsafe.Pointer(iid))
	out := (*uintptr)(unsafe.Pointer(ppvObject))
	if ole.IsEqualGUID(guid, ole.IID_IUnknown) || ole.IsEqualGUID(guid, ole.IID_IDispatch) {
		p := (*noopCallback)(unsafe.Pointer(this))
		atomic.AddInt32(&p.ref, 1)
		*out = this
		return hrSOK
	}
	*out = 0
	return hrENoInterface
}


func ncAddRef(this uintptr) uintptr {
return uintptr(uint32(atomic.AddInt32(&globalNoop.ref, 1)))
}

func ncRelease(this uintptr) uintptr {
// Singleton object: it is never actually freed even if the count reaches
// zero. We still maintain the counter so the value returned to the COM
// caller is meaningful.
return uintptr(uint32(atomic.AddInt32(&globalNoop.ref, -1)))
}
Comment thread
ceshihao marked this conversation as resolved.
Comment on lines +82 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Instead of referencing the global globalNoop singleton directly, cast the this pointer to *noopCallback to access the instance's reference counter. This is more idiomatic for COM method implementations.

func ncAddRef(this uintptr) uintptr {
	p := (*noopCallback)(unsafe.Pointer(this))
	return uintptr(uint32(atomic.AddInt32(&p.ref, 1)))
}

func ncRelease(this uintptr) uintptr {
	// Singleton object: it is never actually freed even if the count reaches
	// zero. We still maintain the counter so the value returned to the COM
	// caller is meaningful.
	p := (*noopCallback)(unsafe.Pointer(this))
	return uintptr(uint32(atomic.AddInt32(&p.ref, -1)))
}


func ncGetTypeInfoCount(this, pctinfo uintptr) uintptr {
if pctinfo != 0 {
*(*uint32)(unsafe.Pointer(pctinfo)) = 0
}
return hrSOK
}

func ncGetTypeInfo(this, iTInfo, lcid, ppTInfo uintptr) uintptr {
return hrENotImpl
}

func ncGetIDsOfNames(this, riid, rgszNames, cNames, lcid, rgDispId uintptr) uintptr {
return hrENotImpl
}

// ncInvoke : no-op body. WUA calls DISPID 0 on progress/completion; we ignore it
// and return S_OK. Completion is detected through EndXxx (blocking).
func ncInvoke(this, dispIdMember, riid, lcid, wFlags, pDispParams, pVarResult, pExcepInfo, puArgErr uintptr) uintptr {
if pVarResult != 0 {
v := (*ole.VARIANT)(unsafe.Pointer(pVarResult))
v.VT = ole.VT_EMPTY
}
return hrSOK
}
Comment thread
ceshihao marked this conversation as resolved.
Comment on lines +110 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Instead of using raw pointer dereferencing with magic numbers (*(*uint16)(unsafe.Pointer(pVarResult)) = 0), cast pVarResult to *ole.VARIANT and use ole.VT_EMPTY from the go-ole package. This is much cleaner and less error-prone.

Suggested change
func ncInvoke(this, dispIdMember, riid, lcid, wFlags, pDispParams, pVarResult, pExcepInfo, puArgErr uintptr) uintptr {
if pVarResult != 0 {
*(*uint16)(unsafe.Pointer(pVarResult)) = 0 // VT_EMPTY
}
return hrSOK
}
func ncInvoke(this, dispIdMember, riid, lcid, wFlags, pDispParams, pVarResult, pExcepInfo, puArgErr uintptr) uintptr {
if pVarResult != 0 {
v := (*ole.VARIANT)(unsafe.Pointer(pVarResult))
v.VT = ole.VT_EMPTY
}
return hrSOK
}


var (
noopOnce sync.Once
globalNoop *noopCallback
)

// newNoopDispatch returns a pointer to a global singleton IDispatch usable as
// a WUA callback. Because the callback is completely stateless, a single
// instance can be safely shared across all async calls. This avoids the
// unbounded memory leak that would result from allocating a new callback on
// every invocation and pinning it in a global slice.
func newNoopDispatch() *ole.IDispatch {
noopOnce.Do(func() {
vtbl := &noopCallbackVtbl{
pQueryInterface: syscall.NewCallback(ncQueryInterface),
pAddRef: syscall.NewCallback(ncAddRef),
pRelease: syscall.NewCallback(ncRelease),
pGetTypeInfoCount: syscall.NewCallback(ncGetTypeInfoCount),
pGetTypeInfo: syscall.NewCallback(ncGetTypeInfo),
pGetIDsOfNames: syscall.NewCallback(ncGetIDsOfNames),
pInvoke: syscall.NewCallback(ncInvoke),
}
globalNoop = &noopCallback{lpVtbl: vtbl, ref: 1}
})
return (*ole.IDispatch)(unsafe.Pointer(globalNoop))
}
24 changes: 24 additions & 0 deletions olecallback_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//go:build !windows

/*
Copyright 2026 Zheng Dayu
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package windowsupdate

import "github.com/go-ole/go-ole"

// newNoopDispatch is a no-op stub on non-Windows platforms.
// The COM async methods are only functional on Windows.
func newNoopDispatch() *ole.IDispatch {
return nil
}
Loading