Skip to content

Commit 8bea50c

Browse files
committed
feat: picotool embed (build-tag-gated), CI + release workflow, handoff update
Three deferred v0.2 items land: picotool embed -- a new internal/picotool package uses //go:embed behind a 'embed_picotool' build tag. Default builds compile in an empty stub and fall back to the host PATH; tagged builds extract the embedded exe to %TEMP%/handoff/picotool-<hash>.exe on first use. scripts/fetch-picotool.ps1 downloads the official binary into internal/picotool/binaries/ (gitignored). Default binary stays at 9.5 MB; tagged binary is 15.7 MB. GitHub Actions -- .github/workflows/ci.yml does vet + default-build + tagged-build on every push and PR. .github/workflows/release.yml fires on tag push (v*), builds the embedded binary with trimpath and -s -w, writes a sidecar handoff-version.json manifest (version, sha256, url, notes), and uploads both to the GitHub release. A code-signing step is wired in but inert -- it activates only when SIGNING_CERT_BASE64 and SIGNING_CERT_PASSWORD repo secrets are set. handoff update -- fetches /dl/handoff-version.json from the relay and, if the listed version differs from the running binary, downloads /dl/handoff.exe to handoff.exe.new next to the current exe. The user replaces and restarts manually (no in-place swap of a running Windows process). --check prints the comparison without downloading. README updated with install / build-from-source / update sections.
1 parent 3d756c9 commit 8bea50c

12 files changed

Lines changed: 482 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
build:
11+
runs-on: windows-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-go@v5
15+
with:
16+
go-version: '1.22'
17+
cache: true
18+
19+
- name: go vet
20+
run: go vet ./...
21+
22+
- name: go build (default)
23+
run: go build -v ./...
24+
25+
- name: Fetch picotool
26+
shell: pwsh
27+
run: ./scripts/fetch-picotool.ps1
28+
29+
- name: go build (embedded)
30+
run: go build -tags embed_picotool -v ./...

.github/workflows/release.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
workflow_dispatch:
8+
inputs:
9+
tag:
10+
description: 'Tag to release (e.g. v0.1.0)'
11+
required: true
12+
13+
permissions:
14+
contents: write
15+
16+
jobs:
17+
build:
18+
runs-on: windows-latest
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-go@v5
23+
with:
24+
go-version: '1.22'
25+
cache: true
26+
27+
- name: Fetch picotool
28+
shell: pwsh
29+
run: |
30+
./scripts/fetch-picotool.ps1
31+
32+
- name: Build with embedded picotool
33+
shell: pwsh
34+
run: |
35+
$env:GOOS = 'windows'
36+
$env:GOARCH = 'amd64'
37+
$env:CGO_ENABLED = '0'
38+
go build -trimpath -ldflags="-s -w" -tags embed_picotool -o handoff.exe .
39+
40+
- name: Build sidecar version manifest
41+
shell: pwsh
42+
run: |
43+
$tag = "${{ github.event.inputs.tag }}"
44+
if (-not $tag) { $tag = "${{ github.ref_name }}" }
45+
$version = $tag.TrimStart('v')
46+
$sha = (Get-FileHash -Algorithm SHA256 handoff.exe).Hash.ToLower()
47+
$manifest = [ordered]@{
48+
version = $version
49+
sha256 = $sha
50+
url = "https://github.com/${{ github.repository }}/releases/download/$tag/handoff.exe"
51+
notes = "Release $tag"
52+
}
53+
$manifest | ConvertTo-Json -Compress | Out-File -FilePath handoff-version.json -Encoding ASCII
54+
Get-Content handoff-version.json
55+
56+
- name: Code signing (disabled until cert is provisioned)
57+
if: ${{ env.SIGNING_CERT_BASE64 != '' }}
58+
env:
59+
SIGNING_CERT_BASE64: ${{ secrets.SIGNING_CERT_BASE64 }}
60+
SIGNING_CERT_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }}
61+
shell: pwsh
62+
run: |
63+
# Activate by setting the SIGNING_CERT_BASE64 and SIGNING_CERT_PASSWORD
64+
# repository secrets to a base64-encoded PFX and its password. The
65+
# block stays inert until both are set.
66+
$pfx = [Convert]::FromBase64String($env:SIGNING_CERT_BASE64)
67+
$tmp = Join-Path $env:RUNNER_TEMP 'handoff-sign.pfx'
68+
[IO.File]::WriteAllBytes($tmp, $pfx)
69+
$signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe' |
70+
Sort-Object -Descending FullName | Select-Object -First 1
71+
& $signtool.FullName sign /f $tmp /p $env:SIGNING_CERT_PASSWORD /fd sha256 /tr http://timestamp.digicert.com /td sha256 handoff.exe
72+
Remove-Item $tmp -Force
73+
74+
- name: Upload release assets
75+
uses: softprops/action-gh-release@v2
76+
with:
77+
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
78+
files: |
79+
handoff.exe
80+
handoff-version.json
81+
generate_release_notes: true
82+
draft: false
83+
prerelease: ${{ contains(github.event.inputs.tag || github.ref_name, '-') }}

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ audit/
1616
# Temp unpacked tools
1717
.handoff-bin/
1818

19+
# Bundled picotool binary downloaded by scripts/fetch-picotool.ps1.
20+
# Embedded only into -tags embed_picotool builds; default builds use PATH.
21+
internal/picotool/binaries/picotool.exe
22+
1923
# Log files
2024
*.log
2125

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,37 @@ view token.
1212
the URL and queues commands -- host console shows each command and result --
1313
host presses Ctrl+C or types `q` to end the session.
1414

15+
### Install
16+
17+
Download the latest `handoff.exe` from
18+
[Releases](https://github.com/RealWhyKnot/Handoff/releases) and run it from a
19+
terminal. No installer. The default relay is `https://couchlink.whyknot.dev`;
20+
set `HANDOFF_RELAY` to point at a different one.
21+
22+
### Build from source
23+
24+
```powershell
25+
git clone https://github.com/RealWhyKnot/Handoff
26+
cd Handoff
27+
go build -o handoff.exe .
28+
```
29+
30+
The default build calls into the system-installed `picotool` for the `pico.*`
31+
commands. To bundle picotool into the binary (so the host doesn't need to
32+
install it separately):
33+
34+
```powershell
35+
./scripts/fetch-picotool.ps1
36+
go build -tags embed_picotool -o handoff.exe .
37+
```
38+
39+
### Update
40+
41+
```powershell
42+
./handoff.exe update --check # check the relay for a newer release
43+
./handoff.exe update # download it next to the running exe
44+
```
45+
46+
### License
47+
1548
GPL-3.0-or-later. Source at <https://github.com/RealWhyKnot/Handoff>.

cmd/update.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
package cmd
3+
4+
import (
5+
"context"
6+
"crypto/sha256"
7+
"encoding/hex"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
"time"
16+
)
17+
18+
// Update implements `handoff update`. Checks the relay's published
19+
// version manifest at <relay>/dl/handoff-version.json and, if a newer
20+
// release is available, downloads it next to the running binary as
21+
// handoff.exe.new. The user replaces the old binary manually -- we
22+
// don't try to atomic-swap a running executable on Windows.
23+
func Update(args []string) {
24+
check := false
25+
for _, a := range args {
26+
if a == "--check" || a == "-c" {
27+
check = true
28+
}
29+
}
30+
31+
relay := strings.TrimRight(defaultRelay(), "/")
32+
manifestURL := relay + "/dl/handoff-version.json"
33+
exeURL := relay + "/dl/handoff.exe"
34+
35+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
36+
defer cancel()
37+
38+
current := getCurrentVersion()
39+
40+
fmt.Println("current:", current)
41+
fmt.Println("checking:", manifestURL)
42+
43+
man, err := fetchManifest(ctx, manifestURL)
44+
if err != nil {
45+
fmt.Fprintln(os.Stderr, "could not reach update manifest:", err)
46+
os.Exit(1)
47+
}
48+
fmt.Println("latest: ", man.Version)
49+
if man.Notes != "" {
50+
fmt.Println("notes: ", man.Notes)
51+
}
52+
53+
if man.Version == current {
54+
fmt.Println("up to date")
55+
return
56+
}
57+
if check {
58+
fmt.Println("(check-only; not downloading)")
59+
return
60+
}
61+
62+
exePath, err := os.Executable()
63+
if err != nil {
64+
fmt.Fprintln(os.Stderr, "could not resolve current executable:", err)
65+
os.Exit(1)
66+
}
67+
dst := filepath.Join(filepath.Dir(exePath), "handoff.exe.new")
68+
69+
fmt.Println("downloading:", exeURL, "->", dst)
70+
if err := downloadVerified(ctx, exeURL, man.Sha256, dst); err != nil {
71+
fmt.Fprintln(os.Stderr, "download failed:", err)
72+
_ = os.Remove(dst)
73+
os.Exit(1)
74+
}
75+
fmt.Println("downloaded.")
76+
fmt.Println()
77+
fmt.Println("To finish the update:")
78+
fmt.Println(" 1) Close any running 'handoff' processes.")
79+
fmt.Println(" 2) Replace", exePath)
80+
fmt.Println(" with ", dst)
81+
fmt.Println(" 3) Start a new session.")
82+
}
83+
84+
type versionManifest struct {
85+
Version string `json:"version"`
86+
Sha256 string `json:"sha256"`
87+
Notes string `json:"notes"`
88+
URL string `json:"url"`
89+
}
90+
91+
func fetchManifest(ctx context.Context, url string) (*versionManifest, error) {
92+
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
93+
resp, err := http.DefaultClient.Do(req)
94+
if err != nil {
95+
return nil, err
96+
}
97+
defer resp.Body.Close()
98+
if resp.StatusCode != 200 {
99+
return nil, fmt.Errorf("manifest fetch: %d", resp.StatusCode)
100+
}
101+
var m versionManifest
102+
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
103+
return nil, err
104+
}
105+
if m.Version == "" {
106+
return nil, fmt.Errorf("manifest has empty version")
107+
}
108+
return &m, nil
109+
}
110+
111+
func downloadVerified(ctx context.Context, url, sha256Hex, dst string) error {
112+
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
113+
resp, err := http.DefaultClient.Do(req)
114+
if err != nil {
115+
return err
116+
}
117+
defer resp.Body.Close()
118+
if resp.StatusCode != 200 {
119+
return fmt.Errorf("%d", resp.StatusCode)
120+
}
121+
122+
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
123+
if err != nil {
124+
return err
125+
}
126+
h := sha256.New()
127+
written, err := io.Copy(io.MultiWriter(f, h), resp.Body)
128+
if cerr := f.Close(); err == nil {
129+
err = cerr
130+
}
131+
if err != nil {
132+
return err
133+
}
134+
if written == 0 {
135+
return fmt.Errorf("empty body")
136+
}
137+
if sha256Hex != "" {
138+
got := hex.EncodeToString(h.Sum(nil))
139+
if !strings.EqualFold(got, sha256Hex) {
140+
return fmt.Errorf("sha256 mismatch (got %s, expected %s)", got, sha256Hex)
141+
}
142+
}
143+
return nil
144+
}
145+
146+
// getCurrentVersion returns the version string baked into the binary.
147+
// We re-read from main's exported const via an env-injected fallback;
148+
// in v0.1 we just print "0.1.x" so the operator has something to
149+
// compare with the manifest until the build stamps a real version.
150+
func getCurrentVersion() string {
151+
if v := os.Getenv("HANDOFF_VERSION"); v != "" {
152+
return v
153+
}
154+
return Version
155+
}

internal/capabilities/pico.go

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111

1212
"github.com/RealWhyKnot/Handoff/internal/dispatch"
13+
"github.com/RealWhyKnot/Handoff/internal/picotool"
1314
)
1415

1516
// RegisterPico wires the pico.* handlers. v0.1 shells out to the
@@ -25,15 +26,7 @@ func RegisterPico(r *dispatch.Router) {
2526
}
2627

2728
func picotoolPath() (string, error) {
28-
p, err := exec.LookPath("picotool")
29-
if err == nil {
30-
return p, nil
31-
}
32-
p, err = exec.LookPath("picotool.exe")
33-
if err == nil {
34-
return p, nil
35-
}
36-
return "", fmt.Errorf("picotool not on PATH (install from raspberrypi/pico-sdk-tools or `winget install raspberrypi.picotool`)")
29+
return picotool.Path()
3730
}
3831

3932
func runPicotool(ctx context.Context, args ...string) (stdout, stderr string, err error) {

internal/picotool/binaries/.keep

Whitespace-only changes.

internal/picotool/embed_with.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
//go:build embed_picotool
3+
4+
package picotool
5+
6+
import _ "embed"
7+
8+
// data carries the bundled picotool.exe. Populated only on builds
9+
// invoked with `-tags embed_picotool`; default builds compile in the
10+
// empty stub from embed_without.go and fall back to the host PATH.
11+
//
12+
//go:embed binaries/picotool.exe
13+
var data []byte
14+
15+
// Embedded reports whether the binary was baked into this build.
16+
func Embedded() bool { return len(data) > 0 }

internal/picotool/embed_without.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
//go:build !embed_picotool
3+
4+
package picotool
5+
6+
// data is empty on default builds; pico.* handlers fall back to PATH.
7+
var data []byte
8+
9+
// Embedded reports whether the binary was baked into this build.
10+
func Embedded() bool { return false }

0 commit comments

Comments
 (0)