v0.9.0
Before you double-click
This build is not signed or notarized, so macOS blocks the first launch. Allow it once:
- macOS 15 Sequoia and later β double-click, dismiss the warning, then open
System Settings βΈ Privacy & Security and click Open Anyway. Apple removed the
Control-click shortcut for unsigned apps in macOS 15, so right-clicking does not help.- macOS 13β14 β right-click the app βΈ Open, then confirm.
Only the first launch asks.
Plugins stopped being something only this repository can write.
A plugin now arrives as a file you double-click. That sounds small and was not: a third party could
not depend on the SDK at all, because its package manifest sits in a subdirectory and SwiftPM looks
for one at a repository root β so the dependency line this project's own documentation had shown
from the start had never resolved for anybody. The API version was checked for equality, in two
places, so the first time that number moved every existing plugin would have stopped loading in the
same instant. A plugin's identity was its display name, so renaming one lost the user's setting and
two sharing a title shared one switch. PCPluginMinHostVersion had been read and compared against
nothing since the day it was added. And installing meant picking a .zip from a file chooser, after
which somebody else's code was running in this process with your whole disk in reach, having told
you neither its name nor what it was about to take over.
All of that is answered, and two companion repositories now exist to prove it rather than describe
it: the SDK as a package you can actually depend
on β with pcplug-validate, which runs this app's own admission checks against a built plugin
before a user is the one who finds out β and a
worked example that reads ISO 9660, Joliet, Rock
Ridge, UDF and El Torito disc images. That example is not a demo: .iso was already browsable here
through bsdtar, one subprocess per file, every file showing the container's timestamp rather
than its own, and UDF unreadable altogether. Nothing on this side changed to let a plugin take that over.
The rest of the release is the editor and the viewer catching up with each other β per-line notes,
Save As, Print, Next/Previous Mark, the strings in a binary in four encodings at once β and
seventeen fixes, several of which were things quietly losing data or hanging the window: a file the
editor could not read opened as an empty one and saving wrote that over it, an unreadable ACL was
replaced by an empty one, and a slow DNS lookup froze the window for half a minute whenever a
terminal reported where it was.
Added
-
Plugins are installable from a package, and you are told what is in it first. A plugin now
arrives as a.pcplugβ a zip with its own extension β and there are four ways to install one,
all ending at the same confirmation: double-click it in the Finder, press Enter on it in a panel
(this is a file manager; the file is usually already in front of you), drop it on the plugin
window, or use Configuration βΈ Plugins βΈ Installβ¦. Until now the only route was a file chooser
behind a button, and the next thing that happened after picking a file was somebody else's code
running in this process with your whole disk in reach. The dialog names the plugin, its version,
its identifier, its type and which file types it will take over β a packer plugin becomes the
app's reader for every extension it claims, and that was invisible.Two things the install refuses rather than repairs: a package whose entries resolve outside it (a
../entry, or a symlink the later copy would follow out of the tree), and a package holding more
than one plugin with nopluginst.infsaying which β previously whichever the file system listed
first was installed and the rest dropped without a word. And one it does on your say-so:
com.apple.quarantineis cleared from the installed bundle after you confirm, which is what
Gatekeeper otherwise refuses to load once the app is signed, with an error nobody could act on. -
Plugins have a stable identity and a version.
PCPluginIdentifier(reverse-DNS) is now the
key everything about a plugin is stored under β the on/off switch, the file associations, the
crash guard's quarantine. It used to be the display name, which meant renaming a plugin
silently lost the user's setting, two plugins sharing a title shared one switch, and a name with
a;in it was written toplugins.inias two entries and read back as neither. An existing
plugins.iniis rewritten once, and only when there are plugins to rewrite it toward β an
empty discovery leaves it alone.PCPluginVersionis read too, so the plugin manager shows it
and an install can say "1.0.0 β 1.1.0" instead of nothing. -
ReadEntryDataandPC_CAP_RANDOM_ACCESS, both optional. A packer plugin could only ever
serve a file by being wound forward to it βProcessFileacts on whateverReadHeaderExlast
returned β so reaching the five-thousandth member meant reading past the four thousand nine
hundred and ninety-nine in front of it, and the whole member had to land in a temporary file
before one byte of it could be shown. A plugin that can seek now says so and is asked directly.
Both are additive: a plugin that exports neither behaves exactly as before. -
Per-line notes in the editor. The viewer has had them since F-379; the editor could neither
show nor write one. They appear in its marks panel as their own group, with the line's text as the
row and a click that puts the caret there β which is where the viewer has always shown them, and
deliberately not in the gutter: that holds one set of annotations with one title and one click
handler, and a plugin's per-line annotations already occupy it, so a note and a blame line would
have had to evict each other. -
The editor can Save As and Print. The read-only viewer had both and the editable window had
neither, which is the wrong way round: you could view a file and print it, but not print the one
you were editing, nor put your changes under a different name. Save As here is a move, not the
viewer's export β the window goes on editing the new file, so the next βS lands there. The
alternative would be a trap: save under a new name, keep typing, and quietly write to the file you
thought you had left behind. -
Next and Previous Mark in the viewer. Mark All has always highlighted every occurrence there;
reaching them meant clicking rows in the marks panel, because the two commands that step through
them existed only in the editor. -
A Windows path pasted into Go to Folder now goes there.
\\server\share\folderwas only ever
understood by Connect to Server; typed into Go to Folder or the path bar it went down the
relative branch, came back as<current folder>/\\server\share\folder, and was reported as not
a folder β and//server/sharewas worse, becausestandardizingPathcollapses the double slash
and produced/server/share, a plausible-looking local path nobody had asked for. All three entry
points now route a network address through the same code, and the path bar says which of the many
reasons applied instead of only beeping. If the share is already mounted it is a plain directory
change: the mount table is reverse-mapped first, so a path out of a mail does not raise a system
dialog for a volume already sitting in the drive bar. -
The strings in a binary, in four encodings at once. The hex representation β in the viewer and
in the hex editor β has a Strings panel: every readable run of text in the file, with the offset it
sits at and how it was decoded, and a click puts the hex view on those bytes and selects them.
ASCII, UTF-8 and UTF-16 in both byte orders are read over the same bytes in the same pass, at both
alignments, so a Windows executable's wide strings and its plain ones are in one list instead of
needing a separate run each. Where two readings claim the same bytes they are reconciled rather
than both listed:Helloappears once, and not also as the pair of ideographs those bytes spell
read two at a time. The harder half was that printable and meaningful are not the same thing β
three quarters of all byte values are printable Latin-1, and every byte pair that is not a
surrogate is printable UTF-16, so the five readings find 65,000 runs in/bin/zshof which 4,400
are strings. Runs that are printable without reading like text are dropped, which leaves 8,000 and
nearly all of them real; Latin-1, the one reading no rule separates from machine code, is offered
but off. A minimum length, a filter that does not re-read the file, and a switch to see what the
filter hides are all in the panel. -
Pictures inside an archive get real thumbnails. Gallery view showed a generic document icon for
everything inside a ZIP, because a thumbnail needs a file and a member of an archive is not one
yet. Each visible one is now unpacked for it β which is affordable only because of the other half
of this change.
Changed
-
The plugin API version is a window, not an equality.
PCPluginAPIVersionand
PcGetApiVersionare checked againstPC_API_MIN_SUPPORTED β¦ PC_API_VERSIONrather than against
one number. The old check meant the first time that number moved, every third-party plugin in
existence would stop loading in the same instant, in the manifest check and the runtime handshake
at once, with no release in between where both worked. The two ends are reported differently
because they need different actions: below the window the plugin is too old, above it the app is,
and the message says which. -
PCPluginMinHostVersiondoes something. It has been parsed and then compared against nothing
since it was added β there was no host version at runtime to compare it to. A"1.2.3"string
is now the minimum app version and is enforced before the plugin's binary is opened; a bare
integer keeps its old meaning as a minimum API level, so every shipped manifest is unaffected. -
PCPluginTypeacceptswcx/wfx/wlx/wdx. The architecture guide has said so since it
was written; only thepluginst.infpath honoured it, and a ported Total Commander plugin
declaring its original type in the manifest was rejected as an unknown type. -
A packer plugin is no longer assumed to be slow. The host hard-coded "one full pass per
member" for every plugin-backed archive, which is right for a format read through a helper
process and wrong for one that is an index and a seek β and during a background search it demoted
such a plugin to a fallback behind whatever else could open the file. The plugin now reports it. -
The SDK is a package you can actually depend on.
PluginSDK/had itsPackage.swiftin a
subdirectory of this repository, so the.package(url: β¦)its own README documented has never
resolved for anyone; a third party had to clone the whole application. It is now published to
its own repository, along with the two Swift helpers (PluginLoc,PluginTheme) that shipped
only in-tree β which is why localising a Swift plugin from outside was not possible either. -
Gallery view stopped reading the whole folder. It asked the system for a thumbnail of every
file in the directory, and it did so again for every batch of a listing as it arrived β up to ten
times a second. A folder of two thousand files therefore cost two thousand thumbnail requests, over
and over. Only the cells actually on screen are made now, and the results are kept, so scrolling
back costs nothing and returning to a folder costs nothing. Measured on 300 images: twelve requests
on the first visit, none at all on the second.
Fixed
-
A rename onto a name that is already taken asks instead of refusing. Both ways in β the in-cell
editor and the Shift+F6 dialog β stopped at an "already exists" alert whose only way out was
retyping the name, so the one thing being attempted could not be done at all. The question is now
the one a copy already asks: the same conflict dialog, the same wording, the same side-by-side
comparison of the two files β a rename is a move within one folder, and meeting a second,
unfamiliar alert for it would be the odd thing. Asked about a single item it offers Overwrite,
Auto-Rename, Skip and Cancel; the "β¦All" buttons and Append are left out, having
nothing to apply to. Auto-Rename answers the conflict by choosing another name and the rename goes
there, asking again if that one is taken too. An overwrite replaces the target, exactly as F5 and
F6 do β it does not go to the Trash. Two mistakes in the old check went with it. It asked
fileExists, which follows a symlink, so a dangling link read as absent while still holding the
name a rename has to land on; and on a
case-insensitive volumenotes.txtβNotes.txtfound the file being renamed sitting on its own
target and refused it. The question is now anlstatcomparing device and inode, so a case-only
rename is not a collision at all β and it is asked only on the local disk, because out on a mount
the path names a place that exists nowhere on this Mac. -
A file the editor could not read opened as an empty one β and saving wrote that over it. Both
the text editor and the hex editor loaded with?? Data()/?? [], and an empty document is
exactly what an empty file looks like, so a read that failed was indistinguishable from one that
succeeded on nothing. Nothing on the window said otherwise, and βS then replaced the content with
what was on screen. The plain case is a write-only file (chmod 222): unreadable by anyone,
writable by its owner, so not even the read-only lock appeared β a stalled network mount and an
evicted cloud file end the same way. The status line now sayscould not be read β this is not the file's content, and Save refuses. Save As still works: writing this somewhere else loses nothing. -
An ACL that could not be read was silently replaced by an empty one. The editor reads a path's
access-control list withls -ledand writes it back by clearing the list (chmod -N) and adding
each row. The read wasrun(β¦) ?? "", so a command that did not answer parsed to no entries β
indistinguishable from a file that genuinely has none, and Apply then wrote exactly that over the
list nobody had been able to see. It now says which of the two happened and refuses to write when
the answer never came. -
And it could not hang the window while finding out.
ls -ledandchmodare instant on a disk
and never return on a stalled network mount, and both ran on the main thread about a path picked in
a panel β so opening the ACL editor on a share that had gone away was a beachball with no way out.
Both are bounded now, by the same watchdog the editor's text filters have used since F-356, which
moved intoBoundedProcessso that there is one of it rather than two, with its deadline under
test. A path goes in as an argument and never through a shell, which is also what keeps a file name
containing a quote from being a command. -
The terminal froze the window for half a minute whenever DNS was slow. A shell reports its
working directory with an OSC 7 escape sequence βfile://hostname/pathβ and the plugin checks
the host part, because ansshsession inside the terminal reports the remote directory and
steering the local panel there would be quietly wrong. That check asked
ProcessInfo.processInfo.hostName, which isNSHost.name, which does a blocking reverse DNS
lookup β on the main thread, on every directory change. Where nothing answers it waits out the
resolver: measured at 10.6 s and then 24.4 s, 36 seconds of dead window. Anyone on a VPN,
behind a captive portal, or offline with a search domain set was in the same wait; it stayed
invisible in normal use because a warm resolver answers instantly. It now asks the kernel with
gethostname(2), once, which is also the more correct comparison: that is the name the shell
itself puts in the payload. Found while auditing why the regression suite took 101 minutes β the
same freeze was 87 of them, and the suite now runs in about 40. -
A rename a server refuses now says why. All a mount could report was the name β "1 file(s) were
not renamed: hello.txt" β while the local path has always saidname: reason. For the thing a
server refuses most often, permission, that left the reader with no way to tell whether to try
again, log in with different credentials, or stop. AVFSErroris a developer's value
(permissionDenied(PCVFS.VFSError.Refusal.modeBits)is what interpolating one gives you), so the
cases are turned into sentences β in all nineteen languages. The two refusals EACCES and EPERM are
deliberately one message out here: on a mount the difference is the server's business and naming it
would send the reader after a distinction they cannot act on. -
Shift+F6 did nothing whatsoever on a server or plugin mount.
isInArchiveis defined as
!(fs is LocalFS), so every mount arrived in the archive branch, found no zip path and returned
without a word β andPfxRenMov, the plugin rename operation, documented and tested, had no route
from the keyboard at all. The rename itself had been waiting inperformRenames, which sends a
non-local pair through the panel's own filesystem; only that gate kept it from ever being reached.
A second, identical gate sat behind it and outlived the first fix silently:cursorItemName()
carries its ownguard fs is LocalFS, rightly so β it exists for link creation, and a symlink has
to be made on a disk β so the rename now asks a question that is about the name, not the volume. -
Find Previous is in the viewer's menu. Shift+F3 had always worked β the command is
implemented and the key handler calls it β but the menu never listed it, so it was reachable only
by knowing it was there. -
Copy works on a hex selection, and Go To marks the byte it went to. Two more of the same
asymmetry between the viewer and the hex editor, found by going through the pair deliberately
rather than waiting for them to be reported. βC was greyed out in the hex representation
altogether β the context menu offered four ways to copy a selection and the obvious key did
nothing, which got worse once the gutter became selectable and search hits started being
selected, since both end in a selection whose natural next step is βC. And Go To scrolled to the
row without marking anything, so asking for offset 0x1234 answered "somewhere on these sixteen
bytes"; the hex editor has always put its caret on the byte. -
A search after Format looks in what is on screen, not in the file. Press Format and the view
shows the formatter's output; the search kept reading the file underneath it, so it reported hits
at offsets into bytes nobody was looking at any more. Harmless while a hit was only scrolled to
and approximately placed β and not harmless once hits are selected: searching a minified JSON for
a value after formatting it highlighted a fragment of a different value, having announced that
it had found the one asked for. The same applies to an XPath result list, which is likewise not
the file. Both are searched as what they are now: in memory, so there is no chunking and a
regular expression gets a real match length instead of an assumed one. -
A search hit is shown in the text and code views too, and shown in the right place. The same
gap as in hex, in the two representations that take over past 4 MB: the match was scrolled to and
never highlighted. Underneath it there was a second, quieter fault β the code view worked out
where a byte offset falls by treating it as a character offset, which is right for ASCII and
drifts with every umlaut and every CRLF above the match. Scrolling made that look merely
approximate; a selection built on it points confidently at the wrong characters. Both views now
convert byte offsets to characters exactly, in the encoding the file was decoded with. -
In the hex viewer the text beside the bytes can be selected, and a search hit is shown. Two
reports, one cause each, both invisible to a screenshot. The gutter could not be selected at all:
the view worked out its column positions with arithmetic of its own, a second silent copy of the
layoutHexFormatterdraws, and that copy covered the hex half only β every click in the ASCII
half mapped to no byte. The layout is now one description, held against the rendered row by tests.
And a search scrolled to its hit without highlighting it, which on a screen where every row looks
like every other row leaves the reader to find it by eye; the match is selected now, in both
halves of the row. The hex editor did both correctly all along, which is what made the
difference noticeable. -
Mounting a share no longer opens Finder in front of you, and no longer mounts the wrong thing.
Connecting went throughNSWorkspace.open, which asks Finder to do the mount β so Finder's own
window came up over the app you had asked from. Worse, handed a URL with a path it mounted the
subdirectory as the volume:\\srv\ablage\a\b\cbecame a volume whose root isc, with no
way to go up out of it and a drive chip named after a folder five levels deep. The share alone is
mounted now, viaNetFSMountURLSyncβ which still shows the system's own sign-in sheet, and hands
back where the volume actually landed instead of leaving it to be guessed as/Volumes/<share>,
which is wrong the moment macOS has to append-1. The rest of the path is an ordinary directory
change, so the whole tree above your folder is still there. The mount runs off the main thread,
because it blocks for as long as the server takes to answer β on an unreachable one, the full TCP
timeout. -
A mounted share can be detached from inside the app again. The drive chip's β was greyed out
and the command answered that "network shares and internal disks stay mounted". They do not β that
was the ejectable flag being read literally: the system reportsisEjectable == falsefor smbfs,
correctly, because there is no device to eject. A share is unmounted, which is what Finder's own
β does to it, and that is what happens now; the message no longer claims otherwise. -
A long path is visible in the dialog that asks for it. Every InputDialog was a fixed 420 points
wide and not resizable, leaving 380 for the field β an 83-character UNC path measures 542, so the
interesting end of it was off-screen with no way to widen the window. It is 620 and resizable now.
Underneath that, a text field created in code is a wrapping, non-scrolling one by default, so the
same path laid itself out over three lines inside a 24-point field and had all but the first
clipped away, unreachable even by the caret. Rename and mkdir ask about long names too, and get
this as well. -
The terminal can type
@and~again. On a German keyboard@is Option+L and~is
Option+N, and neither reached the shell: the emulator defaults to treating Option as the Meta key,
which sends Esc followed by the unmodified key β so@arrived asEsc l. Measured rather than
guessed: with the old default Option+L sendsESC l, Option+7 sendsESC 7instead of|, and
Option+Shift+7 the same instead of\; with the new one each sends the character on the key. The
same applied to the brackets and braces on the French, Spanish, Italian, Nordic and Swiss layouts.
Option now types the character, as it does in Terminal, and Meta is a setting under Configuration βΈ
Plugins βΈ Terminal for people who want Alt+B and Alt+F β it takes effect in terminals that are
already open, not only in new ones. -
A window sized for a larger display no longer hangs off the screen. Change a monitor's
resolution, or unplug it, and macOS leaves every window at the size it had β so the main window
could keep a height the new screen does not have, putting the status bar, the function-key row and
the command line below the bottom edge with no way to reach them. The window is now brought back
inside the screen when that happens. Deliberately without forgetting what you chose: the size from
before is put back as soon as there is room for it again, so unplugging a monitor for a minute does
not cost you your layout β and it is that size, not the shrunken one, that is remembered for the
next launch.