diff --git a/.github/workflows/windows-test.yml b/.github/workflows/windows-test.yml index 152dc97..c70f8c9 100644 --- a/.github/workflows/windows-test.yml +++ b/.github/workflows/windows-test.yml @@ -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 @@ -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 }} @@ -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: | @@ -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 diff --git a/examples/install_updates/go.mod b/examples/install_updates/go.mod index 306677c..62e910d 100644 --- a/examples/install_updates/go.mod +++ b/examples/install_updates/go.mod @@ -1,6 +1,6 @@ module github.com/ceshihao/windowsupdate/examples/install_updates -go 1.23 +go 1.24 replace github.com/ceshihao/windowsupdate => ../../ diff --git a/examples/query_update_history/go.mod b/examples/query_update_history/go.mod index 605b4e3..ec26bd8 100644 --- a/examples/query_update_history/go.mod +++ b/examples/query_update_history/go.mod @@ -1,6 +1,6 @@ module github.com/ceshihao/windowsupdate/examples/query_update_history -go 1.23 +go 1.24 replace github.com/ceshihao/windowsupdate => ../../ diff --git a/go.mod b/go.mod index d112e7b..a2ef049 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/iupdatedownloader.go b/iupdatedownloader.go index 67a1030..6d58c2e 100644 --- a/iupdatedownloader.go +++ b/iupdatedownloader.go @@ -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 } diff --git a/iupdateinstaller.go b/iupdateinstaller.go index ef2741d..c026a38 100644 --- a/iupdateinstaller.go +++ b/iupdateinstaller.go @@ -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 } @@ -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 } diff --git a/iupdatesearcher.go b/iupdatesearcher.go index a8ac890..f9b64c2 100644 --- a/iupdatesearcher.go +++ b/iupdatesearcher.go @@ -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 } diff --git a/olecallback.go b/olecallback.go new file mode 100644 index 0000000..9a6efa4 --- /dev/null +++ b/olecallback.go @@ -0,0 +1,142 @@ +//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 ( + "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) +) + +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 + } + *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))) +} + +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 +} + +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)) +} diff --git a/olecallback_other.go b/olecallback_other.go new file mode 100644 index 0000000..1a30a3a --- /dev/null +++ b/olecallback_other.go @@ -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 +}