AppifyHost recipe

Create a small Mac app around one local tool.

AppifyHost is the shared native runtime in source/AppifyHost. Your app supplies a macOS bundle, an Info.plist contract, a document type, and an app-local command that opens a loopback or local file URL when it is ready.

Minimum contract

  1. Declare the document extension and UTType.
  2. Configure the AppifyHost dictionary.
  3. Bundle a server command under Contents/Resources/AppServer.
  4. Print APPIFY_HOST_OPEN_URL=... from stdout.

Bundle shape

Start with a normal app bundle.

In this repo, root apps keep a development launcher at Contents/MacOS/main.sh. The launcher finds Scripts/appify-host-launcher.sh, sets APPIFY_HOST_BUNDLE_PATH, and execs the built host binary from bin/appify-host-$arch.

For a standalone copy, Scripts/eject-app.sh replaces that launcher with Contents/MacOS/appify-host and rewrites CFBundleExecutable.

MyTool.app/
  Contents/
    Info.plist
    MacOS/main.sh
    Resources/AppServer/main.sh
    Resources/AppServer/your-server-files

Info.plist

The plist is the app contract.

AppifyHost reads Contents/Info.plist, requires an AppifyHost dictionary, and requires at least one document filename extension from CFBundleDocumentTypes or matching UTType declarations. Keep the document type focused: one app should own one clear class of local document.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>AppifyHost</key>
  <dict>
    <key>DocumentKindEnvironmentValue</key>
    <string>com.example.mytool.project</string>
    <key>DocumentMode</key>
    <string>contentPackage</string>
    <key>LogName</key>
    <string>MyTool</string>
    <key>ServerExecutable</key>
    <string>main.sh</string>
    <key>ServerInstallDirectory</key>
    <string>Contents/Resources/AppServer</string>
    <key>ServerArguments</key>
    <array>
      <string>--document={documentPath}</string>
    </array>
    <key>EnvironmentVariables</key>
    <dict>
      <key>MYTOOL_WORKSPACE</key>
      <string>{workingDirectory}</string>
    </dict>
    <key>StartupTimeoutSeconds</key>
    <integer>20</integer>
    <key>WebViewDataStore</key>
    <string>persistent</string>
    <key>WindowContentSizing</key>
    <string>automatic</string>
    <key>WindowTitlePrefix</key>
    <string>MyTool</string>
  </dict>

  <key>CFBundleDisplayName</key>
  <string>MyTool</string>
  <key>CFBundleExecutable</key>
  <string>main.sh</string>
  <key>CFBundleIdentifier</key>
  <string>com.example.mytool</string>
  <key>CFBundleName</key>
  <string>MyTool</string>
  <key>CFBundlePackageType</key>
  <string>APPL</string>
  <key>LSMinimumSystemVersion</key>
  <string>14.0</string>
  <key>NSHighResolutionCapable</key>
  <true/>

  <key>CFBundleDocumentTypes</key>
  <array>
    <dict>
      <key>CFBundleTypeExtensions</key>
      <array>
        <string>mytool</string>
      </array>
      <key>CFBundleTypeName</key>
      <string>MyTool Project</string>
      <key>CFBundleTypeRole</key>
      <string>Editor</string>
      <key>LSHandlerRank</key>
      <string>Owner</string>
      <key>LSItemContentTypes</key>
      <array>
        <string>com.example.mytool.project</string>
      </array>
      <key>LSTypeIsPackage</key>
      <true/>
      <key>NSDocumentClass</key>
      <string>AppifyHostDocument</string>
    </dict>
  </array>

  <key>UTExportedTypeDeclarations</key>
  <array>
    <dict>
      <key>UTTypeIdentifier</key>
      <string>com.example.mytool.project</string>
      <key>UTTypeDescription</key>
      <string>MyTool Project</string>
      <key>UTTypeConformsTo</key>
      <array>
        <string>com.apple.package</string>
        <string>public.directory</string>
      </array>
      <key>UTTypeTagSpecification</key>
      <dict>
        <key>public.filename-extension</key>
        <array>
          <string>mytool</string>
        </array>
      </dict>
    </dict>
  </array>
</dict>
</plist>

Launch path

The repo launcher is intentionally thin.

Use this shape for checked-in root apps while iterating inside this repo. It keeps the built AppifyHost artifact out of every app bundle and makes a stale host binary obvious.

#!/usr/bin/env bash
set -euo pipefail

APP="$(cd "$(dirname "$0")/../.." && pwd)"
cursor="$APP"
while [[ "$cursor" != "/" ]]; do
  launcher="$cursor/Scripts/appify-host-launcher.sh"
  if [[ -x "$launcher" ]]; then
    exec "$launcher" "$APP" "$@"
  fi
  cursor="$(dirname "$cursor")"
done

printf 'Cannot find Appify UI repo launcher for %s. Use Scripts/eject-app.sh for a standalone app.\n' "$APP" >&2
exit 1

Server command

Your app-local server owns the actual product behavior.

AppifyHost starts the configured executable with Contents/Resources/AppServer as the current directory. The server may open a loopback HTTP(S) URL or a local file URL under the document or app bundle. It must print exactly one ready line before the startup timeout.

#!/usr/bin/env bash
set -euo pipefail

DOCUMENT_PATH="$APPIFY_HOST_DOCUMENT_PATH"
SERVER_DIR="$APPIFY_HOST_SERVER_DIR"

if [[ -z "$DOCUMENT_PATH" || -z "$SERVER_DIR" ]]; then
  printf 'AppifyHost document and server paths are required.\n' >&2
  exit 1
fi

cd "$SERVER_DIR"
./server --document "$DOCUMENT_PATH" &
server_pid="$!"

# Print the real URL only after the app is ready to load.
printf 'APPIFY_HOST_OPEN_URL=http://127.0.0.1:8787/\n'

wait "$server_pid"

Document modes

Pick the storage shape before writing UI.

contentPackage

Shape
A document package folder.
Working directory
The package itself.
Use when
Project-style apps where the document owns assets, previews, caches, or metadata.

contentPackageOrFile

Shape
A file or a folder with the same extension.
Working directory
The non-empty folder itself, otherwise the parent folder.
Use when
Dual-mode apps such as Web.app that can open package folders and single marker files.

folderMarker

Shape
A marker folder.
Working directory
The marker folder's parent.
Use when
Repository or workspace hosts where the visible document points at a surrounding folder.

fileDocument

Shape
A regular file.
Working directory
The file's parent folder.
Use when
Data viewers and editors for `.csv`, `.jsonl`, `.sqlite`, logs, and similar files.

Browser contract

The page can cooperate with native save and sizing.

AppifyHost can ask the loaded page whether it is dirty, call a save hook before native save completes, and measure preferred content size. Most static pages need none of this. Editors should implement the hooks explicitly.

<script>
  let dirty = false;

  window.AppifyHost = window.AppifyHost || {};
  window.AppifyHost.isDirty = () => dirty;
  window.AppifyHost.save = async () => {
    await persistDocumentState();
    dirty = false;
    return true;
  };

  window.AppifyHost.preferredContentSize = () => ({
    width: document.documentElement.scrollWidth,
    height: document.documentElement.scrollHeight,
  });
</script>

Validation checklist

Fail fast before you polish the shell.

Plist

  • plutil -lint MyTool.app/Contents/Info.plist passes.
  • The document extension appears in both CFBundleDocumentTypes and the UTType declaration.
  • ServerExecutable is a relative path token, not an absolute path.

Runtime

  • Contents/Resources/AppServer/main.sh is executable.
  • The server prints APPIFY_HOST_OPEN_URL= only after it can serve the page.
  • The ready URL is loopback HTTP(S) or a local file URL under the document or app bundle.

Repo flow

  • Scripts/build-host-artifact.sh is current for the machine architecture.
  • Scripts/verify-root-apps.sh still accepts the app shape.
  • Scripts/eject-app.sh can create a standalone app when needed.