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
23 changes: 22 additions & 1 deletion .github/scripts/make_app_bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,29 @@ if [ ! -f "${work}/${binary}" ]; then
fi

app="${work}/${binary}.app"
mkdir -p "${app}/Contents/MacOS"
mkdir -p "${app}/Contents/MacOS" "${app}/Contents/Resources"
mv "${work}/${binary}" "${app}/Contents/MacOS/${binary}"
chmod +x "${app}/Contents/MacOS/${binary}"

# The icon, and it is refused rather than skipped when it is missing.
#
# A bundle without one is not a bundle that looks slightly worse - macOS draws a
# blank sheet of paper for it in the Finder and the Dock, which is what a program
# it knows nothing about looks like. O154, reported by the owner on 2026-08-28.
#
# The file is in the repository rather than made here with sips and iconutil,
# because this script runs on ubuntu: the command line archives are cross
# compiled, so Apple's tools are not there. tools/appicon.py writes it, at every
# size macOS asks for, and Apple's own iconutil was asked whether it accepts
# what came out.
here="$(cd "$(dirname "$0")" && pwd)"
icon="${here}/../../internal/gui/icon/chickpea.icns"
if [ ! -f "${icon}" ]; then
echo "make_app_bundle: no icon at ${icon}, so the bundle would show a blank page" >&2
exit 1
fi
cp "${icon}" "${app}/Contents/Resources/icon.icns"

# LSMinimumSystemVersion is 11.0 because this project builds darwin/arm64 only
# and Apple silicon starts there. NSHighResolutionCapable keeps the window from
# being drawn blurry on a Retina display, and costs the command line nothing.
Expand All @@ -51,6 +70,8 @@ cat > "${app}/Contents/Info.plist" <<PLIST
<dict>
<key>CFBundleExecutable</key>
<string>${binary}</string>
<key>CFBundleIconFile</key>
<string>icon</string>
<key>CFBundleIdentifier</key>
<string>${bundle_id}</string>
<key>CFBundleName</key>
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,26 @@ because it turns other people's test suites red.

### Fixed

- On macOS the program now has an icon. It is a `.app` bundle since the release
before this one, and a bundle with no icon in it is drawn by the Finder and
the Dock as a blank sheet of paper - which is what a program the system knows
nothing about looks like. The icon is the same drawing the other two systems
use, on the rounded square macOS puts every icon on, at every size from 16 px
to 1024.

- In the window, a menu is now the same width wherever it appears, and always
wide enough for the words in it. The menu for choosing a format was 140 px
wide on the single batch screen and 98 px on the presets screen and in a row
of an archive's contents, for the same twenty formats. In that narrow box the
toolkit's own "(Select one)" was cut off mid word, so a row of an archive's
contents offered "(Select ..." until a format was picked. No menu is drawn
narrower than the boxes standing beside it any more.

- In the window, the Remove button ending a row of an archive's contents is the
size of a button. It was taking a quarter of the form's width and the height
of a label and a control together, which drew it as a panel with a word in
the middle rather than as something to press.

- The notices that travel with a release now name the fonts and drawings the
window binary carries. Seven font files and ninety-seven images are compiled
into `tfg-gui` from inside the graphics toolkit, under the SIL Open Font
Expand Down
54 changes: 54 additions & 0 deletions internal/guard/formwidth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,60 @@ func TestABoxForANumberIsTheWidthOfANumber(t *testing.T) {
// Only the contents are asked about here. A guard that also demanded the
// surface stop at the column would be pinning down the one thing that is
// deliberately different, and the next person to widen the bar would delete it.
// A button standing in a row of fields is the size of a button.
//
// Reported by the owner on 2026-08-28 from the running window, and the numbers
// are off the laid out screen rather than off the code: the Remove button
// ending a row of an archive's contents was 197.50 x 63.16 px for a word the
// toolkit says needs 67.92 x 32. That is a quarter of the form wide and as tall
// as a label and a control together, so it read as a grey panel with a word in
// the middle of it. The Duplicate button at the head of the same batch, which
// stands in no row, is 78.97 x 35.16.
//
// The cause is that parts.Row shares the width out in equal columns, which is
// what a field wants and what anything else gets whether it wants it or not.
// See parts.BesideFields.
//
// Asked against MinSize, which is the widget's own answer for the room its word
// needs, so nothing here repeats a layout's arithmetic. Both directions, because
// a button smaller than its own minimum is a word cut in half.
func TestAButtonInARowOfFieldsIsTheSizeOfAButton(t *testing.T) {
ourTheme(t)
content, _ := laidOutWindow(t)
screen := selectTab(t, content, text.TabRecipe())

// An archive first. The only row in this window that ends in a button is
// the one saying what an archive holds, and it is not on the screen until a
// batch says it holds anything.
chooseFormat(t, screen, "zip")
pressNamed(t, screen, text.ButtonAddContents())

remove := buttonNamed(screen, text.ButtonRemoveContents())
if remove == nil {
t.Fatalf("no %q button after asking a zip what it holds, so this guard checked nothing",
text.ButtonRemoveContents())
}
got, needs := remove.Size(), remove.MinSize()
if got.Width == 0 || got.Height == 0 {
t.Fatal("the button was never laid out, so its size says nothing")
}
const slack = 0.5
if got.Width > needs.Width+slack || got.Height > needs.Height+slack {
t.Errorf("the %q button in a row of an archive's contents is %.2f x %.2f px and the word"+
" in it needs %.2f x %.2f, so the row is drawing it as a panel rather than as a"+
" button.\n"+
"What to do: parts.BesideFields keeps something that is not a field out of the"+
" column arithmetic.",
remove.Text, got.Width, got.Height, needs.Width, needs.Height)
}
if got.Width+slack < needs.Width || got.Height+slack < needs.Height {
t.Errorf("the %q button is %.2f x %.2f px and needs %.2f x %.2f, so its word is cut off.",
remove.Text, got.Width, got.Height, needs.Width, needs.Height)
}
t.Logf("the %q button is %.2f x %.2f, and it needs %.2f x %.2f",
remove.Text, got.Width, got.Height, needs.Width, needs.Height)
}

func TestTheRunSpeaksInsideTheSameColumnAsTheForm(t *testing.T) {
host := newFakeHost(t)
window.Open(host)
Expand Down
108 changes: 108 additions & 0 deletions internal/guard/macossigning_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package guard

import (
"bytes"
"encoding/binary"
"image"
_ "image/png"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -96,6 +100,110 @@ func TestTheLinkBesideTheBundleCannotBeACopy(t *testing.T) {
}
}

// The bundle carries an icon, and it refuses to be built without one.
//
// O154, reported by the owner on 2026-08-28: a bundle with no CFBundleIconFile
// and nothing in Resources is drawn by the Finder and the Dock as a blank sheet
// of paper, which is what a program the system knows nothing about looks like.
// It was not a regression - until that day macOS got a bare binary and had no
// icon either - and it became visible the moment the program became a .app.
//
// Refusing rather than skipping, because the failure is silent on both ends: a
// bundle without an icon builds, signs, notarises and staples exactly like one
// with an icon, and nothing before a person's screen would say a word.
func TestTheMacBundleCarriesOurIcon(t *testing.T) {
script := bundleScript(t)

if !strings.Contains(script, "chickpea.icns") {
t.Error("make_app_bundle.sh never copies an icon into the bundle, so macOS draws " +
"the program as a blank page in the Finder and the Dock")
}
if !strings.Contains(script, "Contents/Resources/icon.icns") {
t.Error("make_app_bundle.sh puts no icon.icns in Contents/Resources, which is " +
"where CFBundleIconFile is looked up")
}
if !strings.Contains(script, "CFBundleIconFile") {
t.Error("the Info.plist this script writes names no icon file, so an icon sitting " +
"in Resources is never read")
}
// The refusal, and it is the half worth guarding. An icon that quietly is
// not there produces a bundle that is wrong in the one way nothing later in
// the release can see.
if !strings.Contains(script, "no icon at") {
t.Error("make_app_bundle.sh does not stop when the icon is missing, so a build " +
"with no icon file produces a bundle that looks finished and shows a blank page")
}
}

// And the icon it copies really carries every size macOS asks for.
//
// Read out of the bytes rather than trusted, because this file is written by a
// script that is not run in CI and cannot be regenerated here - Pillow is a
// build tool on one machine. So the committed file is the artefact, and what
// nobody would notice is a file that parses, opens in a viewer, and is missing
// the two sizes a screen without Retina asks for.
//
// Apple's own iconutil was asked whether it accepts what tools/appicon.py
// writes, on a real Mac on 2026-08-28, and handed back all ten entries at the
// pixel sizes below. This guard is the part of that answer that can be asked
// again on any machine, on every run.
func TestTheIconMacOSReadsCarriesEverySizeItIsAskedFor(t *testing.T) {
body, err := os.ReadFile(filepath.Join(repoRoot(t), "internal", "gui", "icon", "chickpea.icns"))
if err != nil {
t.Skipf("no macOS icon here: %v", err)
}
if len(body) < 8 || string(body[:4]) != "icns" {
t.Fatalf("the icon does not start with the four bytes that say what it is, so no "+
"reader will take it: %q", body[:min(8, len(body))])
}
if declared := binary.BigEndian.Uint32(body[4:8]); int(declared) != len(body) {
t.Errorf("the icon says it is %d bytes and it is %d, and a reader that trusts the "+
"header stops early or runs off the end", declared, len(body))
}
// Apple's names for the sizes, with the pixels each one has to hold. Both
// members of a pair are the same picture: macOS asks for a point size and a
// scale, so 32 px answers two questions and has to be in the file twice.
wanted := map[string]int{
"icp4": 16, "ic11": 32, "icp5": 32, "ic12": 64, "ic07": 128,
"ic13": 256, "ic08": 256, "ic14": 512, "ic09": 512, "ic10": 1024,
}
found := map[string]int{}
for at := 8; at < len(body); {
if at+8 > len(body) {
t.Fatalf("a chunk header runs past the end of the file at byte %d", at)
}
name := string(body[at : at+4])
size := int(binary.BigEndian.Uint32(body[at+4 : at+8]))
if size < 8 || at+size > len(body) {
t.Fatalf("the %q chunk says it is %d bytes, which does not fit in the file", name, size)
}
config, format, err := image.DecodeConfig(bytes.NewReader(body[at+8 : at+size]))
if err != nil {
t.Fatalf("the %q chunk does not hold a picture: %v", name, err)
}
if format != "png" {
t.Errorf("the %q chunk holds a %s and a bundle icon is read as PNG", name, format)
}
if config.Width != config.Height {
t.Errorf("the %q chunk is %dx%d and an icon is square", name, config.Width, config.Height)
}
found[name] = config.Width
at += size
}
for name, pixels := range wanted {
got, is := found[name]
if !is {
t.Errorf("the icon has no %q entry, so macOS falls back to scaling another size "+
"where it wanted %d px", name, pixels)
continue
}
if got != pixels {
t.Errorf("the %q entry is %d px and macOS reads it as %d", name, got, pixels)
}
}
t.Logf("%d entries, every size macOS asks for, %d bytes", len(found), len(body))
}

// The pin is a digest with a date, and the script derives its selector from it.
//
// Same shape as the Windows pin and for the same reason: codesign selects by
Expand Down
132 changes: 132 additions & 0 deletions internal/guard/menushape_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fyne.io/fyne/v2/theme"

"github.com/donislawdev/TestingFilesGenerator/internal/gui/parts"
"github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
)

// What this defends. A control you press to open a list is not drawn as a
Expand Down Expand Up @@ -180,3 +181,134 @@ func typingBoxesOn(screen fyne.CanvasObject) []*parts.Entry {
})
return found
}

// What this defends. A menu is at least as wide as the toolkit says it needs to
// show what it is showing.
//
// Why it needed a guard, and it is a defect somebody looked straight at.
// Reported by the owner on 2026-08-28 from the running window and then rendered:
// the format menu in a row of an archive's contents drew "(Select ..." - the
// placeholder cut off in the middle of the word it exists to show. Measured with
// tools/probes/menuwidth: that box was 97.75 px and the toolkit's own answer for
// what it needs was 119.91.
//
// How it is asked. Against MinSize, which is the widget's own claim about the
// room its text takes, rather than against arithmetic repeated here. That is
// deliberate for two reasons. A guard that recomputed parts.menuWidth would
// agree with a wrong answer. And the string being measured is one the toolkit
// puts in by itself - fyne v2.8.1 widget/select.go line 94 substitutes its
// default placeholder while the renderer is made - so the only party that knows
// what a menu will end up showing is the menu.
func TestAMenuIsWideEnoughForTheWordsTheToolkitPutsInIt(t *testing.T) {
ourTheme(t)
content, canvas := laidOutWindow(t)

checked := 0
tightest, tightestIn := float32(0), float32(0)
for _, tab := range allTabs() {
screen := selectTab(t, content, tab)
for _, menu := range menusWithAnArchiveOpened(t, screen, tab, canvas) {
room, needs := menu.Size().Width, menu.MinSize().Width
if room == 0 {
continue
}
if room < needs {
t.Errorf("a menu of %v on the %s screen is %.2f px and the toolkit says it needs"+
" %.2f to show what is in it, so the words are cut off in the box that"+
" exists to show them.\n"+
"What to do: parts.menuWidth is what decides this width.",
menu.Options, tab, room, needs)
}
if slack := room - needs; tightest == 0 || slack < tightest {
tightest, tightestIn = slack, room
}
checked++
}
}
if checked == 0 {
t.Fatal("no menu was laid out, so this guard checked nothing")
}
t.Logf("%d menus, all wide enough. The tightest has %.2f px to spare in %.2f", checked, tightest, tightestIn)
}

// And no menu is narrower than the boxes it stands beside.
//
// This is the owner's report of 2026-08-28 rather than a preference. The format
// menu on the preset screen sat between a limit and a seed, both 140 px, and was
// 97.75 - so the same setting was 140 px on the single batch screen and 98 on
// this one, because the width came from how long the words "targz" and "pdf"
// happen to be. Nothing was cut off. What was wrong is that a control this
// window never draws under 140 px was drawn at 98.
//
// Asked against the narrowest box laid out on the SAME screen, for the reason
// its sibling above is asked against the widest: the claim is a relationship
// between the controls a person sees together, not a number written down twice.
// Boxes with no width are left out - the two ways of stating a size that the
// switch is hiding are laid out at nought and minus three, and a floor taken
// from those would be no floor at all.
func TestNoMenuIsNarrowerThanTheBoxesItStandsBeside(t *testing.T) {
ourTheme(t)
content, canvas := laidOutWindow(t)

checked := 0
for _, tab := range allTabs() {
screen := selectTab(t, content, tab)
menus := menusWithAnArchiveOpened(t, screen, tab, canvas)
if len(menus) == 0 {
continue
}
narrowest := float32(0)
for _, box := range typingBoxesOn(screen) {
w := box.Size().Width
if w <= 0 {
continue
}
if narrowest == 0 || w < narrowest {
narrowest = w
}
}
if narrowest == 0 {
t.Fatalf("the %s screen has %d menus and no box to type in that was laid out,"+
" so there is nothing to compare them against", tab, len(menus))
}
for _, menu := range menus {
got := menu.Size().Width
if got == 0 {
continue
}
if got < narrowest {
t.Errorf("a menu of %v on the %s screen is %.2f px and the narrowest box beside it"+
" is %.2f, so one setting is drawn shorter here than the same setting is"+
" elsewhere in this window.\n"+
"What to do: parts.menuWidth holds a menu to parts.NumericWidth at the least.",
menu.Options, tab, got, narrowest)
}
checked++
}
}
if checked == 0 {
t.Fatal("no screen has a menu, so this guard checked nothing")
}
t.Logf("%d menus, none of them narrower than the boxes beside them", checked)
}

// menusWithAnArchiveOpened is every menu of a screen, including the ones that
// only exist once a batch says it holds files.
//
// Without this the row an archive's contents are typed into is invisible to
// these guards, and that row is where the defect they are about was seen. A
// screen is left exactly as it was found on every other tab.
func menusWithAnArchiveOpened(t *testing.T, screen fyne.CanvasObject, tab string, canvas fyne.Canvas) []*parts.Chooser {
t.Helper()
if tab != text.TabRecipe() {
return menusOn(screen)
}
// An archive first, because since 2026-08-27 the offer to say what a batch
// holds is only under a format that holds anything.
chooseFormat(t, screen, "zip")
pressNamed(t, screen, text.ButtonAddContents())
if canvas != nil {
canvas.Content().Resize(canvas.Size())
}
return menusOn(screen)
}
Loading
Loading