12 KiB
src/shell/package — package filesystem + dialog seam
Scope
The filesystem and dialog acts behind bank-package export/import: streaming package
file I/O plus the file-status and exclusive-create acts (package_io), the
UTF-8 path conversion every one of them goes through (package_path), the landed-file
journal and its rollback delete (package_rollback), and the two file pickers
(package_pickers). Those are bytes-only — the package format (magic, manifest, entry
layout) is core/package's business. Beside them sit both promptless verbs:
export_bank whole, and the import split so its decisions stay testable —
import_landing (REAPER-free) decides and writes, while import_bank owns the only
REAPER project state this directory touches (the ext-state persist, the undo block,
the generation bump). The export direction touches none of it: an export writes no ext
state, opens no undo point and never bumps the generation, and what prompting it needs
belongs to its skin (shell/actions/package_export_action), not this seam.
Invariants
- Paths cross this seam as UTF-8 narrow strings and are converted through
utf8Path()before ANY filesystem call. This is not decoration: on Windowsstd::filesystemdecodes a narrow path through the runtime ANSI code page (measuredGetACP() == 1252), so a barefs::path(std::string)turnscafé.rsbankintocafé.rsbankor fails to open it. Every path a verb hands in or gets back — includinglistFolderFileNames' results, which go throughpathToUtf8()and neverstring()— is UTF-8.core/util/file_byteshas the un-converted shape, which is whyreadFilePayloadreads through this module's ownPackageFileReaderinstead. - Atomic package write, to the limit of a rename. A package accumulates in a
.rsbanktmpsibling in the destination directory and reaches the destination only throughcommit()'s rename (the mono-collapse temp+rename precedent). A failed, aborted, or abandoned write leaves the destination absent or holding its prior contents. This is process-crash atomic, NOT power-loss atomic:commit()flushes and closes but does notfsync/FlushFileBuffers, so a power cut can still leave a renamed-but-unflushed file. Deliberate — an fsync over a whole sample bank is a real stall, and the failure this design targets is a refused or interrupted export. - Streaming, both ways — at most ONE entry's payload in memory. Writes append
one payload at a time; reads seek and materialize one range at a time. The claim
is structural, not aspirational: every payload crosses this seam as a move-only
PayloadBuffer, andPayloadBuffer::alive()is the seam counter the tests assert against. There is no read-whole-package or write-whole-package entry point; do not add one. - An empty
PayloadBufferis a failure signal, never an entry. It is the seam's one "nothing to work with" branch, so bothPackageFileWriter::appendPayloadandwriteFileExclusiverefuse it — appending it would let a verb commit framing that claims bytes nobody wrote.appendRaw(ptr, 0)stays tolerated: framing has legitimate zero-length edges. - No overwrite of a bank-folder file, ever — and the create is the check.
writeLandedFilelands throughwriteFileExclusive(O_EXCL/_O_EXCL), so the refusal of an occupied path is one atomic act rather than anexists()a concurrent writer could win the race against. Collision handling (auto-rename) remains the import plan's job upstream. The package writer itself DOES replace an existing destination — the export save dialog's own overwrite confirm is the consent — and that asymmetry is deliberate. Closed, both halves.pickPackageSavePath's own.rsbankre-append (see its Gotcha below) can turn a confirmed pathXinto a write targetX.rsbankthat the dialog never asked about, soexportBankre-checksfileStatus()on the path actually handed toPackageFileWriter— after any extension append — and refusesRefusedDestinationExistsuntil the caller setsallowOverwrite. The caller does not always re-prompt to get there:pickPackageSavePathreports whether it appended (outAppended), andpackage_export_actionpre-grantsallowOverwritewhenever it did NOT — an unappended path is exactly what the dialog's own confirm already covered, so asking again would be a second prompt for the same consent. Only an appended path, one the dialog never saw, still costs the verb's own confirm naming that exact path. - The rollback delete is prune's ONE carve-out, and only HALF of it is structural.
The citation and the full discriminator live at
package_rollback.cpp's header. "Did this call create it" is structural: only exclusively-created paths are recorded, resolved absolute at record time so a later CWD change cannot re-aim the delete. "Did anything ever reference it" is a contract the import verb must honour: it MUST callmarkIndexCommitted()only AFTER the index write has returned success — calling it before, then having that write fail, strands the landed files with no index entry and a journal that now refuses to roll them back — after whichrollback()refuses andwriteLandedFilerefuses. (Destroying an armed journal without calling either does NOT roll it back — seeLandedFileJournal's own doc comment.)import_bankhonours it: it callsmarkIndexCommitted()only afterpersistBankOphas returned. - Integrity is proven before the first byte lands, not undone after.
landPackagehashes every declared payload against the manifest and only then creates the bank folder, so a damaged package costs no rollback at all and cannot leave debris behind a rollback that itself failed. The second read of each payload is deliberate on a once-per-gesture path — do not fold it into one hash-and-write pass. - Both pickers ride
GetUserFileName— mode 1 for import, mode 0 for export. There is no platform split and no fallback:main.cppdefinesREAPERAPI_IMPLEMENTwithoutREAPERAPI_MINIMALand aborts the extension load if any single name fails to resolve, so a build that can load us cannot lack it.
Modules
package_path— header-only; the ONE UTF-8-narrow →fs::pathconversion, so the encoding contract has a single enforcement point.package_io— every filesystem act the verbs need:PayloadBuffer(move-only payload + thealive()seam counter),PackageFileWriter(append-only temp+atomic-rename writer),PackageFileReader(seek-and-read one range per call, range-checked against the real file size),readFilePayload(one source file as one entry's payload),fileStatus(Present/Absent/Unreadable — export's refusal message must distinguish the last two, and an empty payload cannot),writeFileExclusive(exclusive create + write, self-cleaning on a partial write), andlistFolderFileNames(bare UTF-8 names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW.package_rollback—LandedFileJournal:writeLandedFile(exclusive-create land, path resolved absolute, recorded on success only),markIndexCommitted(disarms the journal), androllback(deletes exactly the recorded set, hard unlink, tolerating a vanished file; refuses once disarmed). REAPER-free; tested without a DAW.package_pickers—pickPackageForImportandpickPackageSavePath, bothGetUserFileName;pickPackageSavePathalso reports whether it appended.rsbank(outAppended), the signalpackage_export_actionuses to skip a redundant overwrite confirm. Neither picker can be exercised in a unit test.export_bank— the promptless export verb, in three composable public steps:surveyBankExport(the read-only plan, report-before-acting),digestSources(measures each entry's length +hashBytesdigest, one payload at a time), andwritePackageFile(prefix, then each payload re-read and re-verified against that digest before it is appended, then commit).exportBankcomposes the three and gates on the plan verdict, the incomplete confirm and the destination confirm. The session arrives const:saveToActiveProject,bumpBankGenerationandwriteAssignmentRequestare the session's only non-const acts, so a const session cannot reach them and "an export writes no ext state, opens no undo point and never bumps the generation" holds by the type rather than by memory (pruneReclaim, the sole file-deletion path, is const too and sits outside this claim). Reads the session through inline accessors only, which is why its tests link and run without a DAW.import_landing— the import's two halves that decide anything:landPackage(decode, plan, verify EVERY payload's digest, then land through the journal) andapplyImportedBank(the new bank's entries plus a birth record per landed file, in one straight-line block). REAPER-free deliberately — all-or-nothing, integrity and birth-record behaviour are assertable without a DAW.import_bank— the promptless import verb over a liveReaSamplerSession: the project directory, the minted bank id, therecordCreatedwriter, and the one undo-batched persist. REAPER-facing, so it compiles into the extension module rather than into a library with a test target.
package_round_trip_tests is declared here with no library of its own: it drives the
same frozen corpus (tests/fixtures/package_compat/) through both verbs, which is where
export → import → export payload identity is proven.
Gotchas
- A crash mid-export strands the
.rsbanktmpsibling. It is not a.rsbank(no picker filter matches it), and a later export to the same destination truncates it. A crash mid-import strands a partial bank file under its real name instead — the land is a direct exclusive create, not temp+rename. Either way the debris was never recorded in the tracking ledger, so prune sees a foreign file (not owned, never an orphan) and will not touch it; removal is by hand.[verify — DAW]whether the import verb should pre-clean stale debris when it lands. - The picker filter and mode arguments are spelled to
GetUserFileName's documented pair format but are[verify — DAW]on all three platforms — neither picker is exercised outside a live REAPER session.GetUserFileNamealso takes no owner window, so dialog parenting is REAPER's to do; the superseded Win32 path passedGetMainHwnd()explicitly. Also[verify — DAW]: whether mode 0's picker appends an extension fromextension_listwhen the user omits one —pickPackageSavePathre-appends.rsbankitself so the returned path is correct regardless of how that lands (the superseded Win32 path hadofn.lpstrDefExtfor this;GetUserFileNamehas no equivalent parameter). The re-append is suffix-blind: it only skips when the path already ends in.rsbank, so a path carrying a DIFFERENT extension gets.rsbankappended after it (mybank.bak→mybank.bak.rsbank), unlike the supersededofn.lpstrDefExt, which appended only when the path had no extension at all. Defensible for a format-locked export, but a real divergence from the old picker's behavior — whoever tests the picker under[verify — DAW]should expect the double-extension result on a path that already has one. pickPackageSavePath'ssuggestedPathdoubles as the dialog's starting directory when it is a full path. The verbs should seed it from the project directory — passing a bare name leaves the dialog on REAPER's process working directory, which is its install or resource path.readRange(_, 0)returns an empty buffer — indistinguishable from failure, by design (the one "nothing to work with" branch). Cross-track contract, not a local rule: a genuinely zero-length entry cannot round-trip through this seam, socore/package's format layer must not emit one.