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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ Notable changes, newest first. Dates are the day the work landed on `main`.

The versions are what a release is tagged as; between tags, `main` is what is being used daily.

## Unreleased

### Fixed

- **The update dialog says what changed.** It announced that a new version was available and then
showed an empty page, so the only way to find out what you were about to install was to go and
look the changelog up yourself — and pressing Install without knowing is what people actually
did. The feed now carries that version's own changelog section, set to be read in the small
window it appears in and following the Mac's light or dark appearance. A release whose version
has nothing written about it in the changelog is now refused, rather than published with an
empty page to show.

### Changed

- **The self-check writes English names.** Running it created a skill called `auto-teste` and a
command called `auto-comando`, described in Portuguese, on the disk of whoever ran it. They are
now `self-check-skill` and `self-check-command`.

## 0.3.3 — 2026-08-28

### Changed
Expand Down
101 changes: 101 additions & 0 deletions Scripts/appcast.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,110 @@ STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
ditto "$DMG" "$STAGE/$(basename "$UPDATE_DMG")"

# Sparkle's dialog says only that a new version is available unless the feed carries a description
# of its own, and the tooling has exactly one way to be given one: a file beside the archive in the
# directory it scans, named after that archive — Loadout-<version>-update.html next to
# Loadout-<version>-update.dmg. HTML with no DOCTYPE or body tags is embedded in the feed rather
# than linked, which is what the dialog needs, because at that moment it is being read by somebody
# deciding whether to press Install and it cannot go and fetch anything.
NOTES="$STAGE/$(basename "${UPDATE_DMG%.dmg}").html"

# This version's section and no other: from its own '## <version>' heading to the next one. A
# dialog describing the previous release's work is worse than one describing none, because it is
# believed.
SECTION="$(awk -v v="$VERSION" '
$1 == "##" && $2 == v { inside = 1; next }
$1 == "##" && inside { exit }
inside { print }
' "$ROOT/CHANGELOG.md")"

[ -n "$(printf '%s' "$SECTION" | tr -d '[:space:]')" ] || {
echo "error: CHANGELOG.md has nothing under '## $VERSION' — the update dialog would announce" >&2
echo " this version and then show the reader nothing about it" >&2
exit 1
}

# Only what this changelog actually writes: '### Fixed'-style subheadings, '- ' bullets whose text
# wraps across lines and sometimes carries a second paragraph, **bold**, `code` and [text](url).
# Every line is escaped on the way in — one stray & or < in a release note would make the signed
# feed invalid XML, and installed copies would reject the whole thing rather than one item.
#
# Colours are left to the reader's system: Sparkle draws this in a small web view that follows dark
# mode, so a hardcoded black on white becomes unreadable the moment the Mac is dark. 'color-scheme'
# tells WebKit to pick both, and the one tint used here is a grey that works either way.
{
cat <<-'CSS'
<style>
:root { color-scheme: light dark }
body {
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
font-size: 13px;
line-height: 1.5;
margin: 0;
padding: 2px 4px;
}
h2 {
font-size: 11px;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
opacity: .55;
margin: 16px 0 7px;
}
h2:first-child { margin-top: 0 }
ul { margin: 0; padding-left: 17px }
li { margin-bottom: 11px }
li:last-child { margin-bottom: 0 }
p { margin: 0 0 7px }
p:last-child { margin-bottom: 0 }
li p + p { margin-top: 7px }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: rgba(127, 127, 127, .18);
border-radius: 3px;
padding: 0 3px;
}
strong { font-weight: 600 }
</style>
CSS

printf '%s\n' "$SECTION" | awk '
function esc(s) {
gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
return s
}
function add(t) { buf = (buf == "" ? t : buf " " t) }
function para() { if (buf != "") { print "<p>" buf "</p>"; buf = "" } }
function item() { para(); if (li) { print "</li>"; li = 0 } }
function list() { item(); if (ul) { print "</ul>"; ul = 0 } }

/^### / { list(); print "<h2>" esc(substr($0, 5)) "</h2>"; next }
/^- / { item(); if (!ul) { print "<ul>"; ul = 1 }
print "<li>"; li = 1; add(esc(substr($0, 3))); next }
/^[ \t]*$/ { para(); next }
/^ +[^ ]/ { line = $0; sub(/^ +/, "", line); add(esc(line)); next }
{ if (ul) list(); add(esc($0)) }
END { list() }
' | sed -E \
-e 's/\[([^]]+)\]\(([^)]+)\)/<a href="\2">\1<\/a>/g' \
-e 's/\*\*([^*]+)\*\*/<strong>\1<\/strong>/g' \
-e 's/`([^`]+)`/<code>\1<\/code>/g'
} >"$NOTES"

# A section that is only a heading, or that the conversion emptied, would leave the same blank
# dialog as no section at all — and would do it quietly, which is the failure being fixed here.
grep -qE '<(li|p|h2)>' "$NOTES" || {
echo "error: the '## $VERSION' section of CHANGELOG.md converted to nothing readable" >&2
exit 1
}

"$GENERATE" \
--account loadout \
--download-url-prefix "https://github.com/migsilva89/loadout/releases/download/v$VERSION/" \
--full-release-notes-url "https://github.com/migsilva89/loadout/blob/main/CHANGELOG.md" \
--link "https://loadout.migsilva.dev" \
--embed-release-notes \
--maximum-versions 1 \
--maximum-deltas 0 \
-o "$STAGE/appcast.xml" \
Expand All @@ -78,6 +177,8 @@ grep -q '<!-- sparkle-signatures:' "$OUTPUT" \
|| { echo "error: appcast.xml itself is not signed" >&2; exit 1; }
grep -q "$(basename "$UPDATE_DMG")" "$OUTPUT" \
|| { echo "error: the feed does not point at the update copy" >&2; exit 1; }
grep -q '<description>' "$OUTPUT" \
|| { echo "error: the release notes did not reach the feed — the dialog would say nothing" >&2; exit 1; }

echo "update feed → $OUTPUT"
echo "update image → $UPDATE_DMG"
6 changes: 3 additions & 3 deletions Scripts/frame-timing.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
stamps = [(f, i * 0.13) for i, f in enumerate(frames)]

if not stamps:
print("sem fotogramas em", folder)
print("no frames in", folder)
sys.exit(1)

# The first frames of a recording can be a different size from the rest: the window is captured
Expand All @@ -54,7 +54,7 @@ def size(path):
common = max(sizes, key=sizes.get)
dropped = [path for path, _ in stamps if size(path) != common]
if dropped:
print("→ %d fotogramas de outro tamanho, postos de lado" % len(dropped))
print("→ %d frames of another size, set aside" % len(dropped))
stamps = [(path, at) for path, at in stamps if size(path) == common]

lines = []
Expand All @@ -73,4 +73,4 @@ def size(path):
with open(os.path.join(folder, "list.txt"), "w") as handle:
handle.write("\n".join(lines) + "\n")

print("→ %d fotogramas, %.1f s" % (len(stamps), total))
print("→ %d frames, %.1f s" % (len(stamps), total))
38 changes: 19 additions & 19 deletions Sources/LoadoutApp/SelfCheck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ enum SelfCheck {
}

// Create
model.createSkill(name: "auto-teste", description: "Created by the self-check.")
check("creates a skill", model.items.contains { $0.name == "auto-teste" })
check("selects it", model.selected?.name == "auto-teste")
check("loads the file in the editor", model.draft.contains("name: auto-teste"))
model.createSkill(name: "self-check-skill", description: "Created by the self-check.")
check("creates a skill", model.items.contains { $0.name == "self-check-skill" })
check("selects it", model.selected?.name == "self-check-skill")
check("loads the file in the editor", model.draft.contains("name: self-check-skill"))

// Edit and save
model.draft = "---\nname: auto-teste\ndescription: New résumé.\n---\n\nNew body."
model.draft = "---\nname: self-check-skill\ndescription: New résumé.\n---\n\nNew body."
model.isDirty = true
model.save()
check("saves the edit", model.selected?.description == "New résumé.")
Expand All @@ -70,21 +70,21 @@ enum SelfCheck {
model.loadDraft()

// A mutation elsewhere must not wipe an edit in progress either.
model.draft = "---\nname: auto-teste\ndescription: Half edited.\n---\n\nBody."
model.draft = "---\nname: self-check-skill\ndescription: Half edited.\n---\n\nBody."
model.isDirty = true
model.reload()
check("a reload keeps the dirty draft on the same item", model.draft.contains("Half edited"))
model.loadDraft()

// Disable and enable
let created = model.items.first { $0.name == "auto-teste" }!
let created = model.items.first { $0.name == "self-check-skill" }!
let selectionBeforeToggle = model.selectedID
model.toggle(created)
check("toggling keeps the selection", model.selectedID == selectionBeforeToggle)
check(
"disabling moves it to skills-off",
FileManager.default.fileExists(
atPath: paths.skillsOff.appendingPathComponent("auto-teste/SKILL.md").path
atPath: paths.skillsOff.appendingPathComponent("self-check-skill/SKILL.md").path
)
)
model.selection = .skills
Expand All @@ -93,41 +93,41 @@ enum SelfCheck {

// Switching on is a question now: which assistants load it again is the one thing the
// app cannot decide for someone, so the switch opens a sheet instead of guessing.
let parked = model.items.first { $0.name == "auto-teste" }!
let parked = model.items.first { $0.name == "self-check-skill" }!
model.toggle(parked)
check("enabling asks where it goes", model.restoring?.item.name == "auto-teste")
check("enabling asks where it goes", model.restoring?.item.name == "self-check-skill")
check("the sheet proposes what was recorded", model.restoring?.chosen == ["claude"])
check("and says the proposal is remembered", model.restoring?.remembered == true)
model.confirmRestore()
check(
"confirming brings it back",
FileManager.default.fileExists(
atPath: paths.skills.appendingPathComponent("auto-teste/SKILL.md").path
atPath: paths.skills.appendingPathComponent("self-check-skill/SKILL.md").path
)
)

// Commands: made here rather than by hand in the Finder, switched off without deleting,
// and no amber banner about a `name` field they are not supposed to have.
model.selection = .commands
model.createCommand(name: "auto-comando", description: "Feito pelo self-check.")
let madeCommand = model.items.first { $0.kind == .command && $0.name == "auto-comando" }
model.createCommand(name: "self-check-command", description: "Created by the self-check.")
let madeCommand = model.items.first { $0.kind == .command && $0.name == "self-check-command" }
check("creates a command", madeCommand != nil)
check("with no false warning on it", madeCommand?.warning == nil)
check("and no name field in the file", !(model.draft.contains("name:")))
model.toggle(madeCommand!)
check(
"disabling a command moves it next door",
FileManager.default.fileExists(
atPath: paths.claude.appendingPathComponent("commands-off/auto-comando.md").path
atPath: paths.claude.appendingPathComponent("commands-off/self-check-command.md").path
)
)
let parkedCommand = model.items.first { $0.kind == .command && $0.name == "auto-comando" }!
let parkedCommand = model.items.first { $0.kind == .command && $0.name == "self-check-command" }!
check("it stays in the list, switched off", !parkedCommand.enabled)
model.toggle(parkedCommand)
check(
"enabling it asks nothing and puts it back",
model.restoring == nil && FileManager.default.fileExists(
atPath: paths.commands.appendingPathComponent("auto-comando.md").path
atPath: paths.commands.appendingPathComponent("self-check-command.md").path
)
)
model.selection = .skills
Expand Down Expand Up @@ -430,7 +430,7 @@ enum SelfCheck {
// The conversation's changes reaching the document. No CLI is run here — what is being
// checked is the part Loadout owns: a change accepted in a copy of the folder becomes an
// unsaved edit, and only Save writes it, with the snapshot every write takes.
model.select(model.items.first { $0.name == "auto-teste" }?.id)
model.select(model.items.first { $0.name == "self-check-skill" }?.id)
let skill = model.selected!
let folder = skill.path!.deletingLastPathComponent()
let workspaces = AskWorkspaces(paths: paths)
Expand Down Expand Up @@ -499,9 +499,9 @@ enum SelfCheck {
model.showsAskPanel = false

// Delete
model.select(model.items.first { $0.name == "auto-teste" }?.id)
model.select(model.items.first { $0.name == "self-check-skill" }?.id)
model.deleteSelected()
check("deleting removes it from the list", !model.items.contains { $0.name == "auto-teste" })
check("deleting removes it from the list", !model.items.contains { $0.name == "self-check-skill" })

print(failures.isEmpty
? "\nAll good: \(total) checks passed."
Expand Down
2 changes: 1 addition & 1 deletion Sources/LoadoutCore/UsageIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ public final class UsageIndex: @unchecked Sendable {
}

/// Attaches usage to a scanned inventory. Items never seen keep `Usage.none`, which is what
/// makes "nunca usada" visible in the list.
/// makes "never used" visible in the list.
public func annotate(_ items: [Item], assistants: Set<String>? = nil) -> [Item] {
var byKind: [ItemKind: [String: Usage]] = [:]
for kind in ItemKind.allCases { byKind[kind] = usage(kind: kind, assistants: assistants) }
Expand Down