Skip to content

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 07 Sep 10:13
· 122 commits to main since this release
194dd2b

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 no pluginst.inf saying 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.quarantine is 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 to plugins.ini as two entries and read back as neither. An existing
    plugins.ini is rewritten once, and only when there are plugins to rewrite it toward β€” an
    empty discovery leaves it alone. PCPluginVersion is read too, so the plugin manager shows it
    and an install can say "1.0.0 β†’ 1.1.0" instead of nothing.

  • ReadEntryData and PC_CAP_RANDOM_ACCESS, both optional. A packer plugin could only ever
    serve a file by being wound forward to it β€” ProcessFile acts on whatever ReadHeaderEx last
    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\folder was 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/share was worse, because standardizingPath collapses 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: Hello appears 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/zsh of 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. PCPluginAPIVersion and
    PcGetApiVersion are checked against PC_API_MIN_SUPPORTED … PC_API_VERSION rather 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.

  • PCPluginMinHostVersion does 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.

  • PCPluginType accepts wcx/wfx/wlx/wdx. The architecture guide has said so since it
    was written; only the pluginst.inf path 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 its Package.swift in 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 volume notes.txt β†’ Notes.txt found the file being renamed sitting on its own
    target and refused it. The question is now an lstat comparing 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 says could 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 with ls -led and writes it back by clearing the list (chmod -N) and adding
    each row. The read was run(…) ?? "", 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 -led and chmod are 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 into BoundedProcess so 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 an ssh session inside the terminal reports the remote directory and
    steering the local panel there would be quietly wrong. That check asked
    ProcessInfo.processInfo.hostName, which is NSHost.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 said name: 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. A VFSError is 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. isInArchive is defined as
    !(fs is LocalFS), so every mount arrived in the archive branch, found no zip path and returned
    without a word β€” and PfxRenMov, the plugin rename operation, documented and tested, had no route
    from the keyboard at all. The rename itself had been waiting in performRenames, 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 own guard 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
    layout HexFormatter draws, 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 through NSWorkspace.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\c became a volume whose root is c, 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, via NetFSMountURLSync β€” 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 reports isEjectable == false for 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 as Esc l. Measured rather than
    guessed: with the old default Option+L sends ESC l, Option+7 sends ESC 7 instead 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.