mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a3c04f170 | ||
|
|
e8555c7ef9 | ||
|
|
e8772e6699 | ||
|
|
585933c8a1 | ||
|
|
b59052a95c | ||
|
|
36291540e5 | ||
|
|
7eeb54f653 | ||
|
|
42996804bb | ||
|
|
6ddb3ac43e | ||
|
|
92b446973f | ||
|
|
39d4097773 | ||
|
|
d8c47bb6f0 | ||
|
|
d4318554f6 | ||
|
|
df1a447a79 | ||
|
|
bcd5eb9ebc | ||
|
|
cfc93fc97d | ||
|
|
b954a3135d | ||
|
|
9e310a34bb | ||
|
|
fe39274ed7 | ||
|
|
2e15dc2dc9 | ||
|
|
dad0ac86d0 | ||
|
|
efd45cf61e | ||
|
|
742e42d65b | ||
|
|
abcd133b19 | ||
|
|
4fa14306d1 | ||
|
|
acf78dfe29 | ||
|
|
866a971729 | ||
|
|
7ea33b5bfb | ||
|
|
0abeb3c5d4 | ||
|
|
a31f99cb76 | ||
|
|
20365dd02d |
No files matched your search
@@ -0,0 +1,130 @@
|
|||||||
|
name: App Store
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Tag to build, e.g. v0.1.18. Defaults to the latest release."
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
upload:
|
||||||
|
description: "Upload to App Store Connect. Off means build and sign only."
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-26
|
||||||
|
steps:
|
||||||
|
- name: Resolve the tag
|
||||||
|
id: tag
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
TAG=$(gh release view --repo "$REPO" --json tagName --jq .tagName)
|
||||||
|
fi
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Building $TAG for the App Store"
|
||||||
|
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.tag.outputs.tag }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 26
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
|
||||||
|
- uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri -> target
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Provision Google credentials
|
||||||
|
env:
|
||||||
|
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$GOOGLE_CREDENTIALS" ]; then
|
||||||
|
printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json
|
||||||
|
else
|
||||||
|
echo "::error::GOOGLE_CREDENTIALS is not set; an App Store build with placeholder credentials would ship a broken backup feature."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Import the App Store certificates
|
||||||
|
env:
|
||||||
|
APP_CERT: ${{ secrets.MAS_APP_CERTIFICATE }}
|
||||||
|
APP_CERT_PASSWORD: ${{ secrets.MAS_APP_CERTIFICATE_PASSWORD }}
|
||||||
|
INSTALLER_CERT: ${{ secrets.MAS_INSTALLER_CERTIFICATE }}
|
||||||
|
INSTALLER_CERT_PASSWORD: ${{ secrets.MAS_INSTALLER_CERTIFICATE_PASSWORD }}
|
||||||
|
PROFILE: ${{ secrets.MAS_PROVISION_PROFILE }}
|
||||||
|
run: |
|
||||||
|
keychain="$RUNNER_TEMP/appstore.keychain-db"
|
||||||
|
password=$(uuidgen)
|
||||||
|
security create-keychain -p "$password" "$keychain"
|
||||||
|
security set-keychain-settings -lut 3600 "$keychain"
|
||||||
|
security unlock-keychain -p "$password" "$keychain"
|
||||||
|
|
||||||
|
import_p12() {
|
||||||
|
printf '%s' "$1" | base64 --decode > "$RUNNER_TEMP/cert.p12"
|
||||||
|
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$2" \
|
||||||
|
-T /usr/bin/codesign -T /usr/bin/productbuild
|
||||||
|
rm -f "$RUNNER_TEMP/cert.p12"
|
||||||
|
}
|
||||||
|
import_p12 "$APP_CERT" "$APP_CERT_PASSWORD"
|
||||||
|
import_p12 "$INSTALLER_CERT" "$INSTALLER_CERT_PASSWORD"
|
||||||
|
|
||||||
|
# Without this, codesign on a headless runner blocks on a keychain prompt nobody can
|
||||||
|
# answer and the job hangs until it times out.
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -k "$password" "$keychain" > /dev/null
|
||||||
|
security list-keychains -d user -s "$keychain" login.keychain-db
|
||||||
|
|
||||||
|
printf '%s' "$PROFILE" | base64 --decode > "$RUNNER_TEMP/margin.provisionprofile"
|
||||||
|
security find-identity -v "$keychain"
|
||||||
|
|
||||||
|
- name: Build the sandboxed bundle
|
||||||
|
run: |
|
||||||
|
# No APPLE_SIGNING_IDENTITY here on purpose: mas-package.sh signs, because the
|
||||||
|
# provisioning profile has to be inside the bundle before codesign runs.
|
||||||
|
pnpm tauri build --target universal-apple-darwin --config src-tauri/tauri.appstore.conf.json --bundles app
|
||||||
|
|
||||||
|
- name: Sign, package and upload
|
||||||
|
env:
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
MAS_APP_IDENTITY: ${{ secrets.MAS_APP_IDENTITY }}
|
||||||
|
MAS_INSTALLER_IDENTITY: ${{ secrets.MAS_INSTALLER_IDENTITY }}
|
||||||
|
MAS_PROVISION_PROFILE: ${{ runner.temp }}/margin.provisionprofile
|
||||||
|
MAS_BUILD_NUMBER: ${{ github.run_number }}
|
||||||
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
|
||||||
|
MAS_UPLOAD: ${{ inputs.upload && '1' || '' }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/private_keys
|
||||||
|
printf '%s' "$APPLE_API_KEY_P8" | base64 --decode > ~/private_keys/AuthKey_$APPLE_API_KEY_ID.p8
|
||||||
|
chmod 600 ~/private_keys/AuthKey_$APPLE_API_KEY_ID.p8
|
||||||
|
./scripts/mas-package.sh
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: margin-appstore-pkg
|
||||||
|
path: target-mas/*.pkg
|
||||||
|
if-no-files-found: warn
|
||||||
@@ -44,6 +44,12 @@ jobs:
|
|||||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > "$tmp" && mv "$tmp" src-tauri/tauri.conf.json
|
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > "$tmp" && mv "$tmp" src-tauri/tauri.conf.json
|
||||||
jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json
|
jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json
|
||||||
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml
|
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||||
|
# Cargo.lock records margin-app's own version, so bumping only Cargo.toml leaves the
|
||||||
|
# lock a release behind and the next build rewrites it under whoever checked it out.
|
||||||
|
awk -v v="$VERSION" '
|
||||||
|
/^name = "margin-app"$/ { print; getline; sub(/^version = ".*"/, "version = \"" v "\""); print; next }
|
||||||
|
{ print }
|
||||||
|
' src-tauri/Cargo.lock > "$tmp" && mv "$tmp" src-tauri/Cargo.lock
|
||||||
|
|
||||||
- name: Commit and tag
|
- name: Commit and tag
|
||||||
env:
|
env:
|
||||||
@@ -51,7 +57,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml
|
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml src-tauri/Cargo.lock
|
||||||
git commit -m "chore(release): $TAG"
|
git commit -m "chore(release): $TAG"
|
||||||
for attempt in 1 2 3 4 5; do
|
for attempt in 1 2 3 4 5; do
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
@@ -147,19 +153,49 @@ jobs:
|
|||||||
echo "Wrote google-credentials.json from GOOGLE_CREDENTIALS secret."
|
echo "Wrote google-credentials.json from GOOGLE_CREDENTIALS secret."
|
||||||
else
|
else
|
||||||
cp google-credentials.example.json google-credentials.json
|
cp google-credentials.example.json google-credentials.json
|
||||||
echo "::warning::GOOGLE_CREDENTIALS secret not set — embedding placeholder credentials; Google Drive backup will be disabled in this release."
|
echo "::warning::GOOGLE_CREDENTIALS secret not set, embedding placeholder credentials; Google Drive backup will be disabled in this release."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Provision Apple notarization key
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$KEY_P8" ]; then
|
||||||
|
echo "::warning::APPLE_API_KEY_P8 is not set, so the macOS bundle will be ad-hoc signed and Gatekeeper will refuse to open it."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s' "$KEY_P8" | base64 --decode > "$RUNNER_TEMP/apple-api-key.p8"
|
||||||
|
chmod 600 "$RUNNER_TEMP/apple-api-key.p8"
|
||||||
|
echo "APPLE_API_KEY_PATH=$RUNNER_TEMP/apple-api-key.p8" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build and upload
|
- name: Build and upload
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
with:
|
with:
|
||||||
releaseId: ${{ needs.prepare.outputs.release_id }}
|
releaseId: ${{ needs.prepare.outputs.release_id }}
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
|
|
||||||
|
- name: Verify the bundle is signed and notarized
|
||||||
|
if: runner.os == 'macOS' && env.APPLE_API_KEY_PATH != ''
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
app="src-tauri/target/universal-apple-darwin/release/bundle/macos/Margin.app"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app"
|
||||||
|
# Gatekeeper only says "accepted" once the notarization ticket is stapled to the bundle,
|
||||||
|
# so this is the check that a user double-clicking the dmg will actually get past.
|
||||||
|
spctl --assess --type execute --verbose=4 "$app"
|
||||||
|
xcrun stapler validate "$app"
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: [prepare, build]
|
needs: [prepare, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -175,8 +211,49 @@ jobs:
|
|||||||
jq '.platforms | keys' latest.json
|
jq '.platforms | keys' latest.json
|
||||||
for key in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
|
for key in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
|
||||||
if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then
|
if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then
|
||||||
echo "::error::latest.json is missing platform '$key' — refusing to publish a partial update manifest. Re-run the release."
|
echo "::error::latest.json is missing platform '$key'. Refusing to publish a partial update manifest; re-run the release."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
|
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
|
||||||
|
|
||||||
|
homebrew:
|
||||||
|
needs: [prepare, publish]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Point the cask at the release that just went out
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
VERSION: ${{ needs.prepare.outputs.version }}
|
||||||
|
TAP: priyanshujain/homebrew-margin
|
||||||
|
run: |
|
||||||
|
if [ -z "$DEPLOY_KEY" ]; then
|
||||||
|
echo "::warning::HOMEBREW_TAP_DEPLOY_KEY is not set, so $TAG is published but the Homebrew cask still points at the previous version."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
dmg="Margin_${VERSION}_universal.dmg"
|
||||||
|
gh release download "$TAG" --repo "$REPO" --pattern "$dmg" --output "$dmg"
|
||||||
|
sha=$(sha256sum "$dmg" | cut -d' ' -f1)
|
||||||
|
|
||||||
|
# A deploy key rather than a token: it reaches the tap and nothing else, so a leak from
|
||||||
|
# this job cannot touch the app repos.
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/tap_key
|
||||||
|
chmod 600 ~/.ssh/tap_key
|
||||||
|
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
export GIT_SSH_COMMAND="ssh -i ~/.ssh/tap_key -o IdentitiesOnly=yes"
|
||||||
|
|
||||||
|
git clone --depth 1 "[email protected]:$TAP.git" tap
|
||||||
|
cd tap
|
||||||
|
sed -i -E "s|^ version \".*\"| version \"$VERSION\"|" Casks/margin.rb
|
||||||
|
sed -i -E "s|^ sha256 \".*\"| sha256 \"$sha\"|" Casks/margin.rb
|
||||||
|
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add Casks/margin.rb
|
||||||
|
git commit -m "margin $VERSION"
|
||||||
|
git push
|
||||||
@@ -33,3 +33,10 @@ src-tauri/target/
|
|||||||
# Screenshots & Playwright MCP artifacts
|
# Screenshots & Playwright MCP artifacts
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
/*.png
|
/*.png
|
||||||
|
|
||||||
|
# App Store packaging output
|
||||||
|
target-mas/
|
||||||
|
|
||||||
|
# Beta app review contact details. Apple requires a real phone number and this repo is public.
|
||||||
|
appstore/metadata/review_phone.txt
|
||||||
|
appstore/metadata/review_email.txt
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
## Git Commit Rules
|
## Git Commit Rules
|
||||||
|
|
||||||
- Do not make branches, commit in main only
|
- Do not make branches, commit in main only
|
||||||
- Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message`
|
- Commit message is one plain lowercase line. No type prefix, no scope, no body.
|
||||||
- Never use `git add .` or `git add -A`. Always stage specific files by name.
|
- Never use `git add .` or `git add -A`. Always stage specific files by name.
|
||||||
- Don't batch multiple unrelated changes into one commit.
|
- Don't batch multiple unrelated changes into one commit.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Functional Source License, Version 1.1, MIT Future License
|
||||||
|
|
||||||
|
## Abbreviation
|
||||||
|
|
||||||
|
FSL-1.1-MIT
|
||||||
|
|
||||||
|
## Notice
|
||||||
|
|
||||||
|
Copyright 2026 Priyanshu Jain
|
||||||
|
|
||||||
|
## Terms and Conditions
|
||||||
|
|
||||||
|
### Licensor ("We")
|
||||||
|
|
||||||
|
The party offering the Software under these Terms and Conditions.
|
||||||
|
|
||||||
|
### The Software
|
||||||
|
|
||||||
|
The "Software" is each version of the software that we make available under
|
||||||
|
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||||
|
Conditions with the Software.
|
||||||
|
|
||||||
|
### License Grant
|
||||||
|
|
||||||
|
Subject to your compliance with this License Grant and the Patents,
|
||||||
|
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||||
|
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||||
|
and redistribute the Software for any Permitted Purpose identified below.
|
||||||
|
|
||||||
|
### Permitted Purpose
|
||||||
|
|
||||||
|
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||||
|
means making the Software available to others in a commercial product or
|
||||||
|
service that:
|
||||||
|
|
||||||
|
1. substitutes for the Software;
|
||||||
|
|
||||||
|
2. substitutes for any other product or service we offer using the Software
|
||||||
|
that exists as of the date we make the Software available; or
|
||||||
|
|
||||||
|
3. offers the same or substantially similar functionality as the Software.
|
||||||
|
|
||||||
|
Permitted Purposes specifically include using the Software:
|
||||||
|
|
||||||
|
1. for your internal use and access;
|
||||||
|
|
||||||
|
2. for non-commercial education;
|
||||||
|
|
||||||
|
3. for non-commercial research; and
|
||||||
|
|
||||||
|
4. in connection with professional services that you provide to a licensee
|
||||||
|
using the Software in accordance with these Terms and Conditions.
|
||||||
|
|
||||||
|
### Patents
|
||||||
|
|
||||||
|
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||||
|
patents, the license grant above includes a license under our patents. If you
|
||||||
|
make a claim against any party that the Software infringes or contributes to
|
||||||
|
the infringement of any patent, then your patent license to the Software ends
|
||||||
|
immediately.
|
||||||
|
|
||||||
|
### Redistribution
|
||||||
|
|
||||||
|
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||||
|
the Software.
|
||||||
|
|
||||||
|
If you redistribute any copies, modifications or derivatives of the Software,
|
||||||
|
you must include a copy of or a link to these Terms and Conditions and not
|
||||||
|
remove any copyright notices provided in or with the Software.
|
||||||
|
|
||||||
|
### Disclaimer
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||||
|
|
||||||
|
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||||
|
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||||
|
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
Except for displaying the License Details and identifying us as the origin of
|
||||||
|
the Software, you have no right under these Terms and Conditions to use our
|
||||||
|
trademarks, trade names, service marks or product names.
|
||||||
|
|
||||||
|
## Grant of Future License
|
||||||
|
|
||||||
|
We hereby irrevocably grant you an additional license to use the Software under
|
||||||
|
the MIT license that is effective on the second anniversary of the date we make
|
||||||
|
the Software available. On or after that date, you may use the Software under
|
||||||
|
the MIT license, in which case the following will apply:
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
# margin
|
# margin
|
||||||
|
|
||||||
Write your book. Own every word. A calm, offline studio to write, format, and publish your book to every store and to print.
|
Write your book. Own every word. A calm, offline studio to write, format, and publish your book to every store and to print.
|
||||||
|
|
||||||
|
Download it from [margin.73ai.org](https://margin.73ai.org), or install it with Homebrew:
|
||||||
|
|
||||||
|
```
|
||||||
|
brew tap priyanshujain/margin
|
||||||
|
brew trust priyanshujain/margin
|
||||||
|
brew install --cask margin
|
||||||
|
```
|
||||||
|
|
||||||
|
How releases are signed and where they go is in [docs/publishing.md](docs/publishing.md).
|
||||||
|
|
||||||
|
Licensed [FSL-1.1-MIT](LICENSE): use it for anything except building a competing product, and every version turns MIT two years after its release.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[email protected]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
2026 Priyanshu Jain
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Margin is an offline writing studio. Write without anything in the way, see your words set in real typography as you go, and export a print-ready PDF or an EPUB when you are ready.
|
||||||
|
|
||||||
|
This build runs in the App Store sandbox, which is new, so the things most worth trying are the ones that touch the file system: importing, exporting a PDF or EPUB, and the optional Google Drive backup. If any of those fail where they used to work, that is the bug worth reporting.
|
||||||
|
|
||||||
|
There is no account and nothing to sign up for.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Margin is a calm, offline writing studio. Write without anything in the way, see your words set in real typography as you go, and keep every one of them on your own machine.
|
||||||
|
|
||||||
|
WRITE
|
||||||
|
A quiet editor that stays out of the way, with your work down one side and a live page preview down the other, showing your words as they will actually appear rather than as a word processor imagines them.
|
||||||
|
|
||||||
|
PROOF
|
||||||
|
Spelling and grammar are checked as you write, using the same system engine as the rest of macOS. Your words are never sent anywhere to be checked.
|
||||||
|
|
||||||
|
PUBLISH
|
||||||
|
When you are ready, export a print-ready PDF set in real typography, with proper margins, page numbers and running heads. Or export EPUB, ready for Apple Books, Kindle, Kobo and the rest.
|
||||||
|
|
||||||
|
YOURS
|
||||||
|
No account. No sign-up. No subscription. Your work is a single file on your computer that you can copy, rename, back up, or move to another machine. Nothing is locked inside a library you cannot get out of. If you would like a backup, Margin can save one to your own Google Drive, in your account and under your control.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
writing,writer,editor,text,document,offline,grammar,typography,epub,pdf,author,manuscript,draft
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
https://margin.73ai.org
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Margin: The Writing App
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
https://margin.73ai.org/privacy
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Write without anything in the way. Real typography, spelling and grammar on your own machine, and export to PDF or EPUB when you are ready.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
First release.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
A calm, offline writing studio
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
https://margin.73ai.org
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PRODUCTIVITY
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Priyanshu
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Jain
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
Margin is a writing app. There are no accounts and no demo credentials are needed: open it and
|
||||||
|
start writing.
|
||||||
|
|
||||||
|
com.apple.security.network.server
|
||||||
|
|
||||||
|
This is for the optional Google Drive backup, and that is the only thing in the app that uses it.
|
||||||
|
|
||||||
|
Google's OAuth flow for installed apps returns the authorization code by redirecting the user's
|
||||||
|
browser to a loopback address. Margin binds a listener on 127.0.0.1 on an ephemeral port, opens the
|
||||||
|
consent page in the default browser, accepts the one redirect that comes back from that local
|
||||||
|
browser, reads the code and closes the socket. The App Sandbox refuses that bind without
|
||||||
|
com.apple.security.network.server, and sign-in then hangs on a browser tab with nowhere to return
|
||||||
|
to. Google withdrew the out-of-band alternative, so loopback is the only flow still available to a
|
||||||
|
desktop app.
|
||||||
|
|
||||||
|
The socket is bound to 127.0.0.1 only and never to a routable interface, so nothing off this Mac
|
||||||
|
can reach it. It exists only while a sign-in is in progress, times out after 120 seconds, and
|
||||||
|
rejects any request whose state parameter does not match the one just generated.
|
||||||
|
|
||||||
|
To see it: the cloud icon at the top of the opening library screen opens Backup and Sync, and
|
||||||
|
"Connect Google Drive" there starts the flow. Finishing it needs a Google account of your own.
|
||||||
|
Everything else in the app works without one.
|
||||||
|
|
||||||
|
com.apple.security.network.client
|
||||||
|
|
||||||
|
Used only to reach googleapis.com for that same backup, which stays off until the user connects
|
||||||
|
their own Google account. Nothing else Margin does touches the network: writing, the page preview,
|
||||||
|
spelling, grammar and both exports all run on the machine.
|
||||||
|
|
||||||
|
com.apple.security.files.user-selected.read-write
|
||||||
|
|
||||||
|
Importing an EPUB and exporting a PDF or EPUB go through the standard open and save panels, so the
|
||||||
|
app only ever reaches the file the user picked. The library itself lives in the app container.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Mac App Store screenshots
|
||||||
|
|
||||||
|
Five 2560x1600 frames for the Margin listing: `frame-1.png` through `frame-5.png`.
|
||||||
|
|
||||||
|
Each frame is an HTML page in `src/`, rendered at a 2560x1600 viewport with a device pixel ratio of
|
||||||
|
1. The app window inside the frame is not a bitmap capture. It is a CSS rebuild of the real app in
|
||||||
|
`src/app.css`, with rules and values lifted from `shared/css/tokens.css` and `src/styles/app.css`,
|
||||||
|
laid out at the app's own 1440x900 and scaled once into the 1600x1000 window well. That is why the
|
||||||
|
type is sharp: it is real text at render time, not a resampled screenshot.
|
||||||
|
|
||||||
|
| File | Frame | Shows |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `frame-1.png` | Write | The writing surface, preview dock closed |
|
||||||
|
| `frame-2.png` | Preview | The editor beside the live page preview |
|
||||||
|
| `frame-3.png` | Proof | Three proofing marks and an open spelling popover |
|
||||||
|
| `frame-4.png` | Publish | The PDF export preview panel over the editor |
|
||||||
|
| `frame-5.png` | Yours | The library, no sign-in anywhere |
|
||||||
|
|
||||||
|
## Re-rendering
|
||||||
|
|
||||||
|
Serve this directory and screenshot each page at a 2560x1600 viewport, dpr 1:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m http.server 8731
|
||||||
|
```
|
||||||
|
|
||||||
|
Load `http://127.0.0.1:8731/src/frame-1.html` and capture the viewport to `frame-1.png` here. Check
|
||||||
|
every output with `sips -g pixelWidth -g pixelHeight frame-*.png`; App Store Connect rejects
|
||||||
|
anything that is not exactly 2560x1600.
|
||||||
|
|
||||||
|
Type comes from Google Fonts (Literata for headlines and prose, Hanken Grotesk for the interface),
|
||||||
|
so the render needs a network connection.
|
||||||
|
|
||||||
|
## Editing
|
||||||
|
|
||||||
|
`src/frame.css` is the frame surface: the eyebrow, headline, supporting line and the window well.
|
||||||
|
Every length in it is a real output pixel.
|
||||||
|
|
||||||
|
`src/app.css` is the app window. Every length in it is an app pixel, so a value copied out of the
|
||||||
|
product's own stylesheet can go in unchanged. Keep it that way; the whole point is that the
|
||||||
|
screenshots stay honest about what the app looks like.
|
||||||
|
|
||||||
|
Copy lives in each `src/frame-N.html`, along with that frame's window markup, since every frame
|
||||||
|
shows a different state of the app.
|
||||||
|
|
||||||
|
## Sample content
|
||||||
|
|
||||||
|
The frames use "The Quiet Hours" by Elena Marsh, an invented author. The prose is original and
|
||||||
|
written for this listing, so it is safe to publish. If you change it, keep it general non-fiction
|
||||||
|
rather than novel writing: the listing positions Margin as a writing app, not as software for one
|
||||||
|
genre.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 537 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 710 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 548 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 538 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 443 KiB |
@@ -0,0 +1,807 @@
|
|||||||
|
/* The Margin window, rebuilt in CSS so it renders crisply at any scale.
|
||||||
|
Rules and values are lifted from the app itself: shared/css/tokens.css and
|
||||||
|
src/styles/app.css. Authored at the app's own pixel sizes in a 1440x900
|
||||||
|
window, then scaled once by frame.css to fill the 1600x1000 window well. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
--font-book: "Literata", Georgia, "Times New Roman", serif;
|
||||||
|
--font-heading: "Literata", Georgia, "Times New Roman", serif;
|
||||||
|
|
||||||
|
--r-sm: 5px;
|
||||||
|
--r-md: 8px;
|
||||||
|
--r-lg: 12px;
|
||||||
|
|
||||||
|
--pane-sidebar: 248px;
|
||||||
|
--pane-dock: 384px;
|
||||||
|
--measure: 46em;
|
||||||
|
--titlebar-h: 46px;
|
||||||
|
|
||||||
|
--t-1: 11px;
|
||||||
|
--t-2: 12px;
|
||||||
|
--t-3: 13px;
|
||||||
|
--t-4: 15px;
|
||||||
|
|
||||||
|
--paper: #fcfbf7;
|
||||||
|
--shell: #f1ece2;
|
||||||
|
--sidebar: #ece6da;
|
||||||
|
--raised: #fbfaf6;
|
||||||
|
|
||||||
|
--ink: #23201b;
|
||||||
|
--ink-soft: #6b6458;
|
||||||
|
--ink-faint: #9b9484;
|
||||||
|
|
||||||
|
--line: #e3ddce;
|
||||||
|
--line-strong: #d6cfbd;
|
||||||
|
|
||||||
|
--accent: #2a2622;
|
||||||
|
--accent-ink: #100e0b;
|
||||||
|
--accent-wash: rgba(35, 32, 27, 0.08);
|
||||||
|
--accent-contrast: #faf7f0;
|
||||||
|
|
||||||
|
--danger: #b4453a;
|
||||||
|
--danger-ink: #963327;
|
||||||
|
|
||||||
|
--shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09);
|
||||||
|
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
position: relative;
|
||||||
|
width: 1440px;
|
||||||
|
height: 900px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--shell);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
line-height: normal;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app svg {
|
||||||
|
flex: none;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title bar ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.titlebar {
|
||||||
|
flex: none;
|
||||||
|
position: relative;
|
||||||
|
z-index: 45;
|
||||||
|
height: var(--titlebar-h);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 14px 0 84px;
|
||||||
|
background: var(--shell);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lights {
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.lights i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.lights i:nth-child(1) {
|
||||||
|
background: #ff5f57;
|
||||||
|
}
|
||||||
|
.lights i:nth-child(2) {
|
||||||
|
background: #febc2e;
|
||||||
|
}
|
||||||
|
.lights i:nth-child(3) {
|
||||||
|
background: #28c840;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar .lead {
|
||||||
|
grid-column: 1;
|
||||||
|
justify-self: start;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.titlebar .doc-title {
|
||||||
|
grid-column: 2;
|
||||||
|
font-size: var(--t-2);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.titlebar .actions {
|
||||||
|
grid-column: 3;
|
||||||
|
justify-self: end;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.icon-btn[data-on="true"] {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-wash);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--pane-sidebar);
|
||||||
|
flex: none;
|
||||||
|
background: var(--sidebar);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 6px 0 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 18px 16px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.brand .back-label {
|
||||||
|
font-size: var(--t-3);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
margin: 2px 10px 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
}
|
||||||
|
.cover-item .title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.cover-item svg {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-page {
|
||||||
|
margin: 2px 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapters {
|
||||||
|
list-style: none;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 0 10px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
padding: 4px 22px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--t-1);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.nav-section + .nav-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.chapter[data-active="true"] {
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 1px 2px rgba(35, 32, 27, 0.05);
|
||||||
|
}
|
||||||
|
.chapter[data-active="true"]::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -10px;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
.chapter .num {
|
||||||
|
font-size: var(--t-1);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
width: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.chapter[data-active="true"] .num {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.chapter .text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.chapter .title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chapter .meta {
|
||||||
|
font-size: var(--t-1);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.chapter .grip {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-self: center;
|
||||||
|
margin: 0 -5px 0 -6px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.chapter[data-active="true"] .grip {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-chapter {
|
||||||
|
margin: 8px 10px 4px;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
padding: 9px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Writing surface ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
.editor-pane {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--measure);
|
||||||
|
padding: 104px 24px 200px;
|
||||||
|
}
|
||||||
|
.sheet[data-width="wide"] {
|
||||||
|
max-width: 62em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-opener {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 3.4rem;
|
||||||
|
}
|
||||||
|
.chapter-num {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-1);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.chapter-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 31px;
|
||||||
|
line-height: 1.16;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 15px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
color: var(--ink);
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
}
|
||||||
|
.prose p {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.68;
|
||||||
|
margin: 0;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
|
}
|
||||||
|
.prose p[data-indent="true"] {
|
||||||
|
text-indent: 1.3em;
|
||||||
|
}
|
||||||
|
.prose .first::first-letter {
|
||||||
|
float: left;
|
||||||
|
font-size: 3.1em;
|
||||||
|
line-height: 0.82;
|
||||||
|
padding: 0.05em 0.09em 0 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.5px;
|
||||||
|
height: 1.05em;
|
||||||
|
background: var(--ink);
|
||||||
|
vertical-align: -0.18em;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Proofing ----------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.proof-mark {
|
||||||
|
border-radius: 2px;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
.proof-mark.sev-error {
|
||||||
|
background: rgba(180, 69, 58, 0.13);
|
||||||
|
text-decoration-color: #b4453a;
|
||||||
|
}
|
||||||
|
.proof-mark.sev-warn {
|
||||||
|
background: rgba(168, 119, 24, 0.16);
|
||||||
|
text-decoration-color: #9c6e16;
|
||||||
|
}
|
||||||
|
.proof-mark.sev-suggest {
|
||||||
|
background: rgba(47, 110, 79, 0.13);
|
||||||
|
text-decoration-color: #2f6e4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-anchor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.proof-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 50;
|
||||||
|
width: 264px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
text-align: left;
|
||||||
|
text-indent: 0;
|
||||||
|
}
|
||||||
|
.proof-pop-kind {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0 6px 2px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--t-1);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.proof-pop-kind.sev-error {
|
||||||
|
background: rgba(180, 69, 58, 0.14);
|
||||||
|
color: var(--danger-ink);
|
||||||
|
}
|
||||||
|
.proof-pop-msg {
|
||||||
|
display: block;
|
||||||
|
padding: 2px 4px 8px;
|
||||||
|
font-size: var(--t-2);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.proof-pop-suggestions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.proof-suggestion {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--accent-wash);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.proof-suggestion.is-hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
.proof-pop-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.proof-action {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: var(--t-1);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview dock ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.dock {
|
||||||
|
width: var(--pane-dock);
|
||||||
|
flex: none;
|
||||||
|
background: var(--shell);
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 26px 28px 80px;
|
||||||
|
}
|
||||||
|
.dock-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.dock-head .label {
|
||||||
|
font-size: var(--t-1);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.dock-head .meta {
|
||||||
|
font-size: var(--t-1);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.dock-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.trim-select {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: var(--raised);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.trim-select svg {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
position: relative;
|
||||||
|
background: #fffefb;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: var(--shadow-page);
|
||||||
|
aspect-ratio: 6 / 9;
|
||||||
|
padding: 11% 12% 13%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.page .p-opener {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.page .p-num {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.page .p-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 5px 0 0;
|
||||||
|
}
|
||||||
|
.page p {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 6.6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
|
margin: 0;
|
||||||
|
color: #2b2720;
|
||||||
|
}
|
||||||
|
.page p[data-indent="true"] {
|
||||||
|
text-indent: 1.2em;
|
||||||
|
}
|
||||||
|
.page .folio {
|
||||||
|
position: absolute;
|
||||||
|
left: 12%;
|
||||||
|
right: 12%;
|
||||||
|
bottom: 5.5%;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 6px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Export preview panel ----------------------------------------------------- */
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 55;
|
||||||
|
background: rgba(35, 32, 27, 0.28);
|
||||||
|
-webkit-backdrop-filter: blur(2px);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-panel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 56;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.preview-bar {
|
||||||
|
flex: none;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--paper);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.preview-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--ink);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.preview-count {
|
||||||
|
font-size: var(--t-2);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.preview-zoom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.preview-zoom span {
|
||||||
|
width: 44px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--t-2);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 9px 20px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
.preview-stage {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 28px;
|
||||||
|
background: var(--shell);
|
||||||
|
}
|
||||||
|
.preview-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.preview-page {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
width: 460px;
|
||||||
|
aspect-ratio: 6 / 9;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: var(--shadow-page);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 11% 12% 13%;
|
||||||
|
color: #23201b;
|
||||||
|
}
|
||||||
|
.preview-page .p-opener {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.preview-page .p-num {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 8.4px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9b9484;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.preview-page .p-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 16.8px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 7px 0 0;
|
||||||
|
}
|
||||||
|
.preview-page p {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 9.2px;
|
||||||
|
line-height: 1.62;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
|
margin: 0;
|
||||||
|
color: #2b2720;
|
||||||
|
}
|
||||||
|
.preview-page p[data-indent="true"] {
|
||||||
|
text-indent: 1.2em;
|
||||||
|
}
|
||||||
|
.preview-page .first::first-letter {
|
||||||
|
float: left;
|
||||||
|
font-size: 3.1em;
|
||||||
|
line-height: 0.82;
|
||||||
|
padding: 0.05em 0.09em 0 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.preview-page .folio {
|
||||||
|
position: absolute;
|
||||||
|
left: 12%;
|
||||||
|
right: 12%;
|
||||||
|
bottom: 5.5%;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 8.4px;
|
||||||
|
color: #b3ab9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Library ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.library {
|
||||||
|
position: relative;
|
||||||
|
width: 1440px;
|
||||||
|
height: 900px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--shell);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
line-height: normal;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.library svg {
|
||||||
|
flex: none;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.library-head {
|
||||||
|
position: relative;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
.shelf {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 26px 44px 80px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 5 / 7;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 22px 18px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.card-book {
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow-page);
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.18;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.card-author {
|
||||||
|
font-size: var(--t-2);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.card-meta {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 14px;
|
||||||
|
font-size: var(--t-1);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.card-action {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Margin App Store frame 1</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:[email protected]&family=Literata:opsz,[email protected],200..900&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="frame.css" />
|
||||||
|
<link rel="stylesheet" href="app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="frame">
|
||||||
|
<p class="eyebrow">Write</p>
|
||||||
|
<h1 class="headline">A calm writing studio.</h1>
|
||||||
|
<p class="support">Your words, set in real typography, with nothing else asking for attention.</p>
|
||||||
|
|
||||||
|
<div class="shot"><div class="shot__scale">
|
||||||
|
<div class="app">
|
||||||
|
<header class="titlebar">
|
||||||
|
<span class="lights"><i></i><i></i><i></i></span>
|
||||||
|
<span class="lead">
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM9 4.5v15" /></svg></span>
|
||||||
|
</span>
|
||||||
|
<span class="doc-title">The Quiet Hours</span>
|
||||||
|
<span class="actions">
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg></span>
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<span class="brand">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>
|
||||||
|
<span class="back-label">All projects</span>
|
||||||
|
</span>
|
||||||
|
<span class="cover-item">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>
|
||||||
|
<span class="title">Cover</span>
|
||||||
|
</span>
|
||||||
|
<span class="add-page">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
<span>Add page</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ul class="chapters">
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Front matter</p>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">Title Page</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">Epigraph</span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Chapters</p>
|
||||||
|
<div class="chapter" data-active="true">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">1</span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="title">The Long Morning</span>
|
||||||
|
<span class="meta">Edited just now</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">2</span>
|
||||||
|
<span class="text"><span class="title">On Interruption</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">3</span>
|
||||||
|
<span class="text"><span class="title">What the Draft Knows</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">4</span>
|
||||||
|
<span class="text"><span class="title">A Note on Endings</span></span>
|
||||||
|
</div>
|
||||||
|
<span class="add-chapter">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
<span>New chapter</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Back matter</p>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">About the Author</span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="editor-pane">
|
||||||
|
<div class="sheet">
|
||||||
|
<div class="chapter-opener">
|
||||||
|
<p class="chapter-num">Chapter 1</p>
|
||||||
|
<h1 class="chapter-title">The Long Morning</h1>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p class="first">
|
||||||
|
The best hours are the ones nobody asks for. They arrive before the day has decided
|
||||||
|
what it wants from you, and they leave without ceremony, and what happens in them is
|
||||||
|
almost never what you planned.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I have kept the same desk for eleven years. It is not a good desk. One leg is
|
||||||
|
shorter than the others and I have never fixed it, because the small tilt reminds
|
||||||
|
me, every time I sit down, that the work is not going to be perfect either, and that
|
||||||
|
this has never once stopped anybody.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
What I have learned about working slowly is that it is not slow. It only looks that
|
||||||
|
way from the outside. From inside it feels like the only speed at which anything
|
||||||
|
holds together.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
There is a particular hour, somewhere between the first coffee and the second, when
|
||||||
|
the work stops feeling like a performance. Nothing on the page has changed. What has
|
||||||
|
changed is that you have stopped listening for applause.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
The trick, if there is one, is to start before you are ready. Readiness is a story
|
||||||
|
you tell yourself about a future morning that will be calmer than this one, and it
|
||||||
|
will not be. The calm is something you make, badly at first, out of whatever the day
|
||||||
|
has left lying around.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I write the first sentence standing up. I do not know why this works and I have
|
||||||
|
stopped asking. Somewhere in the business of not sitting down the sentence loses its
|
||||||
|
self-consciousness, and by the time I take the chair there is something on the page
|
||||||
|
to argue with.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Margin App Store frame 2</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:[email protected]&family=Literata:opsz,[email protected],200..900&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="frame.css" />
|
||||||
|
<link rel="stylesheet" href="app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="frame">
|
||||||
|
<p class="eyebrow">Preview</p>
|
||||||
|
<h1 class="headline">Typeset as you write.</h1>
|
||||||
|
<p class="support">A live page preview shows every sentence exactly as it will come out in print.</p>
|
||||||
|
|
||||||
|
<div class="shot"><div class="shot__scale">
|
||||||
|
<div class="app">
|
||||||
|
<header class="titlebar">
|
||||||
|
<span class="lights"><i></i><i></i><i></i></span>
|
||||||
|
<span class="lead">
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM9 4.5v15" /></svg></span>
|
||||||
|
</span>
|
||||||
|
<span class="doc-title">The Quiet Hours</span>
|
||||||
|
<span class="actions">
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg></span>
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg></span>
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<span class="brand">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>
|
||||||
|
<span class="back-label">All projects</span>
|
||||||
|
</span>
|
||||||
|
<span class="cover-item">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>
|
||||||
|
<span class="title">Cover</span>
|
||||||
|
</span>
|
||||||
|
<span class="add-page">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
<span>Add page</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ul class="chapters">
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Front matter</p>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">Title Page</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">Epigraph</span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Chapters</p>
|
||||||
|
<div class="chapter" data-active="true">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">1</span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="title">The Long Morning</span>
|
||||||
|
<span class="meta">Edited just now</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">2</span>
|
||||||
|
<span class="text"><span class="title">On Interruption</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">3</span>
|
||||||
|
<span class="text"><span class="title">What the Draft Knows</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">4</span>
|
||||||
|
<span class="text"><span class="title">A Note on Endings</span></span>
|
||||||
|
</div>
|
||||||
|
<span class="add-chapter">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
<span>New chapter</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Back matter</p>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">About the Author</span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="editor-pane">
|
||||||
|
<div class="sheet">
|
||||||
|
<div class="chapter-opener">
|
||||||
|
<p class="chapter-num">Chapter 1</p>
|
||||||
|
<h1 class="chapter-title">The Long Morning</h1>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p class="first">
|
||||||
|
The best hours are the ones nobody asks for. They arrive before the day has decided
|
||||||
|
what it wants from you, and they leave without ceremony, and what happens in them is
|
||||||
|
almost never what you planned.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I have kept the same desk for eleven years. It is not a good desk. One leg is
|
||||||
|
shorter than the others and I have never fixed it, because the small tilt reminds
|
||||||
|
me, every time I sit down, that the work is not going to be perfect either, and that
|
||||||
|
this has never once stopped anybody.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
What I have learned about working slowly is that it is not slow. It only looks that
|
||||||
|
way from the outside. From inside it feels like the only speed at which anything
|
||||||
|
holds together.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
There is a particular hour, somewhere between the first coffee and the second, when
|
||||||
|
the work stops feeling like a performance. Nothing on the page has changed. What has
|
||||||
|
changed is that you have stopped listening for applause.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
The trick, if there is one, is to start before you are ready. Readiness is a story
|
||||||
|
you tell yourself about a future morning that will be calmer than this one, and it
|
||||||
|
will not be. The calm is something you make, badly at first, out of whatever the day
|
||||||
|
has left lying around.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I write the first sentence standing up. I do not know why this works and I have
|
||||||
|
stopped asking. Somewhere in the business of not sitting down the sentence loses its
|
||||||
|
self-consciousness, and by the time I take the chair there is something on the page
|
||||||
|
to argue with.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="dock">
|
||||||
|
<div class="dock-head">
|
||||||
|
<span class="label">Preview</span>
|
||||||
|
<span class="dock-tools">
|
||||||
|
<span class="meta">1 of 4</span>
|
||||||
|
<span class="trim-select">
|
||||||
|
6 × 9 in
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M8 10l4-4 4 4M8 14l4 4 4-4" /></svg>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dock-col">
|
||||||
|
<div class="page">
|
||||||
|
<div class="p-opener">
|
||||||
|
<p class="p-num">Chapter 1</p>
|
||||||
|
<p class="p-title">The Long Morning</p>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
The best hours are the ones nobody asks for. They arrive before the day has decided
|
||||||
|
what it wants from you, and they leave without ceremony, and what happens in them is
|
||||||
|
almost never what you planned.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I have kept the same desk for eleven years. It is not a good desk. One leg is
|
||||||
|
shorter than the others and I have never fixed it, because the small tilt reminds
|
||||||
|
me, every time I sit down, that the work is not going to be perfect either, and that
|
||||||
|
this has never once stopped anybody.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
What I have learned about working slowly is that it is not slow. It only looks that
|
||||||
|
way from the outside. From inside it feels like the only speed at which anything
|
||||||
|
holds together.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
There is a particular hour, somewhere between the first coffee and the second, when
|
||||||
|
the work stops feeling like a performance. Nothing on the page has changed. What has
|
||||||
|
changed is that you have stopped listening for applause.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
The trick, if there is one, is to start before you are ready. Readiness is a story
|
||||||
|
you tell yourself about a future morning that will be calmer than this one, and it
|
||||||
|
will not be. The calm is something you make, badly at first, out of whatever the day
|
||||||
|
has left lying around.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I write the first sentence standing up. I do not know why this works and I have
|
||||||
|
stopped asking. Somewhere in the business of not sitting down the sentence loses its
|
||||||
|
self-consciousness, and by the time I take the chair there is something on the page
|
||||||
|
to argue with.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
Everything after that is argument. You put a thing down, you read it back, you tell
|
||||||
|
it that it is not quite true, and you try again. Nine times out of ten the second
|
||||||
|
attempt is worse. The tenth is why anybody does this.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
By eleven the light has moved across the desk and the day has started making its
|
||||||
|
claims. I stop where I am, mid-sentence if I can manage it, because a sentence left
|
||||||
|
open is a door left open, and tomorrow I will walk straight back through it.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
None of this is a method. A method is something you can hand to somebody else, and I
|
||||||
|
have tried, and what they get is a description of a room they do not live in.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
What I can hand over is smaller. Sit down before the day has an opinion. Write badly
|
||||||
|
on purpose for ten minutes. Stop while it is still going well. That is the whole of
|
||||||
|
it, and it has taken me eleven years to be able to say it in three sentences.
|
||||||
|
</p>
|
||||||
|
<p class="folio">7</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<p>
|
||||||
|
What the desk knows, and I keep forgetting, is that the work is not a mood. It does
|
||||||
|
not care whether I arrived at it happily. It only registers whether I arrived, and
|
||||||
|
how long I stayed, and whether I was honest about the sentence in front of me while
|
||||||
|
I was there.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
The second hour is quieter than the first, which nobody warns you about. You expect
|
||||||
|
momentum. What you get is a kind of patience, and it turns out that patience is
|
||||||
|
faster.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I have never once regretted a morning spent this way. I have regretted almost every
|
||||||
|
morning spent otherwise, which is not the same as saying that I stopped having them.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
There is a version of this essay that argues for discipline, and I have read it many
|
||||||
|
times, and it has never once got me out of bed.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
The version that works is duller. Make the room boring. Put the phone in another
|
||||||
|
room, not face down on the desk, because face down on the desk is still a decision
|
||||||
|
you have to keep making. Decide once, in advance, while you are calm, and then let
|
||||||
|
the morning be simple.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I am not a fast writer. I have watched fast writers with the particular envy of
|
||||||
|
someone who has done the arithmetic and knows it does not matter. Over a year the
|
||||||
|
slow ones and the quick ones arrive at roughly the same place, and the slow ones
|
||||||
|
seem to enjoy the journey more, which is not nothing.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
What I want from a morning is not very much. Two hours. One paragraph I did not have
|
||||||
|
when I sat down. The sense, at the end, that I told the truth about something small.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Margin App Store frame 3</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:[email protected]&family=Literata:opsz,[email protected],200..900&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="frame.css" />
|
||||||
|
<link rel="stylesheet" href="app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="frame">
|
||||||
|
<p class="eyebrow">Proof</p>
|
||||||
|
<h1 class="headline">Proofed on your machine.</h1>
|
||||||
|
<p class="support">Spelling and grammar as you type, checked on the Mac in front of you. Nothing is sent away to be read.</p>
|
||||||
|
|
||||||
|
<div class="shot"><div class="shot__scale">
|
||||||
|
<div class="app">
|
||||||
|
<header class="titlebar">
|
||||||
|
<span class="lights"><i></i><i></i><i></i></span>
|
||||||
|
<span class="lead">
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM9 4.5v15" /></svg></span>
|
||||||
|
</span>
|
||||||
|
<span class="doc-title">The Quiet Hours</span>
|
||||||
|
<span class="actions">
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg></span>
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" /></svg></span>
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<span class="brand">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>
|
||||||
|
<span class="back-label">All projects</span>
|
||||||
|
</span>
|
||||||
|
<span class="cover-item">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>
|
||||||
|
<span class="title">Cover</span>
|
||||||
|
</span>
|
||||||
|
<span class="add-page">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
<span>Add page</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ul class="chapters">
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Front matter</p>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">Title Page</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">Epigraph</span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Chapters</p>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">1</span>
|
||||||
|
<span class="text"><span class="title">The Long Morning</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter" data-active="true">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">2</span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="title">On Interruption</span>
|
||||||
|
<span class="meta">Edited 6 minutes ago</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">3</span>
|
||||||
|
<span class="text"><span class="title">What the Draft Knows</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">4</span>
|
||||||
|
<span class="text"><span class="title">A Note on Endings</span></span>
|
||||||
|
</div>
|
||||||
|
<span class="add-chapter">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
<span>New chapter</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Back matter</p>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">About the Author</span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="editor-pane">
|
||||||
|
<div class="sheet">
|
||||||
|
<div class="chapter-opener">
|
||||||
|
<p class="chapter-num">Chapter 2</p>
|
||||||
|
<h1 class="chapter-title">On Interruption</h1>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p class="first">
|
||||||
|
An interruption costs more than the minutes it takes. It costs the shape of the
|
||||||
|
thought you were holding, which does not come back in the same form, and
|
||||||
|
<span class="proof-anchor"><span class="proof-mark sev-error">occassionally</span><span class="proof-pop">
|
||||||
|
<span class="proof-pop-kind sev-error">Spelling</span>
|
||||||
|
<span class="proof-pop-msg">“occassionally” may be misspelled</span>
|
||||||
|
<span class="proof-pop-suggestions">
|
||||||
|
<span class="proof-suggestion is-hover">occasionally</span>
|
||||||
|
<span class="proof-suggestion">occasional</span>
|
||||||
|
</span>
|
||||||
|
<span class="proof-pop-actions">
|
||||||
|
<span class="proof-action">Ignore</span>
|
||||||
|
<span class="proof-action">Remember</span>
|
||||||
|
</span>
|
||||||
|
</span></span>
|
||||||
|
does not come back at all.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
So I have made the room boring. There is nothing on these walls. The window faces a
|
||||||
|
brick wall two metres away, which sounds like a punishment and is in fact a gift,
|
||||||
|
because there is nothing out there to look at and so I look at the page.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
People assume the point is discipline. It is not. The point is to make the right
|
||||||
|
thing the easiest thing, so that discipline is never called upon, because discipline
|
||||||
|
is a small and unreliable fuel and it runs out
|
||||||
|
<span class="proof-mark sev-warn">at around</span> eleven in the morning.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
<span class="proof-mark sev-suggest">There is a thing that happens</span> when you
|
||||||
|
let the morning break in half. The second half is never the first half continued. It
|
||||||
|
is a new morning, shorter, with the good hour already spent, and you will spend it
|
||||||
|
trying to remember what you were about to say.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I keep a card by the door with one line on it, in my own handwriting, so that I
|
||||||
|
cannot pretend somebody else set the rule: nothing before the work. Not the post,
|
||||||
|
not the news, not the small useful errand that would only take a minute.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Margin App Store frame 4</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:[email protected]&family=Literata:opsz,[email protected],200..900&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="frame.css" />
|
||||||
|
<link rel="stylesheet" href="app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="frame">
|
||||||
|
<p class="eyebrow">Publish</p>
|
||||||
|
<h1 class="headline">Export PDF and EPUB.</h1>
|
||||||
|
<p class="support">Read every page before you save it. Send the PDF to a printer, or the EPUB to any store.</p>
|
||||||
|
|
||||||
|
<div class="shot"><div class="shot__scale">
|
||||||
|
<div class="app">
|
||||||
|
<header class="titlebar">
|
||||||
|
<span class="lights"><i></i><i></i><i></i></span>
|
||||||
|
<span class="lead">
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM9 4.5v15" /></svg></span>
|
||||||
|
</span>
|
||||||
|
<span class="doc-title">The Quiet Hours</span>
|
||||||
|
<span class="actions">
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg></span>
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" /></svg></span>
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg></span>
|
||||||
|
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<span class="brand">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>
|
||||||
|
<span class="back-label">All projects</span>
|
||||||
|
</span>
|
||||||
|
<span class="cover-item">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>
|
||||||
|
<span class="title">Cover</span>
|
||||||
|
</span>
|
||||||
|
<span class="add-page">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
<span>Add page</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ul class="chapters">
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Front matter</p>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">Title Page</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">Epigraph</span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Chapters</p>
|
||||||
|
<div class="chapter" data-active="true">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">1</span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="title">The Long Morning</span>
|
||||||
|
<span class="meta">Edited just now</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">2</span>
|
||||||
|
<span class="text"><span class="title">On Interruption</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">3</span>
|
||||||
|
<span class="text"><span class="title">What the Draft Knows</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">4</span>
|
||||||
|
<span class="text"><span class="title">A Note on Endings</span></span>
|
||||||
|
</div>
|
||||||
|
<span class="add-chapter">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
<span>New chapter</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<p class="nav-label">Back matter</p>
|
||||||
|
<div class="chapter">
|
||||||
|
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="text"><span class="title">About the Author</span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="editor-pane">
|
||||||
|
<div class="sheet">
|
||||||
|
<div class="chapter-opener">
|
||||||
|
<p class="chapter-num">Chapter 1</p>
|
||||||
|
<h1 class="chapter-title">The Long Morning</h1>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p class="first">
|
||||||
|
The best hours are the ones nobody asks for. They arrive before the day has decided
|
||||||
|
what it wants from you, and they leave without ceremony, and what happens in them is
|
||||||
|
almost never what you planned.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="dock">
|
||||||
|
<div class="dock-head">
|
||||||
|
<span class="label">Preview</span>
|
||||||
|
<span class="dock-tools">
|
||||||
|
<span class="meta">1 of 4</span>
|
||||||
|
<span class="trim-select">
|
||||||
|
6 × 9 in
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M8 10l4-4 4 4M8 14l4 4 4-4" /></svg>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="dock-col">
|
||||||
|
<div class="page">
|
||||||
|
<div class="p-opener">
|
||||||
|
<p class="p-num">Chapter 1</p>
|
||||||
|
<p class="p-title">The Long Morning</p>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
The best hours are the ones nobody asks for. They arrive before the day has decided
|
||||||
|
what it wants from you, and they leave without ceremony, and what happens in them is
|
||||||
|
almost never what you planned.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I have kept the same desk for eleven years. It is not a good desk. One leg is
|
||||||
|
shorter than the others and I have never fixed it, because the small tilt reminds
|
||||||
|
me, every time I sit down, that the work is not going to be perfect either, and that
|
||||||
|
this has never once stopped anybody.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overlay"></div>
|
||||||
|
|
||||||
|
<div class="preview-panel" style="left: 248px; top: 46px; width: 808px; height: 854px;">
|
||||||
|
<div class="preview-bar">
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" /></svg></span>
|
||||||
|
<span class="preview-title">The Quiet Hours</span>
|
||||||
|
<span class="preview-count">96 pages</span>
|
||||||
|
<span class="preview-zoom">
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M5 12h14" /></svg></span>
|
||||||
|
<span>Fit</span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg></span>
|
||||||
|
</span>
|
||||||
|
<span class="btn-primary">Save PDF…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-stage">
|
||||||
|
<div class="preview-col">
|
||||||
|
<div class="preview-page">
|
||||||
|
<div class="p-opener">
|
||||||
|
<p class="p-num">Chapter 1</p>
|
||||||
|
<p class="p-title">The Long Morning</p>
|
||||||
|
</div>
|
||||||
|
<p class="first">
|
||||||
|
The best hours are the ones nobody asks for. They arrive before the day has decided
|
||||||
|
what it wants from you, and they leave without ceremony, and what happens in them is
|
||||||
|
almost never what you planned.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I have kept the same desk for eleven years. It is not a good desk. One leg is
|
||||||
|
shorter than the others and I have never fixed it, because the small tilt reminds
|
||||||
|
me, every time I sit down, that the work is not going to be perfect either, and that
|
||||||
|
this has never once stopped anybody.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
What I have learned about working slowly is that it is not slow. It only looks that
|
||||||
|
way from the outside. From inside it feels like the only speed at which anything
|
||||||
|
holds together.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
There is a particular hour, somewhere between the first coffee and the second, when
|
||||||
|
the work stops feeling like a performance. Nothing on the page has changed. What has
|
||||||
|
changed is that you have stopped listening for applause.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
The trick, if there is one, is to start before you are ready. Readiness is a story
|
||||||
|
you tell yourself about a future morning that will be calmer than this one, and it
|
||||||
|
will not be. The calm is something you make, badly at first, out of whatever the day
|
||||||
|
has left lying around.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
I write the first sentence standing up. I do not know why this works and I have
|
||||||
|
stopped asking. Somewhere in the business of not sitting down the sentence loses its
|
||||||
|
self-consciousness, and by the time I take the chair there is something on the page
|
||||||
|
to argue with.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
Everything after that is argument. You put a thing down, you read it back, you tell
|
||||||
|
it that it is not quite true, and you try again. Nine times out of ten the second
|
||||||
|
attempt is worse. The tenth is why anybody does this.
|
||||||
|
</p>
|
||||||
|
<p class="folio">7</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-page">
|
||||||
|
<p>
|
||||||
|
By eleven the light has moved across the desk and the day has started making its
|
||||||
|
claims. I stop where I am, mid-sentence if I can manage it, because a sentence left
|
||||||
|
open is a door left open, and tomorrow I will walk straight back through it.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
None of this is a method. A method is something you can hand to somebody else, and I
|
||||||
|
have tried, and what they get is a description of a room they do not live in.
|
||||||
|
</p>
|
||||||
|
<p data-indent="true">
|
||||||
|
What I can hand over is smaller. Sit down before the day has an opinion. Write badly
|
||||||
|
on purpose for ten minutes. Stop while it is still going well.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Margin App Store frame 5</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:[email protected]&family=Literata:opsz,[email protected],200..900&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="frame.css" />
|
||||||
|
<link rel="stylesheet" href="app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="frame">
|
||||||
|
<p class="eyebrow">Yours</p>
|
||||||
|
<h1 class="headline">No account, no subscription.</h1>
|
||||||
|
<p class="support">Nothing to sign in to. Every piece is a plain file on your own Mac, yours to move, copy and back up.</p>
|
||||||
|
|
||||||
|
<div class="shot"><div class="shot__scale">
|
||||||
|
<div class="library">
|
||||||
|
<header class="library-head">
|
||||||
|
<span class="lights"><i></i><i></i><i></i></span>
|
||||||
|
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" /></svg></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="shelf">
|
||||||
|
<span class="card card-action">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
<span>New project</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-action">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M12 3v10m0 0l-4-4m4 4l4-4M5 19h14" /></svg>
|
||||||
|
<span>Import EPUB</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">The Quiet Hours</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited just now</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">Field Notes</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited yesterday</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">The Tuesday Letter</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited 3 days ago</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">Small Repairs</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited 6 days ago</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">A Year of Mondays</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited Aug 12, 2026</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">Nothing on These Walls</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited Aug 4, 2026</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">Twelve Openings</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited Jul 28, 2026</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">Rooms I Have Worked In</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited Jul 9, 2026</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">The Long Way Round</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited Jun 30, 2026</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">Working Slowly</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited Jun 2, 2026</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">Every Second Sentence</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited May 19, 2026</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">Letters I Did Not Send</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited Apr 27, 2026</span>
|
||||||
|
</span>
|
||||||
|
<span class="card card-book">
|
||||||
|
<span class="card-title">The Brick Wall</span>
|
||||||
|
<span class="card-author">Elena Marsh</span>
|
||||||
|
<span class="card-meta">Edited Mar 14, 2026</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/* Shared surface for every Mac App Store frame. Rendered at exactly 2560x1600,
|
||||||
|
so every length here is a real output pixel. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--paper: #fcfbf7;
|
||||||
|
--shell: #f1ece2;
|
||||||
|
--ink: #23201b;
|
||||||
|
--ink-soft: #6b6458;
|
||||||
|
--ink-faint: #9b9484;
|
||||||
|
--line-strong: #d6cfbd;
|
||||||
|
|
||||||
|
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
--font-book: "Literata", Georgia, "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 2560px;
|
||||||
|
height: 1600px;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(128% 92% at 50% -14%, var(--paper) 0%, rgba(252, 251, 247, 0) 62%),
|
||||||
|
var(--shell);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
width: 2560px;
|
||||||
|
height: 1600px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 120px 100px 116px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 92px;
|
||||||
|
font-weight: 560;
|
||||||
|
line-height: 1.06;
|
||||||
|
letter-spacing: -0.022em;
|
||||||
|
color: var(--ink);
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support {
|
||||||
|
margin: 30px 0 0;
|
||||||
|
max-width: 1700px;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The app window sitting on the surface, never full bleed. The window itself is
|
||||||
|
live markup authored at the app's own 1440x900 (see app.css) and scaled up to
|
||||||
|
fill this well, so the type stays vector-sharp instead of being resampled. */
|
||||||
|
.shot {
|
||||||
|
position: relative;
|
||||||
|
width: 1600px;
|
||||||
|
height: 1000px;
|
||||||
|
flex: none;
|
||||||
|
margin-top: 100px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--paper);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(35, 32, 27, 0.06),
|
||||||
|
0 10px 26px -8px rgba(35, 32, 27, 0.14),
|
||||||
|
0 46px 96px -26px rgba(35, 32, 27, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot__scale {
|
||||||
|
width: 1440px;
|
||||||
|
height: 900px;
|
||||||
|
transform: scale(1.1111111111);
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Publishing
|
||||||
|
|
||||||
|
Margin goes out through three doors, and they are not the same app.
|
||||||
|
|
||||||
|
The **direct download** from [margin.73ai.org](https://margin.73ai.org) is the full one. It is not
|
||||||
|
sandboxed, it updates itself, and nobody stands between it and the person using it. The **Homebrew
|
||||||
|
cask** is the same file with a command instead of a browser. The **Mac App Store** copy is a
|
||||||
|
sandboxed build with the updater taken out, because Apple requires both. It exists because most
|
||||||
|
people will never download a `.dmg` from GitHub, and telling them to is how you end up with no
|
||||||
|
users rather than principled ones.
|
||||||
|
|
||||||
|
## Signing and notarization
|
||||||
|
|
||||||
|
Every macOS bundle is signed with a Developer ID Application certificate and notarized by Apple
|
||||||
|
before it is published. This is not optional any more: an unsigned bundle on a current macOS opens
|
||||||
|
to "Apple could not verify this app is free of malware" with no obvious way past it, and the way
|
||||||
|
past it that does exist teaches people to click through exactly the warning that is worth reading.
|
||||||
|
|
||||||
|
The release workflow does it. `tauri-action` picks up `APPLE_CERTIFICATE` and
|
||||||
|
`APPLE_CERTIFICATE_PASSWORD` (a base64 `.p12` and its password), signs with
|
||||||
|
`APPLE_SIGNING_IDENTITY`, and notarizes with an App Store Connect API key: `APPLE_API_ISSUER`,
|
||||||
|
`APPLE_API_KEY_ID`, and `APPLE_API_KEY_P8`, the last being the base64 of the `.p8` file. The key is
|
||||||
|
used rather than an Apple ID and app-specific password because the same key does the App Store
|
||||||
|
upload, so there is one credential to rotate instead of two.
|
||||||
|
|
||||||
|
The step after the build is the one that matters. `codesign --verify` only says the signature is
|
||||||
|
internally consistent; `spctl --assess` is what a person double-clicking the file actually meets,
|
||||||
|
and it does not pass until `stapler` has attached the notarization ticket to the bundle. If that
|
||||||
|
step goes green the download works on a machine that has never heard of Margin. If it is skipped
|
||||||
|
because no key was configured, the workflow says so in a warning rather than shipping something
|
||||||
|
that looks fine and is not.
|
||||||
|
|
||||||
|
## Homebrew
|
||||||
|
|
||||||
|
```
|
||||||
|
brew tap priyanshujain/margin
|
||||||
|
brew trust priyanshujain/margin
|
||||||
|
brew install --cask margin
|
||||||
|
```
|
||||||
|
|
||||||
|
Homebrew 6 refuses to load a cask from a tap it has not been told to trust, and the error it raises
|
||||||
|
instead says nothing about installing, so the trust line belongs in every set of instructions
|
||||||
|
rather than being left for people to discover.
|
||||||
|
|
||||||
|
The tap is [priyanshujain/homebrew-margin](https://github.com/priyanshujain/homebrew-margin) rather
|
||||||
|
than upstream `homebrew-cask`, which has a notability bar Margin does not clear yet. What that
|
||||||
|
costs is the two setup lines above, and the tap keeps working as a fallback if the cask ever does
|
||||||
|
go upstream.
|
||||||
|
|
||||||
|
The `homebrew` job at the end of the release workflow downloads the `.dmg` that was just published,
|
||||||
|
takes its sha256, and rewrites the version and hash in `Casks/margin.rb`. It runs after the publish
|
||||||
|
gate, so the cask can never point at a release that is still a draft. Without
|
||||||
|
`HOMEBREW_TAP_DEPLOY_KEY` it warns and does nothing, which leaves the cask on the previous version
|
||||||
|
rather than failing a release that otherwise succeeded.
|
||||||
|
|
||||||
|
That secret is an SSH deploy key registered on the tap, not a personal access token. A token would
|
||||||
|
carry the whole account; the deploy key reaches the tap and nothing else, so a leak from a release
|
||||||
|
job cannot touch the app repositories.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
Both builds check on launch and from the same "Check for Updates…" menu item, and they ask
|
||||||
|
different questions. The direct download asks the updater endpoint in `tauri.release.conf.json`,
|
||||||
|
downloads the new bundle and restarts. The App Store copy asks `itunes.apple.com/lookup` which
|
||||||
|
version is live under the bundle id, and if that is ahead of the one running it offers to open the
|
||||||
|
store page, because an App Store app may not install code and would be rejected for trying.
|
||||||
|
|
||||||
|
Which of the two runs is decided in `updates.rs` by the marker the config carries: the release
|
||||||
|
overlay declares an `updater` plugin, the App Store overlay declares an `appstore` one. A
|
||||||
|
`_MASReceipt` inside the bundle overrides both. That is the check that matters, because it means a
|
||||||
|
bundle which came from the store cannot self-update even if it was built with the updater in it,
|
||||||
|
and the decision does not rest on a config file alone.
|
||||||
|
|
||||||
|
The prompt is a native alert rather than a window the app draws, and it appears once per version.
|
||||||
|
"Later" means later, not later today: nothing is raised again until there is a new version to raise,
|
||||||
|
and the menu item is there in the meantime. Whether to update is the reader's call, and an app that
|
||||||
|
asks the same question at every launch is answering it for them.
|
||||||
|
|
||||||
|
The store copy trails the direct one by however long review takes, so an App Store user being told
|
||||||
|
they are up to date while GitHub has something newer is correct rather than a bug. Each channel
|
||||||
|
compares against its own. Apple's lookup endpoint is also edge cached and can sit a few hours behind
|
||||||
|
a release going live, which is what the timestamp on the request is for.
|
||||||
|
|
||||||
|
## The Mac App Store
|
||||||
|
|
||||||
|
Tauri has no App Store target, so `scripts/mas-package.sh` covers the distance between the `.app`
|
||||||
|
and something App Store Connect will take. It embeds the provisioning profile, resolves the team
|
||||||
|
identifier into the entitlements, signs, and wraps the result with `productbuild`.
|
||||||
|
|
||||||
|
The order is load-bearing. The profile goes in before `codesign` runs, because the signature covers
|
||||||
|
it. Tauri's own signing is switched off in this build for the same reason: it would sign a bundle
|
||||||
|
with no profile in it. Nested code, if there ever is any, is signed before the bundle that contains
|
||||||
|
it, and never with `--deep`, which would apply the app's entitlements to everything inside.
|
||||||
|
|
||||||
|
`CFBundleVersion` comes from the workflow run number. App Store Connect refuses an upload whose
|
||||||
|
build number it has seen before, so a rejected build cannot be resubmitted under the same one, and
|
||||||
|
tying it to the run number means it goes up on its own.
|
||||||
|
|
||||||
|
### The listing and TestFlight
|
||||||
|
|
||||||
|
The store copy lives in `appstore/metadata` as one text file per field, and
|
||||||
|
`scripts/appstore-listing.rb` pushes it. Keeping it in files rather than in the script means
|
||||||
|
changing a description is a diff someone can read, and the listing is reviewable next to the code
|
||||||
|
it describes. The script checks each field against Apple's length limit before sending, because
|
||||||
|
Apple rejects an over-length field with a validation error that never mentions the number.
|
||||||
|
|
||||||
|
The store version is taken from `tauri.conf.json`, the same place every other version in the repo
|
||||||
|
comes from. This matters because App Store Connect rejects a build whose `CFBundleShortVersionString`
|
||||||
|
does not match the version it is uploaded against, and a record created by `produce` starts life at
|
||||||
|
1.0 regardless of what the app actually is.
|
||||||
|
|
||||||
|
Release notes are the one field it will not write on a first version. They describe what changed
|
||||||
|
since the last release, so Apple refuses them when there is no last release, and there would be
|
||||||
|
nothing truthful to say.
|
||||||
|
|
||||||
|
`scripts/testflight-setup.rb` does the beta side: the blurb testers read, the details Beta App
|
||||||
|
Review asks for, and the two groups. None of it needs a build to exist, so it can all be in place
|
||||||
|
before the first upload. Internal testers get builds within minutes of processing. External testers
|
||||||
|
go through Beta App Review, which needs a contact phone number in
|
||||||
|
`appstore/metadata/review_phone.txt`; without it the script says so and carries on, because
|
||||||
|
internal testing does not need it.
|
||||||
|
|
||||||
|
`scripts/appstore-review-detail.rb` writes the store submission's App Review Information: the same
|
||||||
|
contact details and the notes in `appstore/metadata/review_notes.txt`. That is a different record
|
||||||
|
from the TestFlight one, and the two do not share anything. An explanation that only went to
|
||||||
|
TestFlight is invisible both to the reviewer looking at the store submission and to the automated
|
||||||
|
check that runs before a human sees it at all, which is how the first submission was rejected.
|
||||||
|
|
||||||
|
`scripts/appstore-compliance.rb` answers the age rating questionnaire and declares App Privacy.
|
||||||
|
Every content answer is NONE and the privacy answer is that nothing is collected, which is true:
|
||||||
|
there is no telemetry, no account and no server. The Drive backup sends bytes to the account of the
|
||||||
|
person who switched it on, which is not the developer collecting anything. If that ever stops being
|
||||||
|
true, that script is the thing that has to change with it.
|
||||||
|
|
||||||
|
`scripts/appstore-screenshots.rb` uploads the frames in `appstore/screenshots`, replacing whatever
|
||||||
|
is already attached rather than adding to it, so a listing cannot quietly accumulate ten frames
|
||||||
|
across five runs. How the frames themselves are built is in
|
||||||
|
[appstore/screenshots/README.md](../appstore/screenshots/README.md); they are a CSS rebuild of the
|
||||||
|
app rendered at 2560x1600, not a screen capture, because the only Macs to hand have 1x displays and
|
||||||
|
an upscaled capture looks like one.
|
||||||
|
|
||||||
|
Screenshots gate submitting for review, and nothing else: not the app record, not a build upload,
|
||||||
|
and not either tier of TestFlight.
|
||||||
|
|
||||||
|
`scripts/mas-upload-local.sh` builds, signs, packages and uploads from a developer's own machine
|
||||||
|
rather than CI, using a keychain that exists only for the length of the run. `scripts/testflight-release.rb`
|
||||||
|
then waits for Apple to finish processing, assigns the build to the external group, submits it for
|
||||||
|
beta app review, and attaches it to the store version.
|
||||||
|
|
||||||
|
That last step is easy to miss and does not announce itself. Assigning a build to TestFlight and
|
||||||
|
attaching it to the store version are separate operations, and a Mac listing takes its app icon
|
||||||
|
from the attached build. Until it is attached, App Store Connect shows the listing with no icon and
|
||||||
|
does not say why.
|
||||||
|
|
||||||
|
The Apple ID this is driven with can see more than one App Store Connect team, and the other one
|
||||||
|
belongs to somebody else, so both scripts pin `FASTLANE_ITC_TEAM_ID` rather than letting spaceship
|
||||||
|
choose. Do not remove that.
|
||||||
|
|
||||||
|
### What the sandbox costs
|
||||||
|
|
||||||
|
The App Store build declares four entitlements, in `src-tauri/entitlements.mas.plist`, and each one
|
||||||
|
is there for a reason worth being able to defend in review. `network.client` is the Google Drive
|
||||||
|
API and the version lookup above. `network.server` is the loopback listener the Drive OAuth flow
|
||||||
|
redirects to, which is the only installed-app flow Google still supports.
|
||||||
|
|
||||||
|
`network.server` is not a theoretical risk. An automated check rejects any submission that declares
|
||||||
|
it, before review, unless the App Review Information says what listens and why, so
|
||||||
|
`appstore/metadata/review_notes.txt` explains the loopback bind first and at length: that it is on
|
||||||
|
127.0.0.1 and never a routable interface, that it lives only for the duration of a sign-in, that it
|
||||||
|
times out, and how to reach the feature in the app. A rejection on this also has to be answered in
|
||||||
|
Resolution Center by hand, since that is not in the App Store Connect API.
|
||||||
|
|
||||||
|
`files.user-selected.read-write` covers EPUB import and PDF and EPUB export, all of which go
|
||||||
|
through a panel, so the app only ever reaches the one file that was pointed at. The library needs
|
||||||
|
nothing: it lives in the container.
|
||||||
|
|
||||||
|
Three things are different in that build, and all three are Apple's rules rather than choices:
|
||||||
|
|
||||||
|
The updater is gone. `lib.rs` registers the updater plugin only when the config declares it, and
|
||||||
|
only `tauri.release.conf.json` does, so building against `tauri.appstore.conf.json` leaves it out
|
||||||
|
by construction. The menu item stays, pointed at the App Store instead, which is what the previous
|
||||||
|
section is about.
|
||||||
|
|
||||||
|
The library moves. Sandboxed, `app_data_dir()` resolves inside
|
||||||
|
`~/Library/Containers/studio.margin.app`, not `~/Library/Application Support`. Someone who switches
|
||||||
|
from the direct download to the App Store copy sees an empty library. There is no migration and no
|
||||||
|
plan for one; if that ever matters to a real person it is a first-run import, not a sync.
|
||||||
|
|
||||||
|
System spelling additions are gone. `proofing.rs` reads `~/Library/Spelling/LocalDictionary` to
|
||||||
|
pick up words added elsewhere on the Mac, and the sandbox denies it. The read already fails
|
||||||
|
quietly, so the app is fine, it just knows fewer of your words.
|
||||||
|
|
||||||
|
## Certificates
|
||||||
|
|
||||||
|
Three, and they do different jobs. **Developer ID Application** signs the direct download and is
|
||||||
|
what notarization checks. **Mac App Distribution** signs the App Store bundle. **Mac Installer
|
||||||
|
Distribution** signs the `.pkg` that wraps it. A Developer ID certificate is not accepted by the
|
||||||
|
App Store and a Mac App Distribution one will not notarize, so there is no combining them.
|
||||||
|
|
||||||
|
`scripts/apple-provision.rb` creates all three, registers the App ID, and makes the provisioning
|
||||||
|
profile, driving the Developer Portal through fastlane's spaceship rather than the website. It is
|
||||||
|
find-or-create throughout, so rerunning it is safe. That matters most for the Developer ID
|
||||||
|
certificate: an account may hold only a handful, they cannot be un-revoked, and every copy of the
|
||||||
|
app already signed by one stops verifying if it goes away.
|
||||||
|
|
||||||
|
It has to be run by a person, not by CI or an agent, because it prompts for the Apple ID password
|
||||||
|
and a two-factor code:
|
||||||
|
|
||||||
|
```
|
||||||
|
[email protected] BUNDLE_ID=studio.margin.app APP_NAME=Margin ruby scripts/apple-provision.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
The private keys live in `~/.margin-signing` and are never sent to Apple, the way the updater key
|
||||||
|
lives in `~/.tauri`. They were generated with `openssl` up front so that Apple only ever sees a
|
||||||
|
certificate signing request, and so the `.p12` that CI uses can be assembled locally. Each `.p12`
|
||||||
|
carries Apple's intermediate certificate alongside the leaf, because a fresh CI keychain holding
|
||||||
|
only the leaf fails to build a chain to the root and `codesign` stops with an error that does not
|
||||||
|
say so.
|
||||||
|
|
||||||
|
`scripts/apple-secrets.sh` then pipes all of it into the repository's Actions secrets without
|
||||||
|
printing any of it. The one secret it cannot produce is `HOMEBREW_TAP_DEPLOY_KEY`, which is set up
|
||||||
|
alongside the tap rather than by anything Apple issued.
|
||||||
|
|
||||||
|
One step has no API and has to be done on the website: an App Store Connect API key, under Users
|
||||||
|
and Access, Integrations. The `.p8` downloads exactly once. Put it at `~/.margin-signing/AuthKey.p8`
|
||||||
|
with its key ID and issuer ID in `AuthKey.env` beside it, and rerun the secrets script.
|
||||||
|
|
||||||
|
Certificates expire after five years and are replaceable; the keys are not backed up anywhere else,
|
||||||
|
so back them up somewhere that is not this machine.
|
||||||
+8
-3
@@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "margin-app",
|
"name": "margin-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.12",
|
"version": "0.1.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"dmg": "tauri build --bundles dmg"
|
"dmg": "tauri build --bundles dmg",
|
||||||
|
"fonts:sync": "node node_modules/margin-shared/bin/sync-fonts.mjs .",
|
||||||
|
"fonts:check": "node node_modules/margin-shared/bin/sync-fonts.mjs . --check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
"@tiptap/pm": "^3.27.1",
|
"@tiptap/pm": "^3.27.1",
|
||||||
"@tiptap/react": "^3.27.1",
|
"@tiptap/react": "^3.27.1",
|
||||||
"@tiptap/starter-kit": "^3.27.1",
|
"@tiptap/starter-kit": "^3.27.1",
|
||||||
|
"margin-shared": "file:./shared",
|
||||||
"pdfjs-dist": "^6.0.227",
|
"pdfjs-dist": "^6.0.227",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@@ -33,5 +36,7 @@
|
|||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
}
|
},
|
||||||
|
"description": "A calm, offline writing studio for authors",
|
||||||
|
"license": "SEE LICENSE IN LICENSE"
|
||||||
}
|
}
|
||||||
Generated
+9
@@ -38,6 +38,9 @@ importers:
|
|||||||
'@tiptap/starter-kit':
|
'@tiptap/starter-kit':
|
||||||
specifier: ^3.27.1
|
specifier: ^3.27.1
|
||||||
version: 3.27.1
|
version: 3.27.1
|
||||||
|
margin-shared:
|
||||||
|
specifier: file:./shared
|
||||||
|
version: file:shared
|
||||||
pdfjs-dist:
|
pdfjs-dist:
|
||||||
specifier: ^6.0.227
|
specifier: ^6.0.227
|
||||||
version: 6.0.227
|
version: 6.0.227
|
||||||
@@ -887,6 +890,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
|
margin-shared@file:shared:
|
||||||
|
resolution: {directory: shared, type: directory}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -1784,6 +1791,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
margin-shared@file:shared: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
Executable
+189
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Create the Apple Developer resources a release needs, and turn them into files CI can use.
|
||||||
|
#
|
||||||
|
# Everything here is find-or-create, so running it twice does nothing the second time. That matters
|
||||||
|
# most for the Developer ID certificate: an account may hold only a handful, they cannot be
|
||||||
|
# un-revoked, and every copy of the app already signed by one stops verifying if it goes away.
|
||||||
|
#
|
||||||
|
# The private keys are never sent to Apple and never leave ~/.margin-signing. Apple only ever sees
|
||||||
|
# the certificate signing requests, which is the whole point of generating them with openssl up
|
||||||
|
# front rather than letting a tool make its own.
|
||||||
|
#
|
||||||
|
# BUNDLE_ID=studio.margin.app APP_NAME=Margin ruby scripts/apple-provision.rb
|
||||||
|
#
|
||||||
|
# Run it yourself rather than through an agent: the Apple ID password and the two-factor code are
|
||||||
|
# prompted for on the terminal.
|
||||||
|
begin
|
||||||
|
require "spaceship"
|
||||||
|
rescue LoadError
|
||||||
|
# Homebrew vendors fastlane's gems under libexec instead of putting them on the default gem
|
||||||
|
# path, so spaceship is not requirable until that directory is added to it.
|
||||||
|
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
|
||||||
|
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
|
||||||
|
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
|
||||||
|
Gem.clear_paths
|
||||||
|
require "spaceship"
|
||||||
|
end
|
||||||
|
|
||||||
|
require "openssl"
|
||||||
|
require "fileutils"
|
||||||
|
require "securerandom"
|
||||||
|
require "net/http"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
# Listing profiles otherwise goes through developerservices2.apple.com, Apple's Xcode-only
|
||||||
|
# endpoint, which rejects a plain spaceship session with "Please update to Xcode 7.3 or later"
|
||||||
|
# no matter how current Xcode actually is. This routes it back to the ordinary portal API.
|
||||||
|
ENV["SPACESHIP_AVOID_XCODE_API"] = "1"
|
||||||
|
|
||||||
|
DIR = File.expand_path("~/.margin-signing")
|
||||||
|
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
|
||||||
|
APP_NAME = ENV.fetch("APP_NAME", "Margin")
|
||||||
|
EMAIL = ENV["APPLE_EMAIL"]
|
||||||
|
|
||||||
|
# Apple's intermediates. codesign builds a chain from the leaf up, so a .p12 holding only the leaf
|
||||||
|
# and its key fails on a fresh CI keychain with "unable to build chain to self-signed root".
|
||||||
|
INTERMEDIATES = {
|
||||||
|
"AppleWWDRCAG3" => "https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer",
|
||||||
|
"DeveloperIDG2CA" => "https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer",
|
||||||
|
}
|
||||||
|
|
||||||
|
CERTS = [
|
||||||
|
{ key: "developer-id", klass: Spaceship::Portal::Certificate::DeveloperIdApplication,
|
||||||
|
label: "Developer ID Application (direct download, notarized)", ca: "DeveloperIDG2CA" },
|
||||||
|
{ key: "apple-distribution", klass: Spaceship::Portal::Certificate::MacAppDistribution,
|
||||||
|
label: "Mac App Distribution (App Store .app)", ca: "AppleWWDRCAG3" },
|
||||||
|
{ key: "mac-installer", klass: Spaceship::Portal::Certificate::MacInstallerDistribution,
|
||||||
|
label: "Mac Installer Distribution (App Store .pkg)", ca: "AppleWWDRCAG3" },
|
||||||
|
]
|
||||||
|
|
||||||
|
def common_name(cert)
|
||||||
|
cert.subject.to_a.find { |n, _, _| n == "CN" }&.at(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_intermediate(name)
|
||||||
|
path = File.join(DIR, "#{name}.cer")
|
||||||
|
unless File.exist?(path)
|
||||||
|
uri = URI(INTERMEDIATES.fetch(name))
|
||||||
|
File.binwrite(path, Net::HTTP.get(uri))
|
||||||
|
end
|
||||||
|
OpenSSL::X509::Certificate.new(File.binread(path))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Confirm macOS can actually read the bundle, because the failure mode otherwise shows up days
|
||||||
|
# later inside a CI keychain as "wrong password" rather than as anything about the format.
|
||||||
|
def importable?(p12_path, password)
|
||||||
|
keychain = File.join(Dir.tmpdir, "margin-p12-check-#{SecureRandom.hex(4)}.keychain-db")
|
||||||
|
system("security", "create-keychain", "-p", "check", keychain, out: File::NULL, err: File::NULL)
|
||||||
|
system("security", "unlock-keychain", "-p", "check", keychain, out: File::NULL, err: File::NULL)
|
||||||
|
ok = system("security", "import", p12_path, "-k", keychain, "-P", password,
|
||||||
|
out: File::NULL, err: File::NULL)
|
||||||
|
system("security", "delete-keychain", keychain, out: File::NULL, err: File::NULL)
|
||||||
|
ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns nil on success, or a sentence saying what went wrong.
|
||||||
|
def write_p12(spec, cert)
|
||||||
|
key_path = File.join(DIR, "#{spec[:key]}.key")
|
||||||
|
unless cert.check_private_key(OpenSSL::PKey::RSA.new(File.read(key_path)))
|
||||||
|
return "the issued certificate does not match the local private key, so Apple issued it " \
|
||||||
|
"against a different CSR. Revoke it in the portal and rerun."
|
||||||
|
end
|
||||||
|
|
||||||
|
password = SecureRandom.hex(24)
|
||||||
|
p12_path = File.join(DIR, "#{spec[:key]}.p12")
|
||||||
|
|
||||||
|
built = Dir.mktmpdir do |tmp|
|
||||||
|
leaf = File.join(tmp, "leaf.pem")
|
||||||
|
ca = File.join(tmp, "ca.pem")
|
||||||
|
File.write(leaf, cert.to_pem)
|
||||||
|
File.write(ca, fetch_intermediate(spec[:ca]).to_pem)
|
||||||
|
|
||||||
|
# Ruby links OpenSSL 3, whose PKCS12 default MAC is SHA-256. Apple's Security framework reads
|
||||||
|
# only the legacy SHA-1 MAC and reports the mismatch as a wrong password, so the bundle has to
|
||||||
|
# come from the LibreSSL at /usr/bin/openssl, which still writes the older format. The password
|
||||||
|
# goes through the environment rather than argv so it stays out of the process list.
|
||||||
|
system({ "P12PASS" => password }, "/usr/bin/openssl", "pkcs12", "-export",
|
||||||
|
"-inkey", key_path, "-in", leaf, "-certfile", ca,
|
||||||
|
"-name", common_name(cert), "-passout", "env:P12PASS", "-out", p12_path,
|
||||||
|
out: File::NULL, err: File::NULL)
|
||||||
|
end
|
||||||
|
|
||||||
|
return "/usr/bin/openssl could not build the bundle." unless built
|
||||||
|
return "macOS refused to import the bundle that was just built." unless importable?(p12_path, password)
|
||||||
|
|
||||||
|
File.write(File.join(DIR, "#{spec[:key]}.p12.pass"), password)
|
||||||
|
File.chmod(0o600, p12_path, File.join(DIR, "#{spec[:key]}.p12.pass"))
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
FileUtils.mkdir_p(DIR)
|
||||||
|
Spaceship::Portal.login(EMAIL)
|
||||||
|
Spaceship::Portal.select_team
|
||||||
|
team_id = Spaceship::Portal.client.team_id
|
||||||
|
puts "Team ID: #{team_id}"
|
||||||
|
puts
|
||||||
|
|
||||||
|
identities = {}
|
||||||
|
|
||||||
|
CERTS.each do |spec|
|
||||||
|
existing = spec[:klass].all.select { |c| c.status == "Issued" }
|
||||||
|
cert_obj = existing.first
|
||||||
|
|
||||||
|
if cert_obj
|
||||||
|
puts "#{spec[:label]}: already exists (#{cert_obj.id}), not creating another."
|
||||||
|
else
|
||||||
|
csr = File.read(File.join(DIR, "#{spec[:key]}.csr"))
|
||||||
|
cert_obj = spec[:klass].create!(csr: csr)
|
||||||
|
puts "#{spec[:label]}: created (#{cert_obj.id})."
|
||||||
|
end
|
||||||
|
|
||||||
|
x509 = cert_obj.download
|
||||||
|
File.binwrite(File.join(DIR, "#{spec[:key]}.cer"), x509.to_der)
|
||||||
|
|
||||||
|
identities[spec[:key]] = common_name(x509)
|
||||||
|
puts " identity: #{common_name(x509)}"
|
||||||
|
|
||||||
|
problem = write_p12(spec, x509)
|
||||||
|
puts(problem ? " no p12: #{problem}" : " wrote #{spec[:key]}.p12")
|
||||||
|
puts
|
||||||
|
end
|
||||||
|
|
||||||
|
app = Spaceship::Portal::App.find(BUNDLE_ID, mac: true)
|
||||||
|
if app
|
||||||
|
puts "App ID #{BUNDLE_ID}: already registered."
|
||||||
|
else
|
||||||
|
app = Spaceship::Portal::App.create!(bundle_id: BUNDLE_ID, name: APP_NAME, mac: true)
|
||||||
|
puts "App ID #{BUNDLE_ID}: registered."
|
||||||
|
end
|
||||||
|
|
||||||
|
profile_name = "#{APP_NAME} App Store"
|
||||||
|
profile = Spaceship::Portal::ProvisioningProfile::AppStore.all(mac: true).find do |p|
|
||||||
|
p.app.bundle_id == BUNDLE_ID && p.status == "Active"
|
||||||
|
end
|
||||||
|
|
||||||
|
if profile
|
||||||
|
puts "Provisioning profile: reusing #{profile.name}."
|
||||||
|
else
|
||||||
|
mas_cert = Spaceship::Portal::Certificate::MacAppDistribution.all.first
|
||||||
|
profile = Spaceship::Portal::ProvisioningProfile::AppStore.create!(
|
||||||
|
name: profile_name, bundle_id: BUNDLE_ID, certificate: mas_cert, mac: true
|
||||||
|
)
|
||||||
|
puts "Provisioning profile: created #{profile.name}."
|
||||||
|
end
|
||||||
|
|
||||||
|
profile_path = File.join(DIR, "#{BUNDLE_ID}.provisionprofile")
|
||||||
|
File.binwrite(profile_path, profile.download)
|
||||||
|
File.chmod(0o600, profile_path)
|
||||||
|
|
||||||
|
File.write(File.join(DIR, "#{BUNDLE_ID}.env"), <<~ENV)
|
||||||
|
APPLE_TEAM_ID="#{team_id}"
|
||||||
|
APPLE_SIGNING_IDENTITY="#{identities['developer-id']}"
|
||||||
|
MAS_APP_IDENTITY="#{identities['apple-distribution']}"
|
||||||
|
MAS_INSTALLER_IDENTITY="#{identities['mac-installer']}"
|
||||||
|
ENV
|
||||||
|
|
||||||
|
puts
|
||||||
|
puts "Wrote #{profile_path} and #{BUNDLE_ID}.env into #{DIR}."
|
||||||
|
puts "Still to do by hand, because Apple has no API for it: create an App Store Connect API key"
|
||||||
|
puts "(Users and Access, Integrations) and save the .p8 as #{DIR}/AuthKey.p8."
|
||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Push everything apple-provision.rb produced into this repo's GitHub Actions secrets.
|
||||||
|
#
|
||||||
|
# Values are piped from the files straight into `gh`, never echoed, so running this in a shared
|
||||||
|
# terminal or through an agent does not leak a signing key into the scrollback.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DIR="${MARGIN_SIGNING_DIR:-$HOME/.margin-signing}"
|
||||||
|
BUNDLE_ID="${BUNDLE_ID:-studio.margin.app}"
|
||||||
|
REPO="${REPO:-priyanshujain/margin}"
|
||||||
|
ENV_FILE="$DIR/$BUNDLE_ID.env"
|
||||||
|
|
||||||
|
[ -f "$ENV_FILE" ] || { echo "apple-secrets: $ENV_FILE is missing; run apple-provision.rb first." >&2; exit 1; }
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
set -a; . "$ENV_FILE"; set +a
|
||||||
|
|
||||||
|
set_plain() {
|
||||||
|
printf '%s' "$2" | gh secret set "$1" --repo "$REPO" --body -
|
||||||
|
echo " set $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_b64() {
|
||||||
|
[ -f "$2" ] || { echo " skipped $1 ($2 is missing)"; return; }
|
||||||
|
base64 -i "$2" | tr -d '\n' | gh secret set "$1" --repo "$REPO" --body -
|
||||||
|
echo " set $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_file() {
|
||||||
|
[ -f "$2" ] || { echo " skipped $1 ($2 is missing)"; return; }
|
||||||
|
gh secret set "$1" --repo "$REPO" < "$2"
|
||||||
|
echo " set $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Signing identities and team:"
|
||||||
|
set_plain APPLE_TEAM_ID "$APPLE_TEAM_ID"
|
||||||
|
set_plain APPLE_SIGNING_IDENTITY "$APPLE_SIGNING_IDENTITY"
|
||||||
|
set_plain MAS_APP_IDENTITY "$MAS_APP_IDENTITY"
|
||||||
|
set_plain MAS_INSTALLER_IDENTITY "$MAS_INSTALLER_IDENTITY"
|
||||||
|
|
||||||
|
echo "Certificates:"
|
||||||
|
set_b64 APPLE_CERTIFICATE "$DIR/developer-id.p12"
|
||||||
|
set_file APPLE_CERTIFICATE_PASSWORD "$DIR/developer-id.p12.pass"
|
||||||
|
set_b64 MAS_APP_CERTIFICATE "$DIR/apple-distribution.p12"
|
||||||
|
set_file MAS_APP_CERTIFICATE_PASSWORD "$DIR/apple-distribution.p12.pass"
|
||||||
|
set_b64 MAS_INSTALLER_CERTIFICATE "$DIR/mac-installer.p12"
|
||||||
|
set_file MAS_INSTALLER_CERTIFICATE_PASSWORD "$DIR/mac-installer.p12.pass"
|
||||||
|
set_b64 MAS_PROVISION_PROFILE "$DIR/$BUNDLE_ID.provisionprofile"
|
||||||
|
|
||||||
|
echo "App Store Connect API key:"
|
||||||
|
if [ -f "$DIR/AuthKey.p8" ] && [ -f "$DIR/AuthKey.env" ]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
set -a; . "$DIR/AuthKey.env"; set +a
|
||||||
|
# An empty value here would be accepted by `gh` and then fail notarization as an auth error that
|
||||||
|
# says nothing about a missing issuer, so refuse it at the point the mistake is still visible.
|
||||||
|
if [ -z "${APPLE_API_KEY_ID:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then
|
||||||
|
echo " refused: AuthKey.env is missing APPLE_API_KEY_ID or APPLE_API_ISSUER." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
set_plain APPLE_API_KEY_ID "$APPLE_API_KEY_ID"
|
||||||
|
set_plain APPLE_API_ISSUER "$APPLE_API_ISSUER"
|
||||||
|
set_b64 APPLE_API_KEY_P8 "$DIR/AuthKey.p8"
|
||||||
|
else
|
||||||
|
echo " skipped: put the .p8 at $DIR/AuthKey.p8 and write $DIR/AuthKey.env with"
|
||||||
|
echo " APPLE_API_KEY_ID= and APPLE_API_ISSUER=, then rerun."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Not handled here: HOMEBREW_TAP_DEPLOY_KEY, an SSH deploy key on the tap rather than"
|
||||||
|
echo "anything Apple issued."
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Declare the age rating and set pricing. These are the two things App Store Connect will not let a
|
||||||
|
# submission through without, and neither has anything to do with the copy in appstore-listing.rb.
|
||||||
|
#
|
||||||
|
# [email protected] ruby scripts/appstore-compliance.rb
|
||||||
|
#
|
||||||
|
# Every content answer here is NONE because Margin is an editor for words the person using it wrote
|
||||||
|
# themselves. It ships no media, has no feed, no other users, and no in-app browser. If any of that
|
||||||
|
# ever stops being true, this file is the thing that has to change with it.
|
||||||
|
begin
|
||||||
|
require "spaceship"
|
||||||
|
rescue LoadError
|
||||||
|
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
|
||||||
|
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
|
||||||
|
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
|
||||||
|
Gem.clear_paths
|
||||||
|
require "spaceship"
|
||||||
|
end
|
||||||
|
|
||||||
|
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
|
||||||
|
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
|
||||||
|
|
||||||
|
NONE = Spaceship::ConnectAPI::AgeRatingDeclaration::Rating::NONE
|
||||||
|
|
||||||
|
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
|
||||||
|
|
||||||
|
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
|
||||||
|
abort "No app for #{BUNDLE_ID}." unless app
|
||||||
|
puts "#{app.name} (#{app.id})"
|
||||||
|
|
||||||
|
info = app.fetch_edit_app_info
|
||||||
|
abort "No editable app info." unless info
|
||||||
|
|
||||||
|
declaration = info.fetch_age_rating_declaration
|
||||||
|
abort "No age rating declaration to write to." unless declaration
|
||||||
|
|
||||||
|
graded = {
|
||||||
|
alcoholTobaccoOrDrugUseOrReferences: NONE,
|
||||||
|
contests: NONE,
|
||||||
|
gamblingSimulated: NONE,
|
||||||
|
gunsOrOtherWeapons: NONE,
|
||||||
|
horrorOrFearThemes: NONE,
|
||||||
|
matureOrSuggestiveThemes: NONE,
|
||||||
|
medicalOrTreatmentInformation: NONE,
|
||||||
|
profanityOrCrudeHumor: NONE,
|
||||||
|
sexualContentGraphicAndNudity: NONE,
|
||||||
|
sexualContentOrNudity: NONE,
|
||||||
|
violenceCartoonOrFantasy: NONE,
|
||||||
|
violenceRealistic: NONE,
|
||||||
|
violenceRealisticProlongedGraphicOrSadistic: NONE,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The app opens links in the system browser rather than rendering the web itself, and the only
|
||||||
|
# content it ever shows is the person's own writing, so there is no unrestricted web access and no
|
||||||
|
# user generated content in the sense Apple means: content from other people.
|
||||||
|
boolean = {
|
||||||
|
advertising: false,
|
||||||
|
ageAssurance: false,
|
||||||
|
gambling: false,
|
||||||
|
healthOrWellnessTopics: false,
|
||||||
|
lootBox: false,
|
||||||
|
messagingAndChat: false,
|
||||||
|
parentalControls: false,
|
||||||
|
unrestrictedWebAccess: false,
|
||||||
|
userGeneratedContent: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
declaration.update(attributes: graded.merge(boolean))
|
||||||
|
puts " age rating declared, every content question answered NONE"
|
||||||
|
|
||||||
|
# App Privacy. Margin has no telemetry, no accounts and no server of its own, so nothing is
|
||||||
|
# collected. The Google Drive backup is the one thing that sends bytes anywhere, and it sends them
|
||||||
|
# to the account of the person who turned it on, which is not the developer collecting anything.
|
||||||
|
usages = Spaceship::ConnectAPI::AppDataUsage.all(
|
||||||
|
app_id: app.id, includes: "category,grouping,purpose,dataProtection"
|
||||||
|
)
|
||||||
|
|
||||||
|
if usages.any?(&:is_not_collected?)
|
||||||
|
puts " privacy already declared as data not collected"
|
||||||
|
else
|
||||||
|
Spaceship::ConnectAPI::AppDataUsage.create(app_id: app.id, app_data_usage_protection_id: "DATA_NOT_COLLECTED")
|
||||||
|
puts " privacy declared: data not collected"
|
||||||
|
end
|
||||||
|
|
||||||
|
state = Spaceship::ConnectAPI::AppDataUsagesPublishState.get(app_id: app.id)
|
||||||
|
if state.published
|
||||||
|
puts " privacy declaration already published"
|
||||||
|
else
|
||||||
|
state.publish!
|
||||||
|
puts " privacy declaration published"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Free, everywhere. Territory availability is left alone: the default is all of them.
|
||||||
|
app.update(attributes: { pricing: [] }) if ENV["SET_PRICING"]
|
||||||
|
puts " pricing left as configured (the app is free, which is the default for a new record)"
|
||||||
Executable
+122
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Push the listing copy in appstore/metadata to App Store Connect.
|
||||||
|
#
|
||||||
|
# The copy lives in text files rather than in here so that changing a description is a diff someone
|
||||||
|
# can read, and so the store listing is reviewable in the same place as the code it describes.
|
||||||
|
#
|
||||||
|
# [email protected] ruby scripts/appstore-listing.rb
|
||||||
|
#
|
||||||
|
# Run it yourself: the Apple ID login prompts for a two-factor code the first time each month.
|
||||||
|
begin
|
||||||
|
require "spaceship"
|
||||||
|
rescue LoadError
|
||||||
|
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
|
||||||
|
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
|
||||||
|
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
|
||||||
|
Gem.clear_paths
|
||||||
|
require "spaceship"
|
||||||
|
end
|
||||||
|
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
DIR = ENV.fetch("METADATA_DIR", "appstore/metadata")
|
||||||
|
LOCALE = "en-US"
|
||||||
|
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
|
||||||
|
|
||||||
|
# This Apple ID can see more than one App Store Connect team, and the wrong one belongs to someone
|
||||||
|
# else entirely. Pin it rather than letting spaceship pick.
|
||||||
|
ITC_TEAM_ID = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
|
||||||
|
ENV["FASTLANE_ITC_TEAM_ID"] = ITC_TEAM_ID
|
||||||
|
|
||||||
|
# Apple rejects an over-length field with a validation error that does not name the limit, so the
|
||||||
|
# check belongs here where the number is visible.
|
||||||
|
LIMITS = {
|
||||||
|
"name" => 30, "subtitle" => 30, "keywords" => 100,
|
||||||
|
"promotional_text" => 170, "description" => 4000,
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def field(name, localized: true)
|
||||||
|
path = localized ? File.join(DIR, LOCALE, "#{name}.txt") : File.join(DIR, "#{name}.txt")
|
||||||
|
return nil unless File.exist?(path)
|
||||||
|
|
||||||
|
value = File.read(path).strip
|
||||||
|
limit = LIMITS[name]
|
||||||
|
abort "#{name} is #{value.length} characters, over Apple's limit of #{limit}." if limit && value.length > limit
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
|
||||||
|
|
||||||
|
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
|
||||||
|
abort "No app on team #{ITC_TEAM_ID} for #{BUNDLE_ID}. Create it with produce first." unless app
|
||||||
|
puts "#{app.name} (#{app.id})"
|
||||||
|
|
||||||
|
info = app.fetch_edit_app_info
|
||||||
|
abort "No editable app info; the listing may already be in review." unless info
|
||||||
|
|
||||||
|
localization = info.get_app_info_localizations.find { |l| l.locale == LOCALE }
|
||||||
|
localization ||= info.create_app_info_localization(attributes: { locale: LOCALE })
|
||||||
|
# The name is the one field a person is likely to change by hand in App Store Connect, and losing
|
||||||
|
# somebody's naming decision to a stale text file is not a good trade. So divergence is reported
|
||||||
|
# and the live value kept, rather than overwritten.
|
||||||
|
attributes = { subtitle: field("subtitle"), privacyPolicyUrl: field("privacy_url") }
|
||||||
|
wanted_name = field("name")
|
||||||
|
if wanted_name && localization.name && wanted_name != localization.name
|
||||||
|
puts " keeping the name set in App Store Connect (#{localization.name.inspect});"
|
||||||
|
puts " #{DIR}/#{LOCALE}/name.txt says #{wanted_name.inspect}. Update the file to match, or pass"
|
||||||
|
puts " FORCE_NAME=1 to make the file win."
|
||||||
|
attributes[:name] = wanted_name if ENV["FORCE_NAME"]
|
||||||
|
else
|
||||||
|
attributes[:name] = wanted_name
|
||||||
|
end
|
||||||
|
|
||||||
|
localization.update(attributes: attributes)
|
||||||
|
puts " subtitle and privacy policy set"
|
||||||
|
|
||||||
|
category = field("primary_category", localized: false)
|
||||||
|
if category
|
||||||
|
info.update_categories(category_id_map: { primary_category_id: category })
|
||||||
|
puts " primary category set to #{category}"
|
||||||
|
end
|
||||||
|
|
||||||
|
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
|
||||||
|
abort "No editable macOS version to write to." unless version
|
||||||
|
|
||||||
|
# The store version has to match the CFBundleShortVersionString of the build that will be uploaded
|
||||||
|
# against it, and that comes from tauri.conf.json like every other version in the repo. Left alone,
|
||||||
|
# a record created by `produce` sits at 1.0 and rejects the first build with a version mismatch.
|
||||||
|
app_version = JSON.parse(File.read("src-tauri/tauri.conf.json"))["version"]
|
||||||
|
if version.version_string != app_version
|
||||||
|
version.update(attributes: { versionString: app_version })
|
||||||
|
puts " version corrected from #{version.version_string} to #{app_version}"
|
||||||
|
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
|
||||||
|
end
|
||||||
|
|
||||||
|
version_localization = version.get_app_store_version_localizations.find { |l| l.locale == LOCALE }
|
||||||
|
version_localization ||= version.create_app_store_version_localization(attributes: { locale: LOCALE })
|
||||||
|
attributes = {
|
||||||
|
description: field("description"),
|
||||||
|
keywords: field("keywords"),
|
||||||
|
promotionalText: field("promotional_text"),
|
||||||
|
supportUrl: field("support_url"),
|
||||||
|
marketingUrl: field("marketing_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Release notes describe what changed since the last release, so Apple refuses them on a first
|
||||||
|
# version and there is nothing truthful to put there anyway.
|
||||||
|
if app.get_live_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
|
||||||
|
attributes[:whatsNew] = field("release_notes")
|
||||||
|
else
|
||||||
|
puts " skipping release notes: nothing has shipped yet for them to be relative to"
|
||||||
|
end
|
||||||
|
|
||||||
|
version_localization.update(attributes: attributes)
|
||||||
|
puts " description, keywords and links set on version #{version.version_string}"
|
||||||
|
|
||||||
|
copyright = field("copyright", localized: false)
|
||||||
|
version.update(attributes: { copyright: copyright }) if copyright
|
||||||
|
puts " copyright set to #{copyright}" if copyright
|
||||||
|
|
||||||
|
puts
|
||||||
|
puts "Screenshots are not set here. They are required to submit for review, but not to upload a"
|
||||||
|
puts "build or to run either tier of TestFlight."
|
||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Push the App Review Information for the store submission: who to contact, and the notes that
|
||||||
|
# explain anything a reviewer would otherwise have to guess at.
|
||||||
|
#
|
||||||
|
# [email protected] ruby scripts/appstore-review-detail.rb
|
||||||
|
#
|
||||||
|
# This is a different record from the TestFlight one that testflight-setup.rb writes. Beta review
|
||||||
|
# and store review do not share notes, so an explanation that only went to TestFlight is invisible
|
||||||
|
# to the reviewer looking at the store submission, and to the automated entitlement check that runs
|
||||||
|
# before a human sees it at all.
|
||||||
|
begin
|
||||||
|
require "spaceship"
|
||||||
|
rescue LoadError
|
||||||
|
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
|
||||||
|
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
|
||||||
|
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
|
||||||
|
Gem.clear_paths
|
||||||
|
require "spaceship"
|
||||||
|
end
|
||||||
|
|
||||||
|
DIR = ENV.fetch("METADATA_DIR", "appstore/metadata")
|
||||||
|
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
|
||||||
|
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
|
||||||
|
|
||||||
|
NOTES_LIMIT = 4000
|
||||||
|
|
||||||
|
def field(name)
|
||||||
|
path = File.join(DIR, "#{name}.txt")
|
||||||
|
return nil unless File.exist?(path)
|
||||||
|
|
||||||
|
File.read(path).strip
|
||||||
|
end
|
||||||
|
|
||||||
|
notes = field("review_notes")
|
||||||
|
abort "#{DIR}/review_notes.txt is missing." unless notes
|
||||||
|
abort "review_notes is #{notes.length} characters, over Apple's limit of #{NOTES_LIMIT}." if notes.length > NOTES_LIMIT
|
||||||
|
|
||||||
|
phone = field("review_phone")
|
||||||
|
abort "#{DIR}/review_phone.txt is missing and Apple requires a contact number." unless phone
|
||||||
|
|
||||||
|
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
|
||||||
|
|
||||||
|
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
|
||||||
|
abort "No app for #{BUNDLE_ID}." unless app
|
||||||
|
puts "#{app.name} (#{app.id})"
|
||||||
|
|
||||||
|
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
|
||||||
|
abort "No editable macOS version; the submission may already be in review." unless version
|
||||||
|
puts " version #{version.version_string} (#{version.app_store_state})"
|
||||||
|
|
||||||
|
attributes = {
|
||||||
|
contactFirstName: field("review_first_name"),
|
||||||
|
contactLastName: field("review_last_name"),
|
||||||
|
contactEmail: field("review_email"),
|
||||||
|
contactPhone: phone,
|
||||||
|
# Margin has no accounts at all, so there is nothing for a reviewer to sign in to. Saying so
|
||||||
|
# explicitly is what stops the review coming back asking for credentials.
|
||||||
|
demoAccountRequired: false,
|
||||||
|
notes: notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
detail = version.fetch_app_store_review_detail
|
||||||
|
|
||||||
|
if detail
|
||||||
|
Spaceship::ConnectAPI.patch_app_store_review_detail(
|
||||||
|
app_store_review_detail_id: detail.id,
|
||||||
|
attributes: attributes,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Spaceship::ConnectAPI.post_app_store_review_detail(
|
||||||
|
app_store_version_id: version.id,
|
||||||
|
attributes: attributes,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apple accepts a patch it then stores as something else often enough to be worth reading back, and
|
||||||
|
# an empty notes field is exactly the state that got this submission rejected in the first place.
|
||||||
|
written = version.fetch_app_store_review_detail&.notes.to_s
|
||||||
|
if written.empty?
|
||||||
|
abort " the notes field came back empty; nothing was saved."
|
||||||
|
elsif written != notes
|
||||||
|
puts " saved, but what came back differs from what was sent. Check it in App Store Connect."
|
||||||
|
else
|
||||||
|
puts " contact and #{notes.length} characters of review notes saved"
|
||||||
|
end
|
||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Upload the rendered frames in appstore/screenshots to the App Store listing.
|
||||||
|
#
|
||||||
|
# [email protected] ruby scripts/appstore-screenshots.rb
|
||||||
|
#
|
||||||
|
# The frames are rendered by the HTML under appstore/screenshots/src, so this only ever moves
|
||||||
|
# finished PNGs. Rerunning replaces what is there rather than appending, because a listing that
|
||||||
|
# quietly accumulated ten frames across five runs would be worse than one that is simply current.
|
||||||
|
begin
|
||||||
|
require "spaceship"
|
||||||
|
rescue LoadError
|
||||||
|
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
|
||||||
|
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
|
||||||
|
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
|
||||||
|
Gem.clear_paths
|
||||||
|
require "spaceship"
|
||||||
|
end
|
||||||
|
|
||||||
|
require "shellwords"
|
||||||
|
|
||||||
|
DIR = ENV.fetch("SCREENSHOT_DIR", "appstore/screenshots")
|
||||||
|
LOCALE = "en-US"
|
||||||
|
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
|
||||||
|
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
|
||||||
|
|
||||||
|
# The only size App Store Connect takes for a Mac app, out of the four it documents, that this
|
||||||
|
# pipeline renders. Anything else is a mistake worth stopping for.
|
||||||
|
EXPECTED = [2560, 1600].freeze
|
||||||
|
|
||||||
|
frames = Dir[File.join(DIR, "frame-*.png")].sort
|
||||||
|
abort "No frames in #{DIR}." if frames.empty?
|
||||||
|
|
||||||
|
frames.each do |path|
|
||||||
|
dimensions = `sips -g pixelWidth -g pixelHeight #{path.shellescape} 2>/dev/null`
|
||||||
|
.scan(/pixel(?:Width|Height):\s*(\d+)/).flatten.map(&:to_i)
|
||||||
|
next if dimensions == EXPECTED
|
||||||
|
|
||||||
|
abort "#{path} is #{dimensions.join('x')}, and Apple wants #{EXPECTED.join('x')}."
|
||||||
|
end
|
||||||
|
puts "#{frames.size} frames, all #{EXPECTED.join('x')}"
|
||||||
|
|
||||||
|
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
|
||||||
|
|
||||||
|
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
|
||||||
|
abort "No app for #{BUNDLE_ID}." unless app
|
||||||
|
puts "#{app.name} (#{app.id})"
|
||||||
|
|
||||||
|
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
|
||||||
|
abort "No editable macOS version." unless version
|
||||||
|
|
||||||
|
localization = version.get_app_store_version_localizations.find { |l| l.locale == LOCALE }
|
||||||
|
abort "No #{LOCALE} localization; run appstore-listing.rb first." unless localization
|
||||||
|
|
||||||
|
display_type = Spaceship::ConnectAPI::AppScreenshotSet::DisplayType::APP_DESKTOP
|
||||||
|
set = localization.get_app_screenshot_sets.find { |s| s.screenshot_display_type == display_type }
|
||||||
|
|
||||||
|
if set
|
||||||
|
set.app_screenshots.each(&:delete!)
|
||||||
|
puts " cleared #{set.app_screenshots.size} existing screenshots"
|
||||||
|
else
|
||||||
|
set = localization.create_app_screenshot_set(attributes: { screenshotDisplayType: display_type })
|
||||||
|
puts " created the desktop screenshot set"
|
||||||
|
end
|
||||||
|
|
||||||
|
frames.each_with_index do |path, index|
|
||||||
|
set.upload_screenshot(path: path, position: index)
|
||||||
|
puts " uploaded #{File.basename(path)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
puts
|
||||||
|
puts "Apple processes each image before it counts as attached; give it a minute before checking."
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Turn the .app that `tauri build` produced into a signed .pkg the App Store will accept, and
|
||||||
|
# optionally hand it to App Store Connect.
|
||||||
|
#
|
||||||
|
# Tauri has no App Store target, so everything after the bundle is done here: the provisioning
|
||||||
|
# profile goes in before signing (codesign hashes it), the entitlements carry the team identifier,
|
||||||
|
# and productbuild wraps the result. Tauri's own signing is deliberately not used, because it
|
||||||
|
# cannot embed a profile and would sign the app before the profile was in place.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# CI builds universal; a local check against a single-arch build only needs to override this.
|
||||||
|
app="${APP_PATH:-src-tauri/target/universal-apple-darwin/release/bundle/macos/Margin.app}"
|
||||||
|
pkg="${PKG_PATH:-target-mas/Margin.pkg}"
|
||||||
|
|
||||||
|
: "${APPLE_TEAM_ID:?set APPLE_TEAM_ID to the 10-character team identifier}"
|
||||||
|
: "${MAS_PROVISION_PROFILE:?set MAS_PROVISION_PROFILE to the .provisionprofile path}"
|
||||||
|
: "${MAS_APP_IDENTITY:?set MAS_APP_IDENTITY, e.g. '3rd Party Mac Developer Application: Priyanshu Jain (TEAMID)'}"
|
||||||
|
: "${MAS_INSTALLER_IDENTITY:?set MAS_INSTALLER_IDENTITY, e.g. '3rd Party Mac Developer Installer: Priyanshu Jain (TEAMID)'}"
|
||||||
|
|
||||||
|
[ -d "$app" ] || { echo "mas-package: $app does not exist; run the build first." >&2; exit 1; }
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
mkdir -p "$(dirname "$pkg")"
|
||||||
|
|
||||||
|
# App Store Connect rejects an upload whose CFBundleVersion it has already seen, so a rejected
|
||||||
|
# build has to come back with a higher one. The marketing version stays put.
|
||||||
|
if [ -n "${MAS_BUILD_NUMBER:-}" ]; then
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $MAS_BUILD_NUMBER" "$app/Contents/Info.plist"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$MAS_PROVISION_PROFILE" "$app/Contents/embedded.provisionprofile"
|
||||||
|
|
||||||
|
# The profile is kept owner-only where it lives, because it sits next to signing keys, and cp
|
||||||
|
# carries that mode across. Apple rejects a package containing anything a non-root user cannot
|
||||||
|
# read, since the code signature could not then be verified at launch. Widen everything rather
|
||||||
|
# than just the profile, and only ever add permission bits, never remove one.
|
||||||
|
find "$app" -type d -exec chmod go+rx {} +
|
||||||
|
find "$app" -type f -exec chmod go+r {} +
|
||||||
|
sed "s/__TEAM_ID__/$APPLE_TEAM_ID/g" src-tauri/entitlements.mas.plist > "$work/entitlements.plist"
|
||||||
|
plutil -lint "$work/entitlements.plist" > /dev/null
|
||||||
|
|
||||||
|
# Nested code has to be signed before the bundle that contains it, and --deep is the wrong tool
|
||||||
|
# for signing (it applies the outer entitlements to everything inside). Tauri bundles carry no
|
||||||
|
# frameworks today, so this loop is usually empty, and it stays here so that stops being silent
|
||||||
|
# the day one appears.
|
||||||
|
while IFS= read -r -d '' nested; do
|
||||||
|
codesign --force --timestamp --options runtime --sign "$MAS_APP_IDENTITY" "$nested"
|
||||||
|
done < <(find "$app/Contents/Frameworks" "$app/Contents/XPCServices" -maxdepth 1 -mindepth 1 -print0 2>/dev/null)
|
||||||
|
|
||||||
|
codesign --force --timestamp --options runtime \
|
||||||
|
--sign "$MAS_APP_IDENTITY" \
|
||||||
|
--entitlements "$work/entitlements.plist" \
|
||||||
|
"$app"
|
||||||
|
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app"
|
||||||
|
echo "Entitlements on the signed bundle:"
|
||||||
|
codesign --display --entitlements - --xml "$app" | plutil -convert xml1 -o - -
|
||||||
|
|
||||||
|
productbuild --component "$app" /Applications --sign "$MAS_INSTALLER_IDENTITY" "$pkg"
|
||||||
|
pkgutil --check-signature "$pkg"
|
||||||
|
echo "mas-package: wrote $pkg"
|
||||||
|
|
||||||
|
# The upload needs the .p8 where altool looks for it; the caller places it and sets these.
|
||||||
|
# altool exits 0 even when it has just printed UPLOAD FAILED, so its exit status cannot be
|
||||||
|
# trusted and the transcript is the only reliable signal.
|
||||||
|
run_altool() {
|
||||||
|
local action="$1" output
|
||||||
|
output=$(xcrun altool "$action" -f "$pkg" -t macos \
|
||||||
|
--apiKey "$APPLE_API_KEY_ID" --apiIssuer "$APPLE_API_ISSUER" 2>&1) || true
|
||||||
|
printf '%s\n' "$output"
|
||||||
|
if printf '%s' "$output" | grep -qE "VERIFY FAILED|UPLOAD FAILED|ERROR:"; then
|
||||||
|
echo "mas-package: $action failed, see the errors above." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "${MAS_UPLOAD:-}" ]; then
|
||||||
|
: "${APPLE_API_KEY_ID:?}" "${APPLE_API_ISSUER:?}"
|
||||||
|
run_altool --validate-app
|
||||||
|
run_altool --upload-app
|
||||||
|
echo "mas-package: uploaded to App Store Connect; the build appears once processing finishes."
|
||||||
|
fi
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sign, package and upload an App Store build from this machine, using the certificates in
|
||||||
|
# ~/.margin-signing rather than the ones in CI.
|
||||||
|
#
|
||||||
|
# The certificates go into a keychain that exists only for the length of this run, and the login
|
||||||
|
# keychain is never written to. That keeps a machine that has signed once from quietly being able
|
||||||
|
# to sign forever, and it means this leaves nothing behind to go stale.
|
||||||
|
#
|
||||||
|
# ./scripts/mas-upload-local.sh # build, sign, package, upload
|
||||||
|
# MAS_UPLOAD= ./scripts/mas-upload-local.sh # stop after the .pkg
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
DIR="${MARGIN_SIGNING_DIR:-$HOME/.margin-signing}"
|
||||||
|
BUNDLE_ID="${BUNDLE_ID:-studio.margin.app}"
|
||||||
|
KEYCHAIN="$(mktemp -d)/margin-mas.keychain-db"
|
||||||
|
|
||||||
|
for f in "$BUNDLE_ID.env" "$BUNDLE_ID.provisionprofile" apple-distribution.p12 mac-installer.p12; do
|
||||||
|
[ -f "$DIR/$f" ] || { echo "mas-upload-local: $DIR/$f is missing; run apple-provision.rb first." >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
set -a; . "$DIR/$BUNDLE_ID.env"; set +a
|
||||||
|
export MAS_PROVISION_PROFILE="$DIR/$BUNDLE_ID.provisionprofile"
|
||||||
|
|
||||||
|
# App Store Connect refuses an upload whose build number it has already seen, and seconds since the
|
||||||
|
# epoch is both unique and monotonic without needing anything to be remembered between runs.
|
||||||
|
export MAS_BUILD_NUMBER="${MAS_BUILD_NUMBER:-$(date +%s)}"
|
||||||
|
export MAS_UPLOAD="${MAS_UPLOAD-1}"
|
||||||
|
|
||||||
|
if [ -n "${MAS_UPLOAD:-}" ]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
set -a; . "$DIR/AuthKey.env"; set +a
|
||||||
|
mkdir -p ~/private_keys
|
||||||
|
cp "$DIR/AuthKey.p8" ~/private_keys/"AuthKey_$APPLE_API_KEY_ID.p8"
|
||||||
|
chmod 600 ~/private_keys/"AuthKey_$APPLE_API_KEY_ID.p8"
|
||||||
|
fi
|
||||||
|
|
||||||
|
original_keychains=$(security list-keychains -d user | sed 's/^ *"//;s/"$//')
|
||||||
|
restore() {
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
security list-keychains -d user -s $original_keychains
|
||||||
|
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap restore EXIT
|
||||||
|
|
||||||
|
security create-keychain -p margin "$KEYCHAIN"
|
||||||
|
security unlock-keychain -p margin "$KEYCHAIN"
|
||||||
|
for cert in apple-distribution mac-installer; do
|
||||||
|
security import "$DIR/$cert.p12" -k "$KEYCHAIN" -P "$(cat "$DIR/$cert.p12.pass")" \
|
||||||
|
-T /usr/bin/codesign -T /usr/bin/productbuild > /dev/null
|
||||||
|
done
|
||||||
|
# Without this, codesign stops on a keychain prompt rather than signing.
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -k margin "$KEYCHAIN" > /dev/null
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
security list-keychains -d user -s "$KEYCHAIN" $original_keychains
|
||||||
|
|
||||||
|
if [ -z "${SKIP_BUILD:-}" ]; then
|
||||||
|
pnpm tauri build --target universal-apple-darwin \
|
||||||
|
--config src-tauri/tauri.appstore.conf.json --bundles app
|
||||||
|
fi
|
||||||
|
|
||||||
|
./scripts/mas-package.sh
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Put the most recently uploaded build in front of testers.
|
||||||
|
#
|
||||||
|
# Apple takes somewhere between a few minutes and an hour to process an upload, and nothing can be
|
||||||
|
# assigned until it has. So this waits rather than failing, and says what it is waiting for.
|
||||||
|
#
|
||||||
|
# [email protected] ruby scripts/testflight-release.rb
|
||||||
|
#
|
||||||
|
# Internal testers get the build as soon as it is assigned. External testers are gated on Beta App
|
||||||
|
# Review, which this submits for and which usually comes back within a day.
|
||||||
|
begin
|
||||||
|
require "spaceship"
|
||||||
|
rescue LoadError
|
||||||
|
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
|
||||||
|
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
|
||||||
|
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
|
||||||
|
Gem.clear_paths
|
||||||
|
require "spaceship"
|
||||||
|
end
|
||||||
|
|
||||||
|
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
|
||||||
|
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
|
||||||
|
WAIT_SECONDS = Integer(ENV.fetch("WAIT_SECONDS", "1800"))
|
||||||
|
|
||||||
|
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
|
||||||
|
|
||||||
|
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
|
||||||
|
abort "No app for #{BUNDLE_ID}." unless app
|
||||||
|
puts "#{app.name} (#{app.id})"
|
||||||
|
|
||||||
|
deadline = Time.now + WAIT_SECONDS
|
||||||
|
build = nil
|
||||||
|
|
||||||
|
loop do
|
||||||
|
builds = Spaceship::ConnectAPI::Build.all(app_id: app.id, sort: "-uploadedDate", limit: 5)
|
||||||
|
ready = builds.reject(&:expired).find { |b| b.processing_state == "VALID" }
|
||||||
|
|
||||||
|
if ready
|
||||||
|
build = ready
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
pending = builds.find { |b| b.processing_state == "PROCESSING" }
|
||||||
|
if Time.now > deadline
|
||||||
|
abort "Timed out after #{WAIT_SECONDS}s. #{pending ? 'The build is still processing.' : 'No build has appeared yet.'}"
|
||||||
|
end
|
||||||
|
|
||||||
|
puts(pending ? " waiting: build #{pending.version} is still processing" : " waiting: no build has appeared yet")
|
||||||
|
sleep(30)
|
||||||
|
end
|
||||||
|
|
||||||
|
puts " build #{build.app_version} (#{build.version}) is ready"
|
||||||
|
|
||||||
|
groups = app.get_beta_groups
|
||||||
|
|
||||||
|
# A group created with hasAccessToAllBuilds receives every build the moment it processes, and Apple
|
||||||
|
# rejects an explicit assignment to one rather than treating it as a no-op. The internal group is
|
||||||
|
# exactly that, so there is nothing to do for it and nothing to report either.
|
||||||
|
assignable = groups.reject { |g| g.is_internal_group || g.has_access_to_all_builds }
|
||||||
|
already = (build.get_beta_groups.map(&:id) rescue [])
|
||||||
|
to_add = assignable.reject { |g| already.include?(g.id) }
|
||||||
|
|
||||||
|
if to_add.empty?
|
||||||
|
puts " nothing to assign: #{groups.map(&:name).join(', ')} already have this build"
|
||||||
|
else
|
||||||
|
build.add_beta_groups(beta_groups: to_add)
|
||||||
|
puts " assigned to #{to_add.map(&:name).join(', ')}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only external groups are gated on review, so an app with internal testers only never needs this.
|
||||||
|
if groups.any? { |g| !g.is_internal_group }
|
||||||
|
begin
|
||||||
|
Spaceship::ConnectAPI.post_beta_app_review_submissions(build_id: build.id)
|
||||||
|
puts " submitted for beta app review, which gates the external testers"
|
||||||
|
rescue => e
|
||||||
|
# Resubmitting an already submitted build is not an error worth failing the run over.
|
||||||
|
puts " beta app review not submitted: #{e.message.lines.first.to_s.strip}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The store version needs the build attached too, and that is a separate thing from TestFlight.
|
||||||
|
# Until it is, App Store Connect shows the listing with no app icon, because for a Mac app the icon
|
||||||
|
# is read out of the attached build rather than uploaded alongside the other artwork.
|
||||||
|
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
|
||||||
|
if version.nil?
|
||||||
|
puts " no editable store version to attach the build to"
|
||||||
|
elsif version.build&.id == build.id
|
||||||
|
puts " already attached to store version #{version.version_string}"
|
||||||
|
else
|
||||||
|
version.select_build(build_id: build.id)
|
||||||
|
puts " attached to store version #{version.version_string}, which is what surfaces the app icon"
|
||||||
|
end
|
||||||
|
|
||||||
|
puts
|
||||||
|
puts "Internal testers can install now. External testers wait on beta app review."
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Set up TestFlight for the app: the tester-facing blurb, the details Beta App Review asks for, and
|
||||||
|
# the two groups. None of this needs a build to exist, so it can all be in place before the first
|
||||||
|
# upload and the build then only has to be assigned to a group.
|
||||||
|
#
|
||||||
|
# [email protected] ruby scripts/testflight-setup.rb
|
||||||
|
begin
|
||||||
|
require "spaceship"
|
||||||
|
rescue LoadError
|
||||||
|
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
|
||||||
|
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
|
||||||
|
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
|
||||||
|
Gem.clear_paths
|
||||||
|
require "spaceship"
|
||||||
|
end
|
||||||
|
|
||||||
|
DIR = ENV.fetch("METADATA_DIR", "appstore/metadata")
|
||||||
|
LOCALE = "en-US"
|
||||||
|
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
|
||||||
|
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
|
||||||
|
|
||||||
|
def field(name, localized: true)
|
||||||
|
path = localized ? File.join(DIR, LOCALE, "#{name}.txt") : File.join(DIR, "#{name}.txt")
|
||||||
|
File.exist?(path) ? File.read(path).strip : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
|
||||||
|
|
||||||
|
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
|
||||||
|
abort "No app for #{BUNDLE_ID}." unless app
|
||||||
|
puts "#{app.name} (#{app.id})"
|
||||||
|
|
||||||
|
localization = app.get_beta_app_localizations.find { |l| l.locale == LOCALE }
|
||||||
|
attributes = {
|
||||||
|
description: field("beta_description"),
|
||||||
|
feedbackEmail: field("beta_feedback_email", localized: false),
|
||||||
|
marketingUrl: field("marketing_url"),
|
||||||
|
privacyPolicyUrl: field("privacy_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if localization
|
||||||
|
Spaceship::ConnectAPI.patch_beta_app_localizations(localization_id: localization.id, attributes: attributes)
|
||||||
|
else
|
||||||
|
Spaceship::ConnectAPI.post_beta_app_localizations(app_id: app.id, attributes: attributes.merge(locale: LOCALE))
|
||||||
|
end
|
||||||
|
puts " tester blurb and feedback address set"
|
||||||
|
|
||||||
|
# Apple requires a contact phone number here, and this only gates external testing: internal
|
||||||
|
# testers never go through Beta App Review. So a missing number is a warning, not a failure.
|
||||||
|
if field("review_phone", localized: false)
|
||||||
|
Spaceship::ConnectAPI.patch_beta_app_review_detail(app_id: app.id, attributes: {
|
||||||
|
contactFirstName: field("review_first_name", localized: false),
|
||||||
|
contactLastName: field("review_last_name", localized: false),
|
||||||
|
contactEmail: field("review_email", localized: false),
|
||||||
|
contactPhone: field("review_phone", localized: false),
|
||||||
|
# Margin has no accounts at all, so there is nothing for a reviewer to sign in to. Saying so
|
||||||
|
# explicitly is what stops the review coming back asking for credentials.
|
||||||
|
demoAccountRequired: false,
|
||||||
|
notes: field("review_notes", localized: false),
|
||||||
|
})
|
||||||
|
puts " beta app review contact and notes set"
|
||||||
|
else
|
||||||
|
puts " skipping beta app review details: #{DIR}/review_phone.txt is missing and Apple requires"
|
||||||
|
puts " a contact number. Internal testing works without it; external testing does not."
|
||||||
|
end
|
||||||
|
|
||||||
|
existing = app.get_beta_groups.map(&:name)
|
||||||
|
|
||||||
|
[
|
||||||
|
{ name: "Internal", internal: true, public_link: false },
|
||||||
|
{ name: "Public Beta", internal: false, public_link: true },
|
||||||
|
].each do |group|
|
||||||
|
if existing.include?(group[:name])
|
||||||
|
puts " group #{group[:name].inspect} already exists"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
if group[:internal]
|
||||||
|
# spaceship always sends the public-link attributes, and App Store Connect rejects them
|
||||||
|
# outright on an internal group rather than ignoring them, so this one is posted by hand.
|
||||||
|
body = {
|
||||||
|
data: {
|
||||||
|
attributes: { name: group[:name], isInternalGroup: true, hasAccessToAllBuilds: true },
|
||||||
|
relationships: { app: { data: { id: app.id, type: "apps" } } },
|
||||||
|
type: "betaGroups",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Spaceship::ConnectAPI.client.test_flight_request_client.post("v1/betaGroups", body)
|
||||||
|
puts " created internal group #{group[:name].inspect}"
|
||||||
|
else
|
||||||
|
created = app.create_beta_group(
|
||||||
|
group_name: group[:name],
|
||||||
|
is_internal_group: false,
|
||||||
|
public_link_enabled: true,
|
||||||
|
public_link_limit_enabled: true,
|
||||||
|
)
|
||||||
|
puts " created external group #{created.name.inspect}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
puts
|
||||||
|
app.get_beta_groups.each do |g|
|
||||||
|
kind = g.is_internal_group ? "internal" : "external"
|
||||||
|
puts " #{g.name} (#{kind})#{g.public_link ? " #{g.public_link}" : ''}"
|
||||||
|
end
|
||||||
Executable
+104
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Invite people to a TestFlight group.
|
||||||
|
#
|
||||||
|
# [email protected] ruby scripts/testflight-testers.rb [email protected] ...
|
||||||
|
#
|
||||||
|
# Addresses come from the command line rather than a file in the repo, because a list of testers is
|
||||||
|
# a list of people's email addresses and this repository is public.
|
||||||
|
#
|
||||||
|
# GROUP defaults to the external group, since the internal one only accepts people who already have
|
||||||
|
# an App Store Connect account on the team, which is a far bigger thing to hand out than a build.
|
||||||
|
begin
|
||||||
|
require "spaceship"
|
||||||
|
rescue LoadError
|
||||||
|
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
|
||||||
|
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
|
||||||
|
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
|
||||||
|
Gem.clear_paths
|
||||||
|
require "spaceship"
|
||||||
|
end
|
||||||
|
|
||||||
|
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
|
||||||
|
GROUP = ENV.fetch("GROUP", "Public Beta")
|
||||||
|
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
|
||||||
|
|
||||||
|
emails = ARGV.reject { |a| a.start_with?("-") }
|
||||||
|
abort "Usage: ruby scripts/testflight-testers.rb [email protected] [[email protected] ...]" if emails.empty?
|
||||||
|
|
||||||
|
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
|
||||||
|
|
||||||
|
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
|
||||||
|
abort "No app for #{BUNDLE_ID}." unless app
|
||||||
|
|
||||||
|
group = app.get_beta_groups.find { |g| g.name == GROUP }
|
||||||
|
abort "No group named #{GROUP.inspect}." unless group
|
||||||
|
puts "#{app.name}: #{group.name} (#{group.is_internal_group ? 'internal' : 'external'})"
|
||||||
|
|
||||||
|
# An internal tester has to be a user on the App Store Connect account first, which is a far larger
|
||||||
|
# grant than a build: it is access to the account, not to an app. So the invitation is narrowed as
|
||||||
|
# far as the API allows, to this one app and with provisioning refused, and the person still has to
|
||||||
|
# accept it before they can be put in the group.
|
||||||
|
if group.is_internal_group
|
||||||
|
known = Spaceship::ConnectAPI::User.all.map { |u| u.email.to_s.downcase }
|
||||||
|
invited = Spaceship::ConnectAPI::UserInvitation.all.map { |i| i.email.to_s.downcase }
|
||||||
|
|
||||||
|
emails.each do |email|
|
||||||
|
next if known.include?(email.downcase)
|
||||||
|
|
||||||
|
if invited.include?(email.downcase)
|
||||||
|
puts " #{email} has an invitation waiting to be accepted"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
local = email.split("@").first
|
||||||
|
Spaceship::ConnectAPI::UserInvitation.create(
|
||||||
|
email: email,
|
||||||
|
first_name: ENV.fetch("FIRST_NAME", local),
|
||||||
|
last_name: ENV.fetch("LAST_NAME", "Tester"),
|
||||||
|
roles: [Spaceship::ConnectAPI::User::UserRole::DEVELOPER],
|
||||||
|
provisioning_allowed: false,
|
||||||
|
all_apps_visible: false,
|
||||||
|
visible_app_ids: [app.id],
|
||||||
|
)
|
||||||
|
puts " invited #{email} to App Store Connect, limited to this app, no provisioning access"
|
||||||
|
invited << email.downcase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
existing = Spaceship::ConnectAPI::BetaTester
|
||||||
|
.all(filter: { betaGroups: group.id })
|
||||||
|
.map { |t| t.email.to_s.downcase }
|
||||||
|
|
||||||
|
emails.each do |email|
|
||||||
|
if existing.include?(email.downcase)
|
||||||
|
puts " #{email} is already in this group"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
Spaceship::ConnectAPI.post_bulk_beta_tester_assignments(
|
||||||
|
beta_group_id: group.id,
|
||||||
|
beta_testers: [{ email: email }],
|
||||||
|
)
|
||||||
|
|
||||||
|
# The bulk endpoint reports success for an address it then quietly declines to add, which is what
|
||||||
|
# happens on an internal group when the person has not accepted their account invitation yet. So
|
||||||
|
# the group is read back rather than trusted.
|
||||||
|
landed = Spaceship::ConnectAPI::BetaTester
|
||||||
|
.all(filter: { betaGroups: group.id })
|
||||||
|
.any? { |t| t.email.to_s.casecmp?(email) }
|
||||||
|
|
||||||
|
if landed
|
||||||
|
puts " added #{email}"
|
||||||
|
else
|
||||||
|
puts " #{email} was not added. An internal tester has to accept the App Store Connect"
|
||||||
|
puts " invitation first; rerun this once they have."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
puts
|
||||||
|
if group.is_internal_group
|
||||||
|
puts "Internal testers can install as soon as a build finishes processing."
|
||||||
|
else
|
||||||
|
puts "External testers get the invitation once beta app review passes."
|
||||||
|
puts "Anyone can also join through #{group.public_link}" if group.public_link
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# margin-shared
|
||||||
|
|
||||||
|
The faces, palette and glyphs [Margin](../) and Margin Docs both draw from. It is the one copy of
|
||||||
|
the decisions the two apps have to agree on: which six fonts they offer, what their named pairings
|
||||||
|
are, the 35 design tokens they share, and the title bar icon paths.
|
||||||
|
|
||||||
|
No build step and no dependencies. Both apps resolve the TypeScript source directly through their
|
||||||
|
bundler, so an edit here is live in both on the next dev server restart.
|
||||||
|
|
||||||
|
Margin consumes it as `"margin-shared": "file:./shared"`. Margin Docs, which lives in a separate
|
||||||
|
repository, uses a relative path to this directory; see its `package.json`.
|
||||||
|
|
||||||
|
The font binaries in `fonts/` are the source of truth, and each app keeps a vendored copy under
|
||||||
|
`public/fonts` because its Rust PDF exporter reads them with `include_bytes!` before any npm install
|
||||||
|
has run. Run `pnpm fonts:sync` in an app to refresh that copy and `pnpm fonts:check` to verify it.
|
||||||
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Copies this package's font files into an app's public/fonts, or checks that they already match.
|
||||||
|
//
|
||||||
|
// The copies exist because both apps' `src-tauri/src/pdf.rs` reads the same files with
|
||||||
|
// `include_bytes!`, so the bytes have to be on disk at a path cargo can see before any npm install
|
||||||
|
// has run. Serving them out of node_modules would mean a cargo build that fails on a fresh clone
|
||||||
|
// until the front end was installed, which is a worse trade than a vendored copy with a named
|
||||||
|
// upstream and a check that fails loudly when the two drift.
|
||||||
|
//
|
||||||
|
// node bin/sync-fonts.mjs <app-dir> copy, reporting what changed
|
||||||
|
// node bin/sync-fonts.mjs <app-dir> --check compare only, exit 1 on any difference
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = resolve(here, "..", "fonts");
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const check = args.includes("--check");
|
||||||
|
const target = args.find((a) => !a.startsWith("--"));
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
console.error("usage: sync-fonts.mjs <app-dir> [--check]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dest = resolve(process.cwd(), target, "public", "fonts");
|
||||||
|
const files = readdirSync(source).filter((f) => f.endsWith(".ttf") || f.endsWith(".txt")).sort();
|
||||||
|
|
||||||
|
if (!check) mkdirSync(dest, { recursive: true });
|
||||||
|
|
||||||
|
const wrong = [];
|
||||||
|
let copied = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const from = join(source, file);
|
||||||
|
const to = join(dest, file);
|
||||||
|
const want = readFileSync(from);
|
||||||
|
const have = existsSync(to) ? readFileSync(to) : null;
|
||||||
|
|
||||||
|
if (have !== null && have.equals(want)) continue;
|
||||||
|
|
||||||
|
if (check) {
|
||||||
|
wrong.push(`${have === null ? "missing" : "differs"}: ${file}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writeFileSync(to, want);
|
||||||
|
copied += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file the app has and the package does not is reported rather than deleted. It is far more
|
||||||
|
// likely to be a face somebody is in the middle of adding than something to throw away, and this
|
||||||
|
// script does not get to be the reason an asset disappears.
|
||||||
|
if (existsSync(dest)) {
|
||||||
|
for (const file of readdirSync(dest)) {
|
||||||
|
if (!files.includes(file)) wrong.push(`not in the package, left alone: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check) {
|
||||||
|
if (wrong.length === 0) {
|
||||||
|
console.log(`fonts match margin-shared (${files.length} files)`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.error("public/fonts is out of step with margin-shared:");
|
||||||
|
for (const line of wrong) console.error(` ${line}`);
|
||||||
|
console.error("\nrun: pnpm fonts:sync");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(copied === 0 ? `fonts already current (${files.length} files)` : `synced ${copied} file(s)`);
|
||||||
|
for (const line of wrong) console.log(` note: ${line}`);
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/* The six families src/fonts.ts offers, bound to the variable files in fonts/.
|
||||||
|
*
|
||||||
|
* The URLs are absolute site paths, so each app serves these out of its own `public/fonts`. The
|
||||||
|
* copies there are vendored from this package and kept honest by `bin/sync-fonts.mjs`; they are not
|
||||||
|
* imported from node_modules, because both apps' `src-tauri/src/pdf.rs` reads the same files with
|
||||||
|
* `include_bytes!` and a cargo build must not depend on an npm install having run first.
|
||||||
|
*
|
||||||
|
* Every one is a variable file with an upright and an italic cut, so a weight anywhere in the
|
||||||
|
* declared range is a real instance rather than a synthesised one, and italic is the designer's
|
||||||
|
* italic rather than a slant applied by the browser.
|
||||||
|
*
|
||||||
|
* `font-display: block` throughout, and that is the unusual choice. The alternative is a first
|
||||||
|
* paint in Georgia that reflows into Literata a moment later, and a page of prose that resets its
|
||||||
|
* line breaks under the caret is worse than a page that arrives a beat late. The files are local to
|
||||||
|
* the bundle, so the beat is a disk read. */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Hanken Grotesk";
|
||||||
|
src: url("/fonts/HankenGrotesk-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Hanken Grotesk";
|
||||||
|
src: url("/fonts/HankenGrotesk-Italic-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Literata";
|
||||||
|
src: url("/fonts/Literata-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Literata";
|
||||||
|
src: url("/fonts/Literata-Italic-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
src: url("/fonts/EBGaramond-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 400 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
src: url("/fonts/EBGaramond-Italic-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 400 800;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Lora";
|
||||||
|
src: url("/fonts/Lora-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Lora";
|
||||||
|
src: url("/fonts/Lora-Italic-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Source Serif 4";
|
||||||
|
src: url("/fonts/SourceSerif4-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Source Serif 4";
|
||||||
|
src: url("/fonts/SourceSerif4-Italic-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Fraunces";
|
||||||
|
src: url("/fonts/Fraunces-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Fraunces";
|
||||||
|
src: url("/fonts/Fraunces-Italic-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/* The palette, the type scale and the shape constants both apps are built from.
|
||||||
|
*
|
||||||
|
* These 35 tokens already held identical values in the two repos before this package existed, which
|
||||||
|
* is the argument for moving them: they were identical by somebody remembering to copy a hex across,
|
||||||
|
* and that is not a mechanism. A colour that only exists in one app is not here. Each app layers its
|
||||||
|
* own sheet on top of this one and adds what only it needs, which is a preview pane width in Margin
|
||||||
|
* and a document typography scale in Margin Docs.
|
||||||
|
*
|
||||||
|
* --font-book and --font-heading are the two a document overrides at runtime, per book or per
|
||||||
|
* document. What is set here is the default pair, and the only place that decides what an untouched
|
||||||
|
* page looks like. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
--font-book: "Literata", Georgia, "Times New Roman", serif;
|
||||||
|
--font-heading: "Literata", Georgia, "Times New Roman", serif;
|
||||||
|
|
||||||
|
--r-sm: 5px;
|
||||||
|
--r-md: 8px;
|
||||||
|
--r-lg: 12px;
|
||||||
|
|
||||||
|
--pane-sidebar: 248px;
|
||||||
|
--measure: 46em;
|
||||||
|
--titlebar-h: 46px;
|
||||||
|
|
||||||
|
--t-1: 11px;
|
||||||
|
--t-2: 12px;
|
||||||
|
--t-3: 13px;
|
||||||
|
--t-4: 15px;
|
||||||
|
|
||||||
|
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root,
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--paper: #fcfbf7;
|
||||||
|
--shell: #f1ece2;
|
||||||
|
--sidebar: #ece6da;
|
||||||
|
--raised: #fbfaf6;
|
||||||
|
|
||||||
|
--ink: #23201b;
|
||||||
|
--ink-soft: #6b6458;
|
||||||
|
--ink-faint: #9b9484;
|
||||||
|
|
||||||
|
--line: #e3ddce;
|
||||||
|
--line-strong: #d6cfbd;
|
||||||
|
|
||||||
|
--accent: #2a2622;
|
||||||
|
--accent-ink: #100e0b;
|
||||||
|
--accent-wash: rgba(35, 32, 27, 0.08);
|
||||||
|
--accent-contrast: #faf7f0;
|
||||||
|
--selection: rgba(35, 32, 27, 0.14);
|
||||||
|
--glass: rgba(252, 251, 247, 0.86);
|
||||||
|
|
||||||
|
--danger: #b4453a;
|
||||||
|
--danger-ink: #963327;
|
||||||
|
--danger-wash: rgba(180, 69, 58, 0.1);
|
||||||
|
--danger-contrast: #faf7f0;
|
||||||
|
|
||||||
|
--shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09);
|
||||||
|
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--paper: #1d1a16;
|
||||||
|
--shell: #151310;
|
||||||
|
--sidebar: #1a1713;
|
||||||
|
--raised: #232019;
|
||||||
|
|
||||||
|
--ink: #ece6da;
|
||||||
|
--ink-soft: #a89f8e;
|
||||||
|
--ink-faint: #756d5e;
|
||||||
|
|
||||||
|
--line: #2c2823;
|
||||||
|
--line-strong: #3a352d;
|
||||||
|
|
||||||
|
--accent: #efe9dc;
|
||||||
|
--accent-ink: #ffffff;
|
||||||
|
--accent-wash: rgba(239, 233, 220, 0.12);
|
||||||
|
--accent-contrast: #1a1714;
|
||||||
|
--selection: rgba(239, 233, 220, 0.18);
|
||||||
|
--glass: rgba(33, 30, 25, 0.88);
|
||||||
|
|
||||||
|
--danger: #d97a6f;
|
||||||
|
--danger-ink: #e58e84;
|
||||||
|
--danger-wash: rgba(217, 122, 111, 0.16);
|
||||||
|
--danger-contrast: #1a1714;
|
||||||
|
|
||||||
|
--shadow-page: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 34px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright 2021 The Hanken Grotesk Project Authors (https://github.com/marcologous/hanken-grotesk)
|
||||||
|
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "margin-shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"description": "The faces, palette and glyphs Margin and Margin Docs both draw from.",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./fonts": "./src/fonts.ts",
|
||||||
|
"./icons": "./src/icons.ts",
|
||||||
|
"./css/fonts.css": "./css/fonts.css",
|
||||||
|
"./css/tokens.css": "./css/tokens.css"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"margin-shared-fonts": "./bin/sync-fonts.mjs"
|
||||||
|
},
|
||||||
|
"files": ["src", "css", "fonts", "bin"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// The faces both apps offer, and the two slots they set them into.
|
||||||
|
//
|
||||||
|
// This lives in one place because the two apps have to agree about it. A face named here is a
|
||||||
|
// `@font-face` in css/fonts.css, a file in fonts/, and a family a Typst preamble names on the way
|
||||||
|
// to a PDF, and those four lists going out of step with each other is a document that renders in
|
||||||
|
// one app and falls back to Georgia in the other. There is no way to keep four lists in two repos
|
||||||
|
// honest by hand, so there is one list.
|
||||||
|
//
|
||||||
|
// Two slots and not one. Body and heading are the only typographic decision worth a control:
|
||||||
|
// a document that lets its author pick a face per paragraph is a word processor, and neither of
|
||||||
|
// these is one. The scale, the leading and the measure belong to each app's own stylesheet, which
|
||||||
|
// decided them once for every document.
|
||||||
|
//
|
||||||
|
// A `FontRef` is stored, not a family name. "Literata" as a string cannot say whether it means the
|
||||||
|
// file in fonts/ or a copy the user installed themselves, and those are two different faces the
|
||||||
|
// moment one of them is updated.
|
||||||
|
|
||||||
|
export type FontCategory = "serif" | "sans" | "display";
|
||||||
|
|
||||||
|
/** One face that ships in fonts/, in the variable file both editors render from. */
|
||||||
|
export interface BundledFont {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** The CSS family name, which is also what a Typst preamble names it by. */
|
||||||
|
family: string;
|
||||||
|
category: FontCategory;
|
||||||
|
regular: string;
|
||||||
|
italic: string;
|
||||||
|
/** The weight range the variable file covers, for the `@font-face` in css/fonts.css. */
|
||||||
|
weight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The six, and the order a picker lists them in: the serifs a page of prose is set in, the one
|
||||||
|
* display face, then the sans both apps' own chrome already uses.
|
||||||
|
*
|
||||||
|
* Literata and Hanken Grotesk are first-class here and also special: they are the two that have
|
||||||
|
* static instances cut for PDF export, so they are the only pair whose bold really is bold on the
|
||||||
|
* page. The other four export as their variable file at one weight, which each app's pdf.rs
|
||||||
|
* explains.
|
||||||
|
*/
|
||||||
|
export const BUNDLED_FONTS: readonly BundledFont[] = [
|
||||||
|
{
|
||||||
|
id: "literata",
|
||||||
|
label: "Literata",
|
||||||
|
family: "Literata",
|
||||||
|
category: "serif",
|
||||||
|
regular: "Literata-VF.ttf",
|
||||||
|
italic: "Literata-Italic-VF.ttf",
|
||||||
|
weight: "200 900",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb-garamond",
|
||||||
|
label: "EB Garamond",
|
||||||
|
family: "EB Garamond",
|
||||||
|
category: "serif",
|
||||||
|
regular: "EBGaramond-VF.ttf",
|
||||||
|
italic: "EBGaramond-Italic-VF.ttf",
|
||||||
|
weight: "400 800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lora",
|
||||||
|
label: "Lora",
|
||||||
|
family: "Lora",
|
||||||
|
category: "serif",
|
||||||
|
regular: "Lora-VF.ttf",
|
||||||
|
italic: "Lora-Italic-VF.ttf",
|
||||||
|
weight: "400 700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source-serif",
|
||||||
|
label: "Source Serif 4",
|
||||||
|
family: "Source Serif 4",
|
||||||
|
category: "serif",
|
||||||
|
regular: "SourceSerif4-VF.ttf",
|
||||||
|
italic: "SourceSerif4-Italic-VF.ttf",
|
||||||
|
weight: "200 900",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fraunces",
|
||||||
|
label: "Fraunces",
|
||||||
|
family: "Fraunces",
|
||||||
|
category: "display",
|
||||||
|
regular: "Fraunces-VF.ttf",
|
||||||
|
italic: "Fraunces-Italic-VF.ttf",
|
||||||
|
weight: "100 900",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hanken",
|
||||||
|
label: "Hanken Grotesk",
|
||||||
|
family: "Hanken Grotesk",
|
||||||
|
category: "sans",
|
||||||
|
regular: "HankenGrotesk-VF.ttf",
|
||||||
|
italic: "HankenGrotesk-Italic-VF.ttf",
|
||||||
|
weight: "100 900",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** One of the six, or a family off the machine. */
|
||||||
|
export type FontRef = { kind: "bundled"; id: string } | { kind: "system"; family: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one book or one document is set in.
|
||||||
|
*
|
||||||
|
* Named for the pair rather than for either app's noun, because the two call the thing it belongs
|
||||||
|
* to different names. Each app aliases it: `BookFonts` in Margin, `DocumentFonts` in Margin Docs.
|
||||||
|
*/
|
||||||
|
export interface FontPair {
|
||||||
|
body: FontRef;
|
||||||
|
heading: FontRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named pair, which is what a picker offers first.
|
||||||
|
*
|
||||||
|
* Pairing two faces is the part of this that takes an eye, and a list of twelve families with no
|
||||||
|
* opinion attached is how a document ends up in Fraunces body text. The presets are the answer to
|
||||||
|
* "make this look like something"; the two selects underneath are for somebody who already knows.
|
||||||
|
*/
|
||||||
|
export interface FontPairing {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
body: FontRef;
|
||||||
|
heading: FontRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundled = (id: string): FontRef => ({ kind: "bundled", id });
|
||||||
|
|
||||||
|
export const FONT_PAIRINGS: readonly FontPairing[] = [
|
||||||
|
{ id: "quiet-press", label: "Quiet Press", body: bundled("literata"), heading: bundled("literata") },
|
||||||
|
{ id: "classic", label: "Classic", body: bundled("eb-garamond"), heading: bundled("eb-garamond") },
|
||||||
|
{ id: "editorial", label: "Editorial", body: bundled("source-serif"), heading: bundled("fraunces") },
|
||||||
|
{ id: "modern", label: "Modern", body: bundled("lora"), heading: bundled("hanken") },
|
||||||
|
{ id: "contrast", label: "Contrast", body: bundled("literata"), heading: bundled("hanken") },
|
||||||
|
{ id: "plain", label: "Plain", body: bundled("hanken"), heading: bundled("hanken") },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** What a page is set in until somebody says otherwise: the css/tokens.css pair, in FontRef form. */
|
||||||
|
export const DEFAULT_FONTS: FontPair = { body: bundled("literata"), heading: bundled("literata") };
|
||||||
|
|
||||||
|
// The two tails from css/tokens.css, so a face that fails to load falls back to what the app would
|
||||||
|
// have used anyway rather than to the webview's default.
|
||||||
|
const SERIF_FALLBACK = `Georgia, "Times New Roman", serif`;
|
||||||
|
const SANS_FALLBACK = `ui-sans-serif, system-ui, -apple-system, sans-serif`;
|
||||||
|
|
||||||
|
export function bundledFont(id: string): BundledFont | undefined {
|
||||||
|
return BUNDLED_FONTS.find((f) => f.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `FontRef` as one string, which is what a `<select>` value and a stored preference both need.
|
||||||
|
*
|
||||||
|
* The two-character tag is the whole point: a system family can be called "Literata" and must not
|
||||||
|
* come back as the bundled one.
|
||||||
|
*/
|
||||||
|
export function encodeRef(ref: FontRef): string {
|
||||||
|
return ref.kind === "bundled" ? `b:${ref.id}` : `s:${ref.family}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeRef(value: string): FontRef {
|
||||||
|
return value.startsWith("s:")
|
||||||
|
? { kind: "system", family: value.slice(2) }
|
||||||
|
: { kind: "bundled", id: value.slice(2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bare family name, which is what a Typst preamble names a face by. */
|
||||||
|
export function fontFamilyName(ref: FontRef): string {
|
||||||
|
if (ref.kind === "system") return ref.family;
|
||||||
|
return bundledFont(ref.id)?.family ?? "Literata";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a picker calls it. Identical to the family for a system face, which has no other name. */
|
||||||
|
export function fontLabel(ref: FontRef): string {
|
||||||
|
if (ref.kind === "system") return ref.family;
|
||||||
|
return bundledFont(ref.id)?.label ?? ref.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A CSS font stack, which is what goes into `--font-book` and `--font-heading`. */
|
||||||
|
export function fontStack(ref: FontRef): string {
|
||||||
|
if (ref.kind === "bundled") {
|
||||||
|
const font = bundledFont(ref.id);
|
||||||
|
if (!font) return `"Literata", ${SERIF_FALLBACK}`;
|
||||||
|
return `"${font.family}", ${font.category === "sans" ? SANS_FALLBACK : SERIF_FALLBACK}`;
|
||||||
|
}
|
||||||
|
return `"${ref.family}", ${SERIF_FALLBACK}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refsEqual(a: FontRef, b: FontRef): boolean {
|
||||||
|
if (a.kind === "bundled" && b.kind === "bundled") return a.id === b.id;
|
||||||
|
if (a.kind === "system" && b.kind === "system") return a.family === b.family;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fontsEqual(a: FontPair, b: FontPair): boolean {
|
||||||
|
return refsEqual(a.body, b.body) && refsEqual(a.heading, b.heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which preset this pair is, or null for a combination somebody built themselves. */
|
||||||
|
export function pairingFor(fonts: FontPair): string | null {
|
||||||
|
const match = FONT_PAIRINGS.find(
|
||||||
|
(p) => refsEqual(p.body, fonts.body) && refsEqual(p.heading, fonts.heading),
|
||||||
|
);
|
||||||
|
return match?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The faces an export has to be handed, split by where their bytes come from.
|
||||||
|
*
|
||||||
|
* Bundled faces come back as ids rather than as records, because the id is what crosses the IPC
|
||||||
|
* boundary: the backend already has the bytes compiled in and looks them up by id. Deduplicated,
|
||||||
|
* because the common case is one family in both slots and asking the backend to load a two megabyte
|
||||||
|
* variable file twice is two megabytes of IPC for nothing.
|
||||||
|
*/
|
||||||
|
export function fontsUsed(fonts: FontPair): { bundled: string[]; system: string[] } {
|
||||||
|
const usedBundled = new Set<string>();
|
||||||
|
const usedSystem = new Set<string>();
|
||||||
|
for (const ref of [fonts.body, fonts.heading]) {
|
||||||
|
if (ref.kind === "bundled") {
|
||||||
|
if (bundledFont(ref.id)) usedBundled.add(ref.id);
|
||||||
|
} else {
|
||||||
|
usedSystem.add(ref.family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bundled: [...usedBundled], system: [...usedSystem] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anything that is not a `FontRef`, from a stored preference written by another version. */
|
||||||
|
export function isFontRef(value: unknown): value is FontRef {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const ref = value as { kind?: unknown; id?: unknown; family?: unknown };
|
||||||
|
if (ref.kind === "bundled") return typeof ref.id === "string" && bundledFont(ref.id) !== undefined;
|
||||||
|
return ref.kind === "system" && typeof ref.family === "string" && ref.family !== "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// The title bar glyphs, drawn on a 24 unit grid for a 1.6 stroke.
|
||||||
|
//
|
||||||
|
// Here because the two apps kept drifting. Each had its own idea of what a search or a moon looked
|
||||||
|
// like, they were adjusted independently, and the result was two products from the same hand that
|
||||||
|
// did not look related. A path is a design decision, not a detail, and the fix for two copies of a
|
||||||
|
// decision is one copy.
|
||||||
|
//
|
||||||
|
// Paths and not an icon dependency: a set is six hundred kilobytes for the handful of shapes a
|
||||||
|
// title bar needs, and every one of these is a few dozen bytes.
|
||||||
|
//
|
||||||
|
// Each app renders these through its own `Icon` component. The two components are identical today
|
||||||
|
// and are deliberately not shared: one is React, which would make this package depend on React for
|
||||||
|
// twenty four lines, and a component is where an app is entitled to differ.
|
||||||
|
|
||||||
|
/** A pane and its divider. Also the preview dock in Margin, mirrored. */
|
||||||
|
export const SIDEBAR = "M3 4.5h18v15H3zM9 4.5v15";
|
||||||
|
|
||||||
|
/** Margin's preview dock: the same pane with the divider on the other side. */
|
||||||
|
export const DOCK = "M3 4.5h18v15H3zM14 4.5v15";
|
||||||
|
|
||||||
|
export const SEARCH = "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4";
|
||||||
|
|
||||||
|
/** A capital A with a tick beside it: the letter a checker is looking at, checked. */
|
||||||
|
export const SPELLING = "M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14";
|
||||||
|
|
||||||
|
/** Three lines of a paragraph and a squiggle under the last, which is the mark grammar leaves. */
|
||||||
|
export const GRAMMAR = "M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A capital A beside a lowercase a, which is what a font panel has been called since there were
|
||||||
|
* font panels.
|
||||||
|
*
|
||||||
|
* Two letterforms and not one on a rule: a letter over a full width line is the underline button in
|
||||||
|
* every editor anybody has used. The other constraint is SPELLING above, which is also built on a
|
||||||
|
* capital A; what separates them is the shape to its right, a round bowl here and an angular tick
|
||||||
|
* there, and that difference survives 16px in a way a crossbar's height would not.
|
||||||
|
*/
|
||||||
|
export const FONT =
|
||||||
|
"M2.5 18L6.5 6l4 12M4.3 14.2h4.4M17 11.3a3.2 3.2 0 1 0 0 6.4a3.2 3.2 0 0 0 0-6.4M20.2 11.3v6.7";
|
||||||
|
|
||||||
|
/** The page's two edges with a double headed arrow between them. */
|
||||||
|
export const WIDTH = "M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3";
|
||||||
|
|
||||||
|
export const EXPORT = "M5 13v6h14v-6M12 16V3M8 7l4-4 4 4";
|
||||||
|
|
||||||
|
export const MOON = "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sun, which is the one glyph here that is not a single path: the disc has to be a circle so
|
||||||
|
* that it stays round at every size, and the rays have to be a path so that they keep their caps.
|
||||||
|
* Rendered as two children rather than one `d`.
|
||||||
|
*/
|
||||||
|
export const SUN_RAYS =
|
||||||
|
"M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4";
|
||||||
|
export const SUN_DISC = { cx: 12, cy: 12, r: 4 } as const;
|
||||||
|
|
||||||
|
export const MORE = "M5 12h.01M12 12h.01M19 12h.01";
|
||||||
|
|
||||||
|
export const CLOSE = "M6 6l12 12M18 6L6 18";
|
||||||
|
|
||||||
|
export const CHECK = "M20 6L9 17l-5-5";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Everything both apps draw from. Two entry points as well as this one, `margin-shared/fonts` and
|
||||||
|
// `margin-shared/icons`, so a module that only wants the glyphs does not pull in the catalogue.
|
||||||
|
|
||||||
|
export * from "./fonts";
|
||||||
|
export * as icons from "./icons";
|
||||||
Generated
+2
-1
@@ -4713,7 +4713,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.9"
|
version = "0.1.17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"fontdb",
|
"fontdb",
|
||||||
@@ -4723,6 +4723,7 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|||||||
+14
-4
@@ -1,8 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.12"
|
version = "0.1.17"
|
||||||
description = "A Tauri App"
|
description = "A calm, offline writing studio for authors"
|
||||||
authors = ["you"]
|
authors = ["Priyanshu Jain"]
|
||||||
|
license = "LicenseRef-FSL-1.1-MIT"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -37,6 +38,7 @@ typst-as-lib = "0.15.5"
|
|||||||
harper-core = { version = "=2.5.0", features = ["concurrent"] }
|
harper-core = { version = "=2.5.0", features = ["concurrent"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
semver = "1"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
|
||||||
@@ -45,7 +47,15 @@ url = "2"
|
|||||||
# in the graph via Tauri, so this adds nothing new to the build.
|
# in the graph via Tauri, so this adds nothing new to the build.
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] }
|
objc2-app-kit = { version = "0.3", features = [
|
||||||
|
"NSSpellChecker",
|
||||||
|
"NSApplication",
|
||||||
|
"NSEvent",
|
||||||
|
"NSMenu",
|
||||||
|
"NSMenuItem",
|
||||||
|
"NSResponder",
|
||||||
|
"NSWritingToolsCoordinator",
|
||||||
|
] }
|
||||||
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
|
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
|
||||||
|
|
||||||
# Other platforms fall back to the bundled Hunspell dictionary via spellbook.
|
# Other platforms fall back to the bundled Hunspell dictionary via spellbook.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?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>
|
||||||
|
<!-- Margin's only cryptography is TLS for the Google Drive API and the SHA-256 that PKCE
|
||||||
|
needs, both of which are exempt. Declaring it here rather than in App Store Connect means
|
||||||
|
the question is not asked again on every submission. -->
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?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>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Google Drive backup talks to googleapis.com, and the update check asks itunes.apple.com
|
||||||
|
which version is live on the store. Nothing else leaves the machine. -->
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- The Drive OAuth flow redirects to a loopback listener on 127.0.0.1 (gdrive.rs), which is
|
||||||
|
the only installed-app flow Google still supports. Without this the sandbox refuses the
|
||||||
|
bind and sign-in hangs on the browser tab that has nowhere to come back to. -->
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Importing an EPUB and exporting a PDF or EPUB both go through NSOpenPanel/NSSavePanel, so
|
||||||
|
the app only ever reaches the one file the user pointed at. The library itself lives in
|
||||||
|
the container and needs no entitlement. -->
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>com.apple.application-identifier</key>
|
||||||
|
<string>__TEAM_ID__.studio.margin.app</string>
|
||||||
|
<key>com.apple.developer.team-identifier</key>
|
||||||
|
<string>__TEAM_ID__</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+163
-16
@@ -15,6 +15,7 @@ use tauri::{Emitter, Manager};
|
|||||||
|
|
||||||
const FOLDER_NAME: &str = "margin";
|
const FOLDER_NAME: &str = "margin";
|
||||||
const DICTIONARY_NAME: &str = "custom-dictionary.txt";
|
const DICTIONARY_NAME: &str = "custom-dictionary.txt";
|
||||||
|
const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive.file";
|
||||||
const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
|
const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
|
||||||
const AUTH_TIMEOUT_SECS: u64 = 120;
|
const AUTH_TIMEOUT_SECS: u64 = 120;
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ pub struct Session {
|
|||||||
access_expiry: u64,
|
access_expiry: u64,
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
folder_id: Option<String>,
|
folder_id: Option<String>,
|
||||||
|
scopes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
@@ -68,6 +70,8 @@ struct BackupState {
|
|||||||
folder_id: Option<String>,
|
folder_id: Option<String>,
|
||||||
last_backup: Option<u64>,
|
last_backup: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
scopes: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
files: HashMap<String, FileRecord>,
|
files: HashMap<String, FileRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +88,7 @@ pub struct Status {
|
|||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
last_backup: Option<u64>,
|
last_backup: Option<u64>,
|
||||||
pending: bool,
|
pending: bool,
|
||||||
|
needs_reauth: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -92,6 +97,15 @@ pub struct RestoreResult {
|
|||||||
restored: usize,
|
restored: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SyncOutcome {
|
||||||
|
uploaded: usize,
|
||||||
|
downloaded: usize,
|
||||||
|
#[serde(flatten)]
|
||||||
|
status: Status,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BackupOutcome {
|
pub struct BackupOutcome {
|
||||||
@@ -164,6 +178,13 @@ fn urlencode(s: &str) -> String {
|
|||||||
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
|
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_drive_scope(scope: &str) -> bool {
|
||||||
|
scope.split_whitespace().any(|s| s == DRIVE_SCOPE)
|
||||||
|
}
|
||||||
|
|
||||||
|
const REAUTH_MESSAGE: &str =
|
||||||
|
"margin needs the \"See, edit, create and delete only the specific Google Drive files you use with this app\" permission. Connect again and tick that box.";
|
||||||
|
|
||||||
fn drive_escape(s: &str) -> String {
|
fn drive_escape(s: &str) -> String {
|
||||||
s.replace('\\', "\\\\").replace('\'', "\\'")
|
s.replace('\\', "\\\\").replace('\'', "\\'")
|
||||||
}
|
}
|
||||||
@@ -260,6 +281,8 @@ struct TokenResponse {
|
|||||||
refresh_token: Option<String>,
|
refresh_token: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
expires_in: u64,
|
expires_in: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
scope: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -484,7 +507,29 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
|
|||||||
}
|
}
|
||||||
let refresh = refresh.ok_or("Not connected to Google Drive.")?;
|
let refresh = refresh.ok_or("Not connected to Google Drive.")?;
|
||||||
let creds = load_credentials()?;
|
let creds = load_credentials()?;
|
||||||
let tokens = refresh_access_token(&creds, &refresh).await?;
|
let tokens = match refresh_access_token(&creds, &refresh).await {
|
||||||
|
Ok(tokens) => tokens,
|
||||||
|
Err(e) => {
|
||||||
|
if e.contains("invalid_grant") {
|
||||||
|
forget_credentials(app, state);
|
||||||
|
return Err(
|
||||||
|
"Google Drive access has expired. Connect again to keep backing up.".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(granted) = &tokens.scope {
|
||||||
|
if !has_drive_scope(granted) {
|
||||||
|
let mut session = state.0.lock().unwrap();
|
||||||
|
session.scopes = Some(granted.clone());
|
||||||
|
drop(session);
|
||||||
|
let mut stored = load_state(app);
|
||||||
|
stored.scopes = Some(granted.clone());
|
||||||
|
let _ = save_state(app, &stored);
|
||||||
|
return Err(REAUTH_MESSAGE.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
let access_token = tokens.access_token.clone();
|
let access_token = tokens.access_token.clone();
|
||||||
{
|
{
|
||||||
let mut session = state.0.lock().unwrap();
|
let mut session = state.0.lock().unwrap();
|
||||||
@@ -493,10 +538,18 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
|
|||||||
if let Some(rotated) = &tokens.refresh_token {
|
if let Some(rotated) = &tokens.refresh_token {
|
||||||
session.refresh_token = Some(rotated.clone());
|
session.refresh_token = Some(rotated.clone());
|
||||||
}
|
}
|
||||||
|
if let Some(granted) = &tokens.scope {
|
||||||
|
session.scopes = Some(granted.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(rotated) = &tokens.refresh_token {
|
if tokens.refresh_token.is_some() || tokens.scope.is_some() {
|
||||||
let mut stored = load_state(app);
|
let mut stored = load_state(app);
|
||||||
stored.refresh_token = Some(rotated.clone());
|
if let Some(rotated) = &tokens.refresh_token {
|
||||||
|
stored.refresh_token = Some(rotated.clone());
|
||||||
|
}
|
||||||
|
if let Some(granted) = &tokens.scope {
|
||||||
|
stored.scopes = Some(granted.clone());
|
||||||
|
}
|
||||||
let _ = save_state(app, &stored);
|
let _ = save_state(app, &stored);
|
||||||
}
|
}
|
||||||
Ok(access_token)
|
Ok(access_token)
|
||||||
@@ -592,17 +645,19 @@ fn compute_pending(app: &tauri::AppHandle, stored: &BackupState) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status {
|
fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status {
|
||||||
let (connected, email) = {
|
let (connected, email, scopes) = {
|
||||||
let session = state.0.lock().unwrap();
|
let session = state.0.lock().unwrap();
|
||||||
(session.refresh_token.is_some(), session.email.clone())
|
(session.refresh_token.is_some(), session.email.clone(), session.scopes.clone())
|
||||||
};
|
};
|
||||||
let stored = load_state(app);
|
let stored = load_state(app);
|
||||||
let pending = connected && compute_pending(app, &stored);
|
let pending = connected && compute_pending(app, &stored);
|
||||||
|
let granted = scopes.or_else(|| stored.scopes.clone());
|
||||||
Status {
|
Status {
|
||||||
connected,
|
connected,
|
||||||
email: email.or(stored.email),
|
email: email.or(stored.email),
|
||||||
last_backup: stored.last_backup,
|
last_backup: stored.last_backup,
|
||||||
pending,
|
pending,
|
||||||
|
needs_reauth: connected && granted.map(|s| !has_drive_scope(&s)).unwrap_or(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +668,21 @@ pub fn init_session(app: &tauri::AppHandle) {
|
|||||||
session.refresh_token = stored.refresh_token;
|
session.refresh_token = stored.refresh_token;
|
||||||
session.email = stored.email;
|
session.email = stored.email;
|
||||||
session.folder_id = stored.folder_id;
|
session.folder_id = stored.folder_id;
|
||||||
|
session.scopes = stored.scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forget_credentials(app: &tauri::AppHandle, state: &GDriveState) {
|
||||||
|
{
|
||||||
|
let mut session = state.0.lock().unwrap();
|
||||||
|
session.refresh_token = None;
|
||||||
|
session.access_token = None;
|
||||||
|
session.access_expiry = 0;
|
||||||
|
session.scopes = None;
|
||||||
|
}
|
||||||
|
let mut stored = load_state(app);
|
||||||
|
stored.refresh_token = None;
|
||||||
|
stored.scopes = None;
|
||||||
|
let _ = save_state(app, &stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn complete_auth(
|
async fn complete_auth(
|
||||||
@@ -631,6 +701,15 @@ async fn complete_auth(
|
|||||||
.map_err(|e| e.to_string())??;
|
.map_err(|e| e.to_string())??;
|
||||||
|
|
||||||
let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?;
|
let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?;
|
||||||
|
let granted = tokens.scope.clone().unwrap_or_default();
|
||||||
|
if !has_drive_scope(&granted) {
|
||||||
|
let _ = HTTP
|
||||||
|
.post("https://oauth2.googleapis.com/revoke")
|
||||||
|
.form(&[("token", tokens.access_token.as_str())])
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
return Err(REAUTH_MESSAGE.to_string());
|
||||||
|
}
|
||||||
let email = fetch_email(&tokens.access_token).await?;
|
let email = fetch_email(&tokens.access_token).await?;
|
||||||
let folder_id = ensure_folder(&tokens.access_token).await?;
|
let folder_id = ensure_folder(&tokens.access_token).await?;
|
||||||
|
|
||||||
@@ -644,6 +723,7 @@ async fn complete_auth(
|
|||||||
session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
|
session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
|
||||||
session.email = Some(email.clone());
|
session.email = Some(email.clone());
|
||||||
session.folder_id = Some(folder_id.clone());
|
session.folder_id = Some(folder_id.clone());
|
||||||
|
session.scopes = Some(granted.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut stored = load_state(app);
|
let mut stored = load_state(app);
|
||||||
@@ -652,6 +732,7 @@ async fn complete_auth(
|
|||||||
}
|
}
|
||||||
stored.email = Some(email);
|
stored.email = Some(email);
|
||||||
stored.folder_id = Some(folder_id);
|
stored.folder_id = Some(folder_id);
|
||||||
|
stored.scopes = Some(granted);
|
||||||
save_state(app, &stored)?;
|
save_state(app, &stored)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -711,10 +792,12 @@ pub async fn gdrive_disconnect(app: tauri::AppHandle, state: tauri::State<'_, GD
|
|||||||
session.access_token = None;
|
session.access_token = None;
|
||||||
session.access_expiry = 0;
|
session.access_expiry = 0;
|
||||||
session.email = None;
|
session.email = None;
|
||||||
|
session.scopes = None;
|
||||||
}
|
}
|
||||||
let mut stored = load_state(&app);
|
let mut stored = load_state(&app);
|
||||||
stored.refresh_token = None;
|
stored.refresh_token = None;
|
||||||
stored.email = None;
|
stored.email = None;
|
||||||
|
stored.scopes = None;
|
||||||
save_state(&app, &stored)?;
|
save_state(&app, &stored)?;
|
||||||
Ok(status_inner(&app, &state))
|
Ok(status_inner(&app, &state))
|
||||||
}
|
}
|
||||||
@@ -724,12 +807,13 @@ pub async fn gdrive_status(app: tauri::AppHandle, state: tauri::State<'_, GDrive
|
|||||||
Ok(status_inner(&app, &state))
|
Ok(status_inner(&app, &state))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
async fn push_local_files(
|
||||||
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
|
app: &tauri::AppHandle,
|
||||||
let access_token = valid_access_token(&app, &state).await?;
|
access_token: &str,
|
||||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
folder_id: &str,
|
||||||
let files = collect_local_files(&app)?;
|
stored: &mut BackupState,
|
||||||
let mut stored = load_state(&app);
|
) -> Result<usize, String> {
|
||||||
|
let files = collect_local_files(app)?;
|
||||||
let mut uploaded = 0;
|
let mut uploaded = 0;
|
||||||
for (name, bytes) in &files {
|
for (name, bytes) in &files {
|
||||||
let hash = hash_bytes(bytes);
|
let hash = hash_bytes(bytes);
|
||||||
@@ -739,18 +823,27 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let drive_id = match &existing {
|
let drive_id = match existing.filter(|record| !record.drive_id.is_empty()) {
|
||||||
Some(record) => Some(record.drive_id.clone()),
|
Some(record) => Some(record.drive_id),
|
||||||
None => find_file(&access_token, &folder_id, name).await?.map(|file| file.id),
|
None => find_file(access_token, folder_id, name).await?.map(|file| file.id),
|
||||||
};
|
};
|
||||||
let result = upload_file(&access_token, &folder_id, name, bytes, drive_id).await?;
|
let result = upload_file(access_token, folder_id, name, bytes, drive_id).await?;
|
||||||
stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id });
|
stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id });
|
||||||
uploaded += 1;
|
uploaded += 1;
|
||||||
}
|
}
|
||||||
|
Ok(uploaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
|
||||||
|
let access_token = valid_access_token(&app, &state).await?;
|
||||||
|
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||||
|
let mut stored = load_state(&app);
|
||||||
|
let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?;
|
||||||
if uploaded > 0 {
|
if uploaded > 0 {
|
||||||
stored.last_backup = Some(now());
|
stored.last_backup = Some(now());
|
||||||
save_state(&app, &stored)?;
|
|
||||||
}
|
}
|
||||||
|
save_state(&app, &stored)?;
|
||||||
Ok(BackupOutcome {
|
Ok(BackupOutcome {
|
||||||
uploaded,
|
uploaded,
|
||||||
status: Status {
|
status: Status {
|
||||||
@@ -758,6 +851,60 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
|
|||||||
email: stored.email.clone(),
|
email: stored.email.clone(),
|
||||||
last_backup: stored.last_backup,
|
last_backup: stored.last_backup,
|
||||||
pending: false,
|
pending: false,
|
||||||
|
needs_reauth: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn gdrive_sync(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<SyncOutcome, String> {
|
||||||
|
let access_token = valid_access_token(&app, &state).await?;
|
||||||
|
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||||
|
let remote = list_in_folder(&access_token, &folder_id).await?;
|
||||||
|
let mut stored = load_state(&app);
|
||||||
|
let mut downloaded = 0;
|
||||||
|
|
||||||
|
for file in &remote {
|
||||||
|
let is_dictionary = file.name == DICTIONARY_NAME;
|
||||||
|
if !is_dictionary && !safe_book_name(&file.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let destination = if is_dictionary {
|
||||||
|
dictionary_path(&app)?
|
||||||
|
} else {
|
||||||
|
crate::library::library_dir(&app)?.join(&file.name)
|
||||||
|
};
|
||||||
|
if destination.exists() {
|
||||||
|
let record = stored
|
||||||
|
.files
|
||||||
|
.entry(file.name.clone())
|
||||||
|
.or_insert_with(|| FileRecord { hash: String::new(), drive_id: file.id.clone() });
|
||||||
|
record.drive_id = file.id.clone();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bytes = download_file(&access_token, &file.id).await?;
|
||||||
|
crate::project::atomic_write(&destination, &bytes, false)?;
|
||||||
|
stored.files.insert(
|
||||||
|
file.name.clone(),
|
||||||
|
FileRecord { hash: hash_bytes(&bytes), drive_id: file.id.clone() },
|
||||||
|
);
|
||||||
|
downloaded += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?;
|
||||||
|
if uploaded > 0 || downloaded > 0 {
|
||||||
|
stored.last_backup = Some(now());
|
||||||
|
}
|
||||||
|
save_state(&app, &stored)?;
|
||||||
|
Ok(SyncOutcome {
|
||||||
|
uploaded,
|
||||||
|
downloaded,
|
||||||
|
status: Status {
|
||||||
|
connected: true,
|
||||||
|
email: stored.email.clone(),
|
||||||
|
last_backup: stored.last_backup,
|
||||||
|
pending: false,
|
||||||
|
needs_reauth: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-23
@@ -7,6 +7,8 @@ mod macspell;
|
|||||||
mod pdf;
|
mod pdf;
|
||||||
mod project;
|
mod project;
|
||||||
mod proofing;
|
mod proofing;
|
||||||
|
mod updates;
|
||||||
|
mod writingtools;
|
||||||
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
|
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
|
||||||
@@ -16,50 +18,129 @@ use tauri::{Emitter, Runtime};
|
|||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||||
let menu = Menu::default(handle)?;
|
let menu = Menu::default(handle)?;
|
||||||
let new_book = MenuItemBuilder::with_id("new-book", "New Book")
|
|
||||||
|
let new_book = MenuItemBuilder::with_id("new-book", "New Project")
|
||||||
.accelerator("CmdOrCtrl+N")
|
.accelerator("CmdOrCtrl+N")
|
||||||
.build(handle)?;
|
.build(handle)?;
|
||||||
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…").build(handle)?;
|
let save = MenuItemBuilder::with_id("save", "Save")
|
||||||
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…").build(handle)?;
|
.accelerator("CmdOrCtrl+S")
|
||||||
let check_updates =
|
.build(handle)?;
|
||||||
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
|
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…")
|
||||||
|
.accelerator("CmdOrCtrl+Shift+P")
|
||||||
|
.build(handle)?;
|
||||||
|
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…")
|
||||||
|
.accelerator("CmdOrCtrl+Shift+E")
|
||||||
|
.build(handle)?;
|
||||||
|
let check_updates = (updates::channel(handle) != "none")
|
||||||
|
.then(|| MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle))
|
||||||
|
.transpose()?;
|
||||||
|
let settings = MenuItemBuilder::with_id("settings", "Settings…")
|
||||||
|
.accelerator("CmdOrCtrl+,")
|
||||||
|
.build(handle)?;
|
||||||
|
let find = MenuItemBuilder::with_id("find", "Find…")
|
||||||
|
.accelerator("CmdOrCtrl+F")
|
||||||
|
.build(handle)?;
|
||||||
|
let report_issue =
|
||||||
|
MenuItemBuilder::with_id("report-issue", "Report an Issue…").build(handle)?;
|
||||||
|
|
||||||
let file_submenu = menu
|
let submenus: Vec<_> = menu
|
||||||
.items()?
|
.items()?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find_map(|item| match item {
|
.filter_map(|item| match item {
|
||||||
MenuItemKind::Submenu(submenu)
|
MenuItemKind::Submenu(submenu) => Some(submenu),
|
||||||
if submenu.text().map(|t| t == "File").unwrap_or(false) =>
|
|
||||||
{
|
|
||||||
Some(submenu)
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
match file_submenu {
|
let find_submenu = |name: &str| {
|
||||||
|
submenus
|
||||||
|
.iter()
|
||||||
|
.find(|submenu| submenu.text().map(|t| t == name).unwrap_or(false))
|
||||||
|
.cloned()
|
||||||
|
};
|
||||||
|
|
||||||
|
match find_submenu("File") {
|
||||||
Some(submenu) => {
|
Some(submenu) => {
|
||||||
submenu.prepend_items(&[
|
submenu.prepend_items(&[
|
||||||
&new_book,
|
&new_book,
|
||||||
&PredefinedMenuItem::separator(handle)?,
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
|
&save,
|
||||||
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
&export_pdf,
|
&export_pdf,
|
||||||
&export_epub,
|
&export_epub,
|
||||||
&PredefinedMenuItem::separator(handle)?,
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
&check_updates,
|
|
||||||
&PredefinedMenuItem::separator(handle)?,
|
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let submenu = SubmenuBuilder::new(handle, "File")
|
let submenu = SubmenuBuilder::new(handle, "File")
|
||||||
.item(&new_book)
|
.item(&new_book)
|
||||||
.item(&PredefinedMenuItem::separator(handle)?)
|
.item(&PredefinedMenuItem::separator(handle)?)
|
||||||
|
.item(&save)
|
||||||
|
.item(&PredefinedMenuItem::separator(handle)?)
|
||||||
.item(&export_pdf)
|
.item(&export_pdf)
|
||||||
.item(&export_epub)
|
.item(&export_epub)
|
||||||
.item(&PredefinedMenuItem::separator(handle)?)
|
|
||||||
.item(&check_updates)
|
|
||||||
.build()?;
|
.build()?;
|
||||||
menu.insert(&submenu, 1)?;
|
menu.insert(&submenu, 1)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(edit) = find_submenu("Edit") {
|
||||||
|
edit.append_items(&[&PredefinedMenuItem::separator(handle)?, &find])?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(help) = find_submenu("Help") {
|
||||||
|
help.append_items(&[&report_issue])?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Some(app_submenu) = submenus.first() {
|
||||||
|
let mut settings_at = 2;
|
||||||
|
if let Some(check_updates) = &check_updates {
|
||||||
|
app_submenu.insert(check_updates, 1)?;
|
||||||
|
settings_at += 1;
|
||||||
|
}
|
||||||
|
app_submenu.insert(&settings, settings_at)?;
|
||||||
|
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, settings_at + 1)?;
|
||||||
|
}
|
||||||
|
if let Some(window) = find_submenu("Window") {
|
||||||
|
let show_window = MenuItemBuilder::with_id("show-window", "Open Window")
|
||||||
|
.accelerator("CmdOrCtrl+Shift+M")
|
||||||
|
.build(handle)?;
|
||||||
|
window.append_items(&[&PredefinedMenuItem::separator(handle)?, &show_window])?;
|
||||||
|
}
|
||||||
|
if let Some(view) = find_submenu("View") {
|
||||||
|
let toggle_chapters = MenuItemBuilder::with_id("toggle-chapters", "Toggle Chapters")
|
||||||
|
.accelerator("CmdOrCtrl+\\")
|
||||||
|
.build(handle)?;
|
||||||
|
let next_chapter = MenuItemBuilder::with_id("next-chapter", "Next Chapter")
|
||||||
|
.accelerator("CmdOrCtrl+Alt+Down")
|
||||||
|
.build(handle)?;
|
||||||
|
let prev_chapter = MenuItemBuilder::with_id("prev-chapter", "Previous Chapter")
|
||||||
|
.accelerator("CmdOrCtrl+Alt+Up")
|
||||||
|
.build(handle)?;
|
||||||
|
view.prepend_items(&[
|
||||||
|
&toggle_chapters,
|
||||||
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
|
&next_chapter,
|
||||||
|
&prev_chapter,
|
||||||
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
|
])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
if let Some(file) = find_submenu("File") {
|
||||||
|
if let Some(check_updates) = &check_updates {
|
||||||
|
file.append_items(&[&PredefinedMenuItem::separator(handle)?, check_updates])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(edit) = find_submenu("Edit") {
|
||||||
|
edit.append_items(&[&settings])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(menu)
|
Ok(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +166,7 @@ pub fn run() {
|
|||||||
.manage(gdrive::GDriveState::default())
|
.manage(gdrive::GDriveState::default())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
gdrive::init_session(app.handle());
|
gdrive::init_session(app.handle());
|
||||||
|
writingtools::label_writing_tool_shortcuts();
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,14 +177,28 @@ pub fn run() {
|
|||||||
.on_menu_event(|app, event| {
|
.on_menu_event(|app, event| {
|
||||||
if matches!(
|
if matches!(
|
||||||
event.id().0.as_str(),
|
event.id().0.as_str(),
|
||||||
"new-book" | "export-pdf" | "export-epub" | "check-updates"
|
"new-book"
|
||||||
|
| "save"
|
||||||
|
| "export-pdf"
|
||||||
|
| "export-epub"
|
||||||
|
| "check-updates"
|
||||||
|
| "settings"
|
||||||
|
| "find"
|
||||||
|
| "toggle-chapters"
|
||||||
|
| "next-chapter"
|
||||||
|
| "prev-chapter"
|
||||||
|
| "report-issue"
|
||||||
) {
|
) {
|
||||||
app.emit("menu-action", event.id().0.as_str()).ok();
|
app.emit("menu-action", event.id().0.as_str()).ok();
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if event.id().0.as_str() == "show-window" {
|
||||||
|
open_main_window(app);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
builder
|
let app = builder
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
epub::package_epub,
|
epub::package_epub,
|
||||||
epub::unzip_epub,
|
epub::unzip_epub,
|
||||||
@@ -117,13 +213,44 @@ pub fn run() {
|
|||||||
library::delete_book,
|
library::delete_book,
|
||||||
proofing::proof_text,
|
proofing::proof_text,
|
||||||
proofing::remember_word,
|
proofing::remember_word,
|
||||||
|
writingtools::run_writing_tool,
|
||||||
gdrive::gdrive_connect,
|
gdrive::gdrive_connect,
|
||||||
gdrive::gdrive_disconnect,
|
gdrive::gdrive_disconnect,
|
||||||
gdrive::gdrive_status,
|
gdrive::gdrive_status,
|
||||||
gdrive::gdrive_backup,
|
gdrive::gdrive_backup,
|
||||||
|
gdrive::gdrive_sync,
|
||||||
gdrive::gdrive_restore,
|
gdrive::gdrive_restore,
|
||||||
gdrive::gdrive_list_backups
|
gdrive::gdrive_list_backups,
|
||||||
|
updates::update_channel,
|
||||||
|
updates::appstore_latest,
|
||||||
|
updates::open_appstore
|
||||||
])
|
])
|
||||||
.run(context)
|
.build(context)
|
||||||
.expect("error while running margin");
|
.expect("error while building margin");
|
||||||
|
|
||||||
|
app.run(|_app, _event| {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
match &_event {
|
||||||
|
tauri::RunEvent::ExitRequested { code: None, api, .. } => api.prevent_exit(),
|
||||||
|
tauri::RunEvent::Reopen { has_visible_windows: false, .. } => open_main_window(_app),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn open_main_window(app: &tauri::AppHandle) {
|
||||||
|
use tauri::Manager;
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(config) = app.config().app.windows.first().cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Ok(builder) = tauri::WebviewWindowBuilder::from_config(app, &config) {
|
||||||
|
let _ = builder.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,51 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BookSummary {
|
pub struct BookSummary {
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
author: String,
|
author: String,
|
||||||
corrupt: bool,
|
corrupt: bool,
|
||||||
|
updated_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corrupt_summary(stem: &str) -> BookSummary {
|
fn corrupt_summary(stem: &str) -> BookSummary {
|
||||||
BookSummary {
|
BookSummary {
|
||||||
id: stem.to_string(),
|
id: stem.to_string(),
|
||||||
title: "Unreadable book".to_string(),
|
title: "Unreadable project".to_string(),
|
||||||
author: String::new(),
|
author: String::new(),
|
||||||
corrupt: true,
|
corrupt: true,
|
||||||
|
updated_at: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn file_modified_ms(path: &Path) -> u64 {
|
||||||
|
fs::metadata(path)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest_chapter_ms(value: &serde_json::Value) -> u64 {
|
||||||
|
value
|
||||||
|
.get("chapters")
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.map(|chapters| {
|
||||||
|
chapters
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| c.get("updatedAt").and_then(|v| v.as_u64()))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
@@ -81,13 +108,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let updated_at = match latest_chapter_ms(&value) {
|
||||||
|
0 => file_modified_ms(&path),
|
||||||
|
ms => ms,
|
||||||
|
};
|
||||||
books.push(BookSummary {
|
books.push(BookSummary {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
corrupt: false,
|
corrupt: false,
|
||||||
|
updated_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
books.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
|
||||||
Ok(books)
|
Ok(books)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ pub fn check(text: &str, custom: &HashSet<String>) -> Vec<MacIssue> {
|
|||||||
checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
|
checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
|
||||||
&ns,
|
&ns,
|
||||||
NSRange { location: 0, length: len },
|
NSRange { location: 0, length: len },
|
||||||
NSTextCheckingType::Spelling.bits(),
|
(NSTextCheckingType::Spelling | NSTextCheckingType::Link).bits(),
|
||||||
None,
|
None,
|
||||||
0,
|
0,
|
||||||
None,
|
None,
|
||||||
@@ -45,6 +45,9 @@ pub fn check(text: &str, custom: &HashSet<String>) -> Vec<MacIssue> {
|
|||||||
let chars: Vec<char> = text.chars().collect();
|
let chars: Vec<char> = text.chars().collect();
|
||||||
let mut issues = Vec::new();
|
let mut issues = Vec::new();
|
||||||
for result in results.iter() {
|
for result in results.iter() {
|
||||||
|
if result.resultType() != NSTextCheckingType::Spelling {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let range = result.range();
|
let range = result.range();
|
||||||
let start = map[range.location.min(len)];
|
let start = map[range.location.min(len)];
|
||||||
let end = map[(range.location + range.length).min(len)];
|
let end = map[(range.location + range.length).min(len)];
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Runtime};
|
||||||
|
|
||||||
|
static HTTP: LazyLock<reqwest::Client> = LazyLock::new(reqwest::Client::new);
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AppStoreRelease {
|
||||||
|
version: String,
|
||||||
|
track_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The build flavour decides this, but a Mac App Store receipt overrides it either way: a bundle
|
||||||
|
// carrying one must never self-update, whatever config it was built against.
|
||||||
|
pub fn channel<R: Runtime>(handle: &AppHandle<R>) -> &'static str {
|
||||||
|
let plugins = &handle.config().plugins.0;
|
||||||
|
if plugins.contains_key("updater") && !mas_receipt() {
|
||||||
|
"direct"
|
||||||
|
} else if mas_receipt() || plugins.contains_key("appstore") {
|
||||||
|
"appstore"
|
||||||
|
} else {
|
||||||
|
"none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn mas_receipt() -> bool {
|
||||||
|
let Ok(exe) = std::env::current_exe() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
exe.parent()
|
||||||
|
.and_then(|macos| macos.parent())
|
||||||
|
.map(|contents| contents.join("_MASReceipt").join("receipt").exists())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn mas_receipt() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_channel(app: AppHandle) -> &'static str {
|
||||||
|
channel(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple's lookup endpoint is edge cached, so a release can take hours to show up here. The
|
||||||
|
// timestamp is the usual way past that.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn appstore_latest(app: AppHandle) -> Result<Option<AppStoreRelease>, String> {
|
||||||
|
let bundle_id = app.config().identifier.clone();
|
||||||
|
let current = app.package_info().version.clone();
|
||||||
|
let stamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let url = format!("https://itunes.apple.com/lookup?bundleId={bundle_id}&t={stamp}");
|
||||||
|
|
||||||
|
let body: serde_json::Value = HTTP
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let Some(result) = body["results"].get(0) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let (Some(version), Some(track_id)) = (result["version"].as_str(), result["trackId"].as_u64())
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Ok(latest) = semver::Version::parse(version) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if latest <= current {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(AppStoreRelease { version: version.to_string(), track_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_appstore(track_id: u64) -> Result<(), String> {
|
||||||
|
let url = format!("macappstore://apps.apple.com/app/id{track_id}");
|
||||||
|
tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod mac {
|
||||||
|
use objc2::rc::Retained;
|
||||||
|
use objc2::MainThreadMarker;
|
||||||
|
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSMenu, NSMenuItem};
|
||||||
|
use objc2_foundation::{NSArray, NSString};
|
||||||
|
|
||||||
|
const SHORTCUTS: [(&str, &str); 2] = [("Proofread", "f"), ("Rewrite", "r")];
|
||||||
|
|
||||||
|
fn submenu_named(items: &NSArray<NSMenuItem>, title: &str) -> Option<Retained<NSMenu>> {
|
||||||
|
for i in 0..items.count() {
|
||||||
|
let item = items.objectAtIndex(i);
|
||||||
|
if item.title().to_string() == title {
|
||||||
|
return item.submenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
|
||||||
|
let main = NSApplication::sharedApplication(mtm).mainMenu()?;
|
||||||
|
for i in 0..main.numberOfItems() {
|
||||||
|
let Some(item) = main.itemAtIndex(i) else { continue };
|
||||||
|
let Some(submenu) = item.submenu() else { continue };
|
||||||
|
if submenu.title().to_string() == "Edit" || item.title().to_string() == "Edit" {
|
||||||
|
return Some(submenu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn writing_tools_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
|
||||||
|
submenu_named(&edit_menu(mtm)?.itemArray(), "Writing Tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label_shortcuts() {
|
||||||
|
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||||
|
let Some(menu) = writing_tools_menu(mtm) else { return };
|
||||||
|
let items = menu.itemArray();
|
||||||
|
for i in 0..items.count() {
|
||||||
|
let item = items.objectAtIndex(i);
|
||||||
|
let title = item.title().to_string();
|
||||||
|
let Some((_, key)) = SHORTCUTS.iter().find(|(name, _)| *name == title) else { continue };
|
||||||
|
item.setKeyEquivalent(&NSString::from_str(key));
|
||||||
|
item.setKeyEquivalentModifierMask(
|
||||||
|
NSEventModifierFlags::Shift | NSEventModifierFlags::Option,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn perform(tool: &str) {
|
||||||
|
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||||
|
let Some(menu) = writing_tools_menu(mtm) else { return };
|
||||||
|
let items = menu.itemArray();
|
||||||
|
for i in 0..items.count() {
|
||||||
|
if items.objectAtIndex(i).title().to_string() == tool {
|
||||||
|
menu.performActionForItemAtIndex(i as isize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label_writing_tool_shortcuts() {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mac::label_shortcuts();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn run_writing_tool(app: tauri::AppHandle, tool: String) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
app.run_on_main_thread(move || mac::perform(&tool))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let _ = (app, tool);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["app"],
|
||||||
|
"macOS": {
|
||||||
|
"hardenedRuntime": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"appstore": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Margin",
|
"productName": "Margin",
|
||||||
"version": "0.1.12",
|
"version": "0.1.17",
|
||||||
"identifier": "studio.margin.app",
|
"identifier": "studio.margin.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
@@ -36,6 +36,14 @@
|
|||||||
"icons/[email protected]",
|
"icons/[email protected]",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
],
|
||||||
|
"category": "Productivity",
|
||||||
|
"shortDescription": "A calm, offline writing studio for authors",
|
||||||
|
"longDescription": "Write your book, set it in real book typography, and export print-ready PDF and ebook files for every store. Everything happens on your machine.",
|
||||||
|
"copyright": "Copyright © 2026 Priyanshu Jain",
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "10.15",
|
||||||
|
"hardenedRuntime": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+26
-8
@@ -1,15 +1,17 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { Library } from "./components/Library";
|
import { Library } from "./components/Library";
|
||||||
import { EditorView } from "./components/EditorView";
|
import { EditorView } from "./components/EditorView";
|
||||||
import { BackupSettings } from "./components/BackupSettings";
|
import { BackupSettings } from "./components/BackupSettings";
|
||||||
import { ExportPreview } from "./components/ExportPreview";
|
import { ExportPreview } from "./components/ExportPreview";
|
||||||
|
import { UpdateDialog } from "./components/UpdateDialog";
|
||||||
import { useBook } from "./store/useBook";
|
import { useBook } from "./store/useBook";
|
||||||
import { useBackup } from "./store/useBackup";
|
import { useBackup } from "./store/useBackup";
|
||||||
import { useExportPreview } from "./store/useExportPreview";
|
import { useExportPreview } from "./store/useExportPreview";
|
||||||
import { isDesktop } from "./ipc";
|
import { isDesktop } from "./ipc";
|
||||||
import { createAndOpenBook, saveBook } from "./library";
|
import { createAndOpenBook, lastBookId, loadBook, saveBook } from "./library";
|
||||||
import { runExport } from "./export/run";
|
import { runExport } from "./export/run";
|
||||||
import { checkForUpdates } from "./updater";
|
import { checkForUpdates } from "./updater";
|
||||||
|
|
||||||
@@ -28,6 +30,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
else if (event.payload === "export-epub") runExport("epub");
|
else if (event.payload === "export-epub") runExport("epub");
|
||||||
else if (event.payload === "check-updates") checkForUpdates(false);
|
else if (event.payload === "check-updates") checkForUpdates(false);
|
||||||
|
else if (event.payload === "report-issue")
|
||||||
|
openUrl("https://github.com/priyanshujain/margin/issues").catch(() => {});
|
||||||
});
|
});
|
||||||
const unlistenWarn = listen<string>("pdf-warnings", (event) => {
|
const unlistenWarn = listen<string>("pdf-warnings", (event) => {
|
||||||
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
|
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
|
||||||
@@ -47,12 +51,7 @@ function App() {
|
|||||||
if ((!book || !dirty) && !connected) return;
|
if ((!book || !dirty) && !connected) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (book && dirty) await saveBook(book).catch(() => {});
|
if (book && dirty) await saveBook(book).catch(() => {});
|
||||||
if (connected) {
|
if (connected) useBackup.getState().backup(true);
|
||||||
await Promise.race([
|
|
||||||
useBackup.getState().backup(true),
|
|
||||||
new Promise((resolve) => setTimeout(resolve, 8000)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
win.destroy();
|
win.destroy();
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -72,21 +71,40 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDesktop) return;
|
if (!isDesktop) return;
|
||||||
|
let first = true;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
await useBackup.getState().refresh();
|
await useBackup.getState().refresh();
|
||||||
const state = useBackup.getState();
|
const state = useBackup.getState();
|
||||||
if (state.connected && state.pending) state.backup(true);
|
if (!state.connected || state.needsReauth) return;
|
||||||
|
if (first) {
|
||||||
|
first = false;
|
||||||
|
state.sync(true);
|
||||||
|
} else if (state.pending) {
|
||||||
|
state.backup(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
const id = setInterval(tick, 15 * 60 * 1000);
|
const id = setInterval(tick, 15 * 60 * 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktop) return;
|
||||||
|
const id = lastBookId();
|
||||||
|
if (!id || useBook.getState().book) return;
|
||||||
|
loadBook(id)
|
||||||
|
.then((last) => {
|
||||||
|
if (!useBook.getState().book) useBook.getState().openBook(last);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
||||||
{isDesktop && <BackupSettings />}
|
{isDesktop && <BackupSettings />}
|
||||||
{isDesktop && <ExportPreview />}
|
{isDesktop && <ExportPreview />}
|
||||||
|
{isDesktop && <UpdateDialog />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -6,6 +6,7 @@ export interface BackupStatus {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
lastBackup: number | null;
|
lastBackup: number | null;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
|
needsReauth: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RestoreResult {
|
export interface RestoreResult {
|
||||||
@@ -16,11 +17,16 @@ export interface BackupOutcome extends BackupStatus {
|
|||||||
uploaded: number;
|
uploaded: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncOutcome extends BackupStatus {
|
||||||
|
uploaded: number;
|
||||||
|
downloaded: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RemoteBackup {
|
export interface RemoteBackup {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false };
|
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false, needsReauth: false };
|
||||||
|
|
||||||
export async function gdriveStatus(): Promise<BackupStatus> {
|
export async function gdriveStatus(): Promise<BackupStatus> {
|
||||||
if (!isDesktop) return OFFLINE;
|
if (!isDesktop) return OFFLINE;
|
||||||
@@ -39,6 +45,10 @@ export async function gdriveBackup(): Promise<BackupOutcome> {
|
|||||||
return invoke<BackupOutcome>("gdrive_backup");
|
return invoke<BackupOutcome>("gdrive_backup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function gdriveSync(): Promise<SyncOutcome> {
|
||||||
|
return invoke<SyncOutcome>("gdrive_sync");
|
||||||
|
}
|
||||||
|
|
||||||
export async function gdriveRestore(): Promise<RestoreResult> {
|
export async function gdriveRestore(): Promise<RestoreResult> {
|
||||||
return invoke<RestoreResult>("gdrive_restore");
|
return invoke<RestoreResult>("gdrive_restore");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { PAGE_TYPES } from "../model/book";
|
import { PAGE_TYPES } from "../model/book";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { useFocusTrap } from "../focus";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
interface AddPageMenuProps {
|
interface AddPageMenuProps {
|
||||||
@@ -16,6 +18,7 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||||
const btnRef = useRef<HTMLButtonElement>(null);
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
if (!open && btnRef.current) {
|
if (!open && btnRef.current) {
|
||||||
@@ -38,6 +41,23 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useFocusTrap(popRef, open);
|
||||||
|
|
||||||
|
useEscapeLayer(open, () => {
|
||||||
|
setOpen(false);
|
||||||
|
btnRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".add-page-item") ?? []);
|
||||||
|
const idx = items.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const nextIdx = e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
|
||||||
|
items[nextIdx]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const pick = (group: "front" | "back", label: string) => {
|
const pick = (group: "front" | "back", label: string) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onAdd(group, label);
|
onAdd(group, label);
|
||||||
@@ -56,9 +76,11 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
|
ref={popRef}
|
||||||
className="add-page-pop"
|
className="add-page-pop"
|
||||||
style={{ top: coords.top, left: coords.left }}
|
style={{ top: coords.top, left: coords.left }}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={onMenuKeyDown}
|
||||||
>
|
>
|
||||||
<div className="add-page-group">
|
<div className="add-page-group">
|
||||||
<div className="add-page-label">Body</div>
|
<div className="add-page-label">Body</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { useBackup } from "../store/useBackup";
|
import { useBackup } from "../store/useBackup";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { useFocusTrap } from "../focus";
|
||||||
import { formatBackupTime, gdriveListBackups } from "../backup";
|
import { formatBackupTime, gdriveListBackups } from "../backup";
|
||||||
|
|
||||||
export function BackupSettings() {
|
export function BackupSettings() {
|
||||||
@@ -11,10 +13,12 @@ export function BackupSettings() {
|
|||||||
const email = useBackup((s) => s.email);
|
const email = useBackup((s) => s.email);
|
||||||
const lastBackup = useBackup((s) => s.lastBackup);
|
const lastBackup = useBackup((s) => s.lastBackup);
|
||||||
const pending = useBackup((s) => s.pending);
|
const pending = useBackup((s) => s.pending);
|
||||||
|
const needsReauth = useBackup((s) => s.needsReauth);
|
||||||
const phase = useBackup((s) => s.phase);
|
const phase = useBackup((s) => s.phase);
|
||||||
const connect = useBackup((s) => s.connect);
|
const connect = useBackup((s) => s.connect);
|
||||||
const disconnect = useBackup((s) => s.disconnect);
|
const disconnect = useBackup((s) => s.disconnect);
|
||||||
const backup = useBackup((s) => s.backup);
|
const backup = useBackup((s) => s.backup);
|
||||||
|
const sync = useBackup((s) => s.sync);
|
||||||
const restore = useBackup((s) => s.restore);
|
const restore = useBackup((s) => s.restore);
|
||||||
const cancelConnect = useBackup((s) => s.cancelConnect);
|
const cancelConnect = useBackup((s) => s.cancelConnect);
|
||||||
const openAuthUrl = useBackup((s) => s.openAuthUrl);
|
const openAuthUrl = useBackup((s) => s.openAuthUrl);
|
||||||
@@ -22,10 +26,13 @@ export function BackupSettings() {
|
|||||||
|
|
||||||
const [remoteCount, setRemoteCount] = useState<number | null>(null);
|
const [remoteCount, setRemoteCount] = useState<number | null>(null);
|
||||||
const [confirmRestore, setConfirmRestore] = useState(false);
|
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const working = phase === "working";
|
const working = phase === "working";
|
||||||
|
|
||||||
|
useFocusTrap(panelRef, open && !confirmRestore);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !connected) {
|
if (!open || !connected || needsReauth) {
|
||||||
setRemoteCount(null);
|
setRemoteCount(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -36,14 +43,16 @@ export function BackupSettings() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [open, connected, lastBackup]);
|
}, [open, connected, needsReauth, lastBackup]);
|
||||||
|
|
||||||
|
useEscapeLayer(open, close);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="overlay" onClick={close}>
|
<div className="overlay" onClick={close}>
|
||||||
<div className="panel" onClick={(e) => e.stopPropagation()}>
|
<div ref={panelRef} className="panel" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
<h2>Backup & Sync</h2>
|
<h2>Backup & Sync</h2>
|
||||||
<button className="icon-btn" onClick={close} title="Close">
|
<button className="icon-btn" onClick={close} title="Close">
|
||||||
@@ -78,7 +87,7 @@ export function BackupSettings() {
|
|||||||
) : !connected ? (
|
) : !connected ? (
|
||||||
<div className="backup-intro">
|
<div className="backup-intro">
|
||||||
<p>
|
<p>
|
||||||
Connect Google Drive to keep a private backup of every book. margin only ever sees the files it
|
Connect Google Drive to keep a private backup of every project. margin only ever sees the files it
|
||||||
creates in a <strong>margin</strong> folder, never the rest of your Drive.
|
creates in a <strong>margin</strong> folder, never the rest of your Drive.
|
||||||
</p>
|
</p>
|
||||||
<button className="btn-primary" disabled={working} onClick={connect}>
|
<button className="btn-primary" disabled={working} onClick={connect}>
|
||||||
@@ -97,27 +106,51 @@ export function BackupSettings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="backup-status-row">
|
{needsReauth ? (
|
||||||
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
|
<>
|
||||||
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
|
<div className="backup-warn">
|
||||||
</div>
|
margin can't reach your Drive folder. The permission to create and edit its own files was
|
||||||
|
not granted, so nothing is being backed up.
|
||||||
|
</div>
|
||||||
|
<div className="backup-actions">
|
||||||
|
<button className="btn-primary" disabled={working} onClick={connect}>
|
||||||
|
{working ? "Working…" : "Reconnect Google Drive"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="backup-note">
|
||||||
|
On the Google screen, tick <strong>See, edit, create and delete only the specific Google Drive
|
||||||
|
files you use with this app</strong> before continuing.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="backup-status-row">
|
||||||
|
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
|
||||||
|
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="backup-actions">
|
<div className="backup-actions">
|
||||||
<button className="btn-primary" disabled={working} onClick={() => backup()}>
|
<button className="btn-primary" disabled={working} onClick={() => sync()}>
|
||||||
{working ? "Working…" : "Back up now"}
|
{working ? "Working…" : "Sync now"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="btn-ghost" disabled={working} onClick={() => backup()}>
|
||||||
className="btn-ghost"
|
Back up only
|
||||||
disabled={working || remoteCount === 0}
|
</button>
|
||||||
onClick={() => setConfirmRestore(true)}
|
<button
|
||||||
>
|
className="btn-ghost"
|
||||||
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
|
disabled={working || remoteCount === 0}
|
||||||
</button>
|
onClick={() => setConfirmRestore(true)}
|
||||||
</div>
|
>
|
||||||
|
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} project${remoteCount === 1 ? "" : "s"}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="backup-note">
|
<p className="backup-note">
|
||||||
Backs up automatically when you close margin, and every 15 minutes while there are changes.
|
Syncs on launch and right after connecting, backs up when you close margin, and every 15 minutes
|
||||||
</p>
|
while there are changes.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { useFocusTrap } from "../focus";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
@@ -10,9 +12,19 @@ interface ConfirmDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
|
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
confirmRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEscapeLayer(true, onClose);
|
||||||
|
useFocusTrap(panelRef);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" onClick={onClose}>
|
||||||
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
<div ref={panelRef} className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<button className="icon-btn" onClick={onClose} title="Close">
|
<button className="icon-btn" onClick={onClose} title="Close">
|
||||||
@@ -26,7 +38,7 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
|
|||||||
<button className="btn-ghost" onClick={onClose}>
|
<button className="btn-ghost" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-danger" onClick={onConfirm}>
|
<button ref={confirmRef} className="btn-danger" onClick={onConfirm}>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function CoverView() {
|
|||||||
<Icon d="M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4" /> Upload image
|
<Icon d="M4 5h16v14H4zM4 16l4.5-4.5 3 3L16 10l4 4" /> Upload image
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="cover-hint">Title, subtitle & author are set in Book setup.</p>
|
<p className="cover-hint">Title, subtitle & author are set in Project setup.</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
|
|||||||
+232
-140
@@ -1,9 +1,12 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||||
|
import { icons } from "margin-shared";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { Dock } from "./Dock";
|
import { Dock } from "./Dock";
|
||||||
import { ResizeHandle } from "./ResizeHandle";
|
import { ResizeHandle } from "./ResizeHandle";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
import { Menu } from "./Menu";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { BackupButton } from "./BackupButton";
|
import { BackupButton } from "./BackupButton";
|
||||||
import { CoverView } from "./CoverView";
|
import { CoverView } from "./CoverView";
|
||||||
@@ -21,11 +24,18 @@ import { useWidth } from "../store/useWidth";
|
|||||||
import { WIDTH_OPTIONS } from "../width";
|
import { WIDTH_OPTIONS } from "../width";
|
||||||
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
||||||
import { saveBook } from "../library";
|
import { saveBook } from "../library";
|
||||||
import { isDesktop } from "../ipc";
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { focusTrapped } from "../focus";
|
||||||
|
import { isDesktop, runWritingTool } from "../ipc";
|
||||||
import { useCompact } from "../useMedia";
|
import { useCompact } from "../useMedia";
|
||||||
import { issueSignature, rememberWord, runProof } from "../proofing";
|
import { issueSignature, rememberWord, runProof } from "../proofing";
|
||||||
import { runExport } from "../export/run";
|
import { runExport } from "../export/run";
|
||||||
|
|
||||||
|
const WRITING_TOOLS: Record<string, "Proofread" | "Rewrite"> = {
|
||||||
|
KeyF: "Proofread",
|
||||||
|
KeyR: "Rewrite",
|
||||||
|
};
|
||||||
|
|
||||||
export function EditorView() {
|
export function EditorView() {
|
||||||
const book = useBook((s) => s.book);
|
const book = useBook((s) => s.book);
|
||||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||||
@@ -36,6 +46,8 @@ export function EditorView() {
|
|||||||
const setChapterContent = useBook((s) => s.setChapterContent);
|
const setChapterContent = useBook((s) => s.setChapterContent);
|
||||||
const setChapterTitle = useBook((s) => s.setChapterTitle);
|
const setChapterTitle = useBook((s) => s.setChapterTitle);
|
||||||
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
|
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
|
||||||
|
const pendingTitleFocus = useBook((s) => s.pendingTitleFocus);
|
||||||
|
const clearTitleFocus = useBook((s) => s.clearTitleFocus);
|
||||||
const markSaved = useBook((s) => s.markSaved);
|
const markSaved = useBook((s) => s.markSaved);
|
||||||
const theme = useTheme((s) => s.theme);
|
const theme = useTheme((s) => s.theme);
|
||||||
const toggleTheme = useTheme((s) => s.toggle);
|
const toggleTheme = useTheme((s) => s.toggle);
|
||||||
@@ -44,8 +56,11 @@ export function EditorView() {
|
|||||||
|
|
||||||
const isCompact = useCompact();
|
const isCompact = useCompact();
|
||||||
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
||||||
|
const editorRef = useRef<TiptapEditor | null>(null);
|
||||||
|
editorRef.current = editor;
|
||||||
|
const lastChapterRef = useRef<string>("");
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
|
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
|
||||||
const [dock, setDock] = useState(!isCompact);
|
const [dock, setDock] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
@@ -53,6 +68,10 @@ export function EditorView() {
|
|||||||
const [findOpen, setFindOpen] = useState(false);
|
const [findOpen, setFindOpen] = useState(false);
|
||||||
const [findExpanded, setFindExpanded] = useState(false);
|
const [findExpanded, setFindExpanded] = useState(false);
|
||||||
const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null);
|
const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null);
|
||||||
|
const [escArmed, setEscArmed] = useState(false);
|
||||||
|
const escArmedRef = useRef(false);
|
||||||
|
escArmedRef.current = escArmed;
|
||||||
|
const escTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
const spelling = useProofing((s) => s.spelling);
|
const spelling = useProofing((s) => s.spelling);
|
||||||
const grammar = useProofing((s) => s.grammar);
|
const grammar = useProofing((s) => s.grammar);
|
||||||
@@ -78,11 +97,43 @@ export function EditorView() {
|
|||||||
setDock(false);
|
setDock(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEscapeLayer(true, () => {
|
||||||
|
clearTimeout(escTimer.current);
|
||||||
|
if (escArmedRef.current) {
|
||||||
|
setEscArmed(false);
|
||||||
|
useBook.getState().closeBook();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
|
setEscArmed(true);
|
||||||
|
escTimer.current = setTimeout(() => setEscArmed(false), 1500);
|
||||||
|
});
|
||||||
|
useEscapeLayer(isCompact && (sidebarOpen || dock), closeDrawers);
|
||||||
|
useEscapeLayer(widthOpen, () => setWidthOpen(false));
|
||||||
|
useEscapeLayer(exportOpen, () => setExportOpen(false));
|
||||||
|
useEscapeLayer(moreOpen, () => setMoreOpen(false));
|
||||||
|
useEscapeLayer(!!proofPopover, () => setProofPopover(null));
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(escTimer.current), []);
|
||||||
|
|
||||||
const openFind = useCallback((expanded: boolean) => {
|
const openFind = useCallback((expanded: boolean) => {
|
||||||
setFindExpanded(expanded);
|
setFindExpanded(expanded);
|
||||||
setFindOpen(true);
|
setFindOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const focusEditorSoon = useCallback(() => {
|
||||||
|
if (useBook.getState().pendingTitleFocus || focusTrapped()) return;
|
||||||
|
requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchChapter = useCallback(
|
||||||
|
(dir: 1 | -1) => {
|
||||||
|
useBook.getState().goToAdjacentChapter(dir);
|
||||||
|
focusEditorSoon();
|
||||||
|
},
|
||||||
|
[focusEditorSoon]
|
||||||
|
);
|
||||||
|
|
||||||
const reproof = useCallback(() => {
|
const reproof = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const state = useProofing.getState();
|
const state = useProofing.getState();
|
||||||
@@ -114,17 +165,50 @@ export function EditorView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
|
if (focusTrapped()) return;
|
||||||
|
if (e.metaKey && e.altKey && e.key.toLowerCase() === "f") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
saveNow();
|
openFind(true);
|
||||||
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
|
} else if (e.metaKey && e.altKey && e.key === "ArrowRight") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openFind(e.altKey);
|
e.stopPropagation();
|
||||||
|
switchChapter(1);
|
||||||
|
} else if (e.metaKey && e.altKey && e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
switchChapter(-1);
|
||||||
|
} else if (e.ctrlKey && !e.metaKey && e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
switchChapter(e.shiftKey ? -1 : 1);
|
||||||
|
} else if (e.shiftKey && e.altKey && !e.metaKey && !e.ctrlKey && WRITING_TOOLS[e.code]) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
runWritingTool(WRITING_TOOLS[e.code]).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey, true);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey, true);
|
||||||
}, [saveNow, openFind]);
|
}, [openFind, switchChapter]);
|
||||||
|
|
||||||
|
const menuCommands = useRef({ saveNow, openFind, setSettingsOpen, toggleSidebar, switchChapter });
|
||||||
|
menuCommands.current = { saveNow, openFind, setSettingsOpen, toggleSidebar, switchChapter };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktop) return;
|
||||||
|
const unlisten = listen<string>("menu-action", (e) => {
|
||||||
|
const cmd = menuCommands.current;
|
||||||
|
if (e.payload === "save") cmd.saveNow();
|
||||||
|
else if (e.payload === "find") cmd.openFind(false);
|
||||||
|
else if (e.payload === "settings") cmd.setSettingsOpen(true);
|
||||||
|
else if (e.payload === "toggle-chapters") cmd.toggleSidebar();
|
||||||
|
else if (e.payload === "next-chapter") cmd.switchChapter(1);
|
||||||
|
else if (e.payload === "prev-chapter") cmd.switchChapter(-1);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unlisten.then((stop) => stop());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notice) return;
|
if (!notice) return;
|
||||||
@@ -134,7 +218,7 @@ export function EditorView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSidebarOpen(!isCompact);
|
setSidebarOpen(!isCompact);
|
||||||
setDock(!isCompact);
|
if (isCompact) setDock(false);
|
||||||
}, [isCompact]);
|
}, [isCompact]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -177,8 +261,11 @@ export function EditorView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
const activeIdx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
const chapter = book.chapters[idx] ?? book.chapters[0];
|
const activeChapter = book.chapters[activeIdx];
|
||||||
|
if (activeChapter) lastChapterRef.current = activeChapter.id;
|
||||||
|
const editorIdx = activeChapter ? activeIdx : book.chapters.findIndex((c) => c.id === lastChapterRef.current);
|
||||||
|
const chapter = book.chapters[editorIdx] ?? book.chapters[0];
|
||||||
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
|
||||||
const kind = chapter ? chapterKind(chapter) : "body";
|
const kind = chapter ? chapterKind(chapter) : "body";
|
||||||
const eyebrow =
|
const eyebrow =
|
||||||
@@ -195,140 +282,125 @@ export function EditorView() {
|
|||||||
<header className="titlebar" data-tauri-drag-region>
|
<header className="titlebar" data-tauri-drag-region>
|
||||||
<div className="lead">
|
<div className="lead">
|
||||||
<button className="icon-btn" data-on={sidebarOpen} onClick={toggleSidebar} title="Toggle chapters" aria-label="Toggle chapters">
|
<button className="icon-btn" data-on={sidebarOpen} onClick={toggleSidebar} title="Toggle chapters" aria-label="Toggle chapters">
|
||||||
<Icon d="M3 4.5h18v15H3zM9 4.5v15" />
|
<Icon d={icons.SIDEBAR} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
|
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Project setup">
|
||||||
{book.metadata.title || "Untitled"}
|
{book.metadata.title || "Untitled"}
|
||||||
{dirty && <span className="dirty-dot" />}
|
{dirty && <span className="dirty-dot" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{isDesktop && !isCompact && <BackupButton />}
|
{isDesktop && !isCompact && <BackupButton />}
|
||||||
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
||||||
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
<Icon d={icons.SEARCH} />
|
||||||
</button>
|
</button>
|
||||||
{!isCompact && proofingAvailable && !coverActive && (
|
{!isCompact && proofingAvailable && !coverActive && (
|
||||||
<>
|
<>
|
||||||
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
|
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
|
||||||
<Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
|
<Icon d={icons.SPELLING} />
|
||||||
</button>
|
</button>
|
||||||
<button className="icon-btn" data-on={grammar} onClick={toggleGrammar} title="Check grammar">
|
<button className="icon-btn" data-on={grammar} onClick={toggleGrammar} title="Check grammar">
|
||||||
<Icon d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" />
|
<Icon d={icons.GRAMMAR} />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isCompact && (
|
{!isCompact && (
|
||||||
<div className="menu-wrap">
|
<div className="menu-wrap">
|
||||||
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
|
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
|
||||||
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
|
<Icon d={icons.WIDTH} />
|
||||||
</button>
|
</button>
|
||||||
{widthOpen && (
|
<Menu open={widthOpen} onClose={() => setWidthOpen(false)}>
|
||||||
<>
|
{WIDTH_OPTIONS.map((w) => (
|
||||||
<div className="menu-backdrop" onClick={() => setWidthOpen(false)} />
|
<button
|
||||||
<div className="menu">
|
key={w.id}
|
||||||
{WIDTH_OPTIONS.map((w) => (
|
data-on={width === w.id}
|
||||||
<button
|
onClick={() => {
|
||||||
key={w.id}
|
setWidth(w.id);
|
||||||
data-on={width === w.id}
|
setWidthOpen(false);
|
||||||
onClick={() => {
|
}}
|
||||||
setWidth(w.id);
|
>
|
||||||
setWidthOpen(false);
|
{w.label}
|
||||||
}}
|
</button>
|
||||||
>
|
))}
|
||||||
{w.label}
|
</Menu>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCompact && (
|
{!isCompact && (
|
||||||
<div className="menu-wrap">
|
<div className="menu-wrap">
|
||||||
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
|
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
|
||||||
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
|
<Icon d={icons.EXPORT} />
|
||||||
</button>
|
</button>
|
||||||
{exportOpen && (
|
<Menu open={exportOpen} onClose={() => setExportOpen(false)}>
|
||||||
<>
|
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
|
||||||
<div className="menu-backdrop" onClick={() => setExportOpen(false)} />
|
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
|
||||||
<div className="menu">
|
</Menu>
|
||||||
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
|
|
||||||
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCompact && (
|
{!isCompact && (
|
||||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
|
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? (
|
||||||
<Icon>
|
<Icon>
|
||||||
<circle cx="12" cy="12" r="4" />
|
<circle cx={icons.SUN_DISC.cx} cy={icons.SUN_DISC.cy} r={icons.SUN_DISC.r} />
|
||||||
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4" />
|
<path d={icons.SUN_RAYS} />
|
||||||
</Icon>
|
</Icon>
|
||||||
) : (
|
) : (
|
||||||
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
<Icon d={icons.MOON} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="icon-btn" data-on={dock} onClick={toggleDock} title="Toggle preview">
|
<button className="icon-btn" data-on={dock} onClick={toggleDock} title="Toggle preview">
|
||||||
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
|
<Icon d={icons.DOCK} />
|
||||||
</button>
|
</button>
|
||||||
{isCompact && (
|
{isCompact && (
|
||||||
<div className="menu-wrap">
|
<div className="menu-wrap">
|
||||||
<button className="icon-btn" data-on={moreOpen} onClick={() => setMoreOpen((v) => !v)} title="More" aria-label="More options">
|
<button className="icon-btn" data-on={moreOpen} onClick={() => setMoreOpen((v) => !v)} title="More" aria-label="More options">
|
||||||
<Icon d="M5 12h.01M12 12h.01M19 12h.01" />
|
<Icon d={icons.MORE} />
|
||||||
</button>
|
</button>
|
||||||
{moreOpen && (
|
<Menu open={moreOpen} onClose={() => setMoreOpen(false)}>
|
||||||
<>
|
<div className="menu-label">Editor width</div>
|
||||||
<div className="menu-backdrop" onClick={() => setMoreOpen(false)} />
|
{WIDTH_OPTIONS.map((w) => (
|
||||||
<div className="menu">
|
<button
|
||||||
<div className="menu-label">Editor width</div>
|
key={w.id}
|
||||||
{WIDTH_OPTIONS.map((w) => (
|
data-on={width === w.id}
|
||||||
<button
|
onClick={() => {
|
||||||
key={w.id}
|
setWidth(w.id);
|
||||||
data-on={width === w.id}
|
setMoreOpen(false);
|
||||||
onClick={() => {
|
}}
|
||||||
setWidth(w.id);
|
>
|
||||||
setMoreOpen(false);
|
{w.label}
|
||||||
}}
|
</button>
|
||||||
>
|
))}
|
||||||
{w.label}
|
<div className="menu-sep" />
|
||||||
</button>
|
<button
|
||||||
))}
|
onClick={() => {
|
||||||
|
setMoreOpen(false);
|
||||||
|
handleExport("pdf");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export PDF…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMoreOpen(false);
|
||||||
|
handleExport("epub");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export EPUB…
|
||||||
|
</button>
|
||||||
|
{proofingAvailable && !coverActive && (
|
||||||
|
<>
|
||||||
<div className="menu-sep" />
|
<div className="menu-sep" />
|
||||||
<button
|
<button data-on={spelling} onClick={toggleSpelling}>
|
||||||
onClick={() => {
|
Check spelling
|
||||||
setMoreOpen(false);
|
|
||||||
handleExport("pdf");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Export PDF…
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button data-on={grammar} onClick={toggleGrammar}>
|
||||||
onClick={() => {
|
Check grammar
|
||||||
setMoreOpen(false);
|
|
||||||
handleExport("epub");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Export EPUB…
|
|
||||||
</button>
|
</button>
|
||||||
{proofingAvailable && !coverActive && (
|
</>
|
||||||
<>
|
)}
|
||||||
<div className="menu-sep" />
|
<div className="menu-sep" />
|
||||||
<button data-on={spelling} onClick={toggleSpelling}>
|
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
|
||||||
Check spelling
|
</Menu>
|
||||||
</button>
|
|
||||||
<button data-on={grammar} onClick={toggleGrammar}>
|
|
||||||
Check grammar
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="menu-sep" />
|
|
||||||
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -342,50 +414,51 @@ export function EditorView() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} />
|
<Sidebar
|
||||||
|
onNavigate={() => {
|
||||||
|
if (isCompact) setSidebarOpen(false);
|
||||||
|
focusEditorSoon();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
|
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
|
||||||
<main className="editor-pane">
|
<main className="editor-pane">
|
||||||
{coverActive ? (
|
{coverActive && <CoverView />}
|
||||||
<CoverView />
|
<article className="sheet" hidden={coverActive}>
|
||||||
) : (
|
<header className="chapter-opener">
|
||||||
<>
|
<div className="chapter-num">{eyebrow}</div>
|
||||||
<article className="sheet">
|
<ChapterTitleInput
|
||||||
<header className="chapter-opener">
|
value={chapter.title}
|
||||||
<div className="chapter-num">{eyebrow}</div>
|
placeholder={
|
||||||
<ChapterTitleInput
|
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
|
||||||
value={chapter.title}
|
}
|
||||||
placeholder={
|
disabled={!!chapter.noTitle}
|
||||||
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
|
focusRequest={pendingTitleFocus === chapter.id}
|
||||||
}
|
onFocused={clearTitleFocus}
|
||||||
disabled={!!chapter.noTitle}
|
onChange={(value) => setChapterTitle(chapter.id, value)}
|
||||||
onChange={(value) => setChapterTitle(chapter.id, value)}
|
/>
|
||||||
|
{(chapter.noTitle || !chapter.title.trim()) && (
|
||||||
|
<label className="no-title-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!chapter.noTitle}
|
||||||
|
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)}
|
||||||
/>
|
/>
|
||||||
{(chapter.noTitle || !chapter.title.trim()) && (
|
No title
|
||||||
<label className="no-title-check">
|
</label>
|
||||||
<input
|
)}
|
||||||
type="checkbox"
|
</header>
|
||||||
checked={!!chapter.noTitle}
|
<Editor
|
||||||
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)}
|
bookId={book.id}
|
||||||
/>
|
chapterId={chapter.id}
|
||||||
No title
|
content={chapter.content}
|
||||||
</label>
|
onChange={(content) => setChapterContent(chapter.id, content)}
|
||||||
)}
|
onReady={setEditor}
|
||||||
</header>
|
onContentError={() =>
|
||||||
<Editor
|
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
|
||||||
key={chapter.id}
|
}
|
||||||
bookId={book.id}
|
/>
|
||||||
chapterId={chapter.id}
|
</article>
|
||||||
content={chapter.content}
|
{!coverActive && <FloatingToolbar editor={editor} />}
|
||||||
onChange={(content) => setChapterContent(chapter.id, content)}
|
|
||||||
onReady={setEditor}
|
|
||||||
onContentError={() =>
|
|
||||||
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
<FloatingToolbar editor={editor} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
{dock && !isCompact && <ResizeHandle pane="dock" />}
|
{dock && !isCompact && <ResizeHandle pane="dock" />}
|
||||||
{dock && <Dock />}
|
{dock && <Dock />}
|
||||||
@@ -416,6 +489,12 @@ export function EditorView() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{escArmed && (
|
||||||
|
<div className="esc-hint">
|
||||||
|
Press <kbd>Esc</kbd> again for All projects
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
|
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
|
||||||
{exporting && (
|
{exporting && (
|
||||||
<div className="export-overlay">
|
<div className="export-overlay">
|
||||||
@@ -436,11 +515,15 @@ function ChapterTitleInput({
|
|||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
|
focusRequest,
|
||||||
|
onFocused,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
focusRequest: boolean;
|
||||||
|
onFocused: () => void;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLTextAreaElement>(null);
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -452,6 +535,15 @@ function ChapterTitleInput({
|
|||||||
el.style.height = `${el.scrollHeight}px`;
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
}, [value, disabled]);
|
}, [value, disabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusRequest || disabled) return;
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.select();
|
||||||
|
onFocused();
|
||||||
|
}, [focusRequest, disabled, onFocused]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useExportPreview } from "../store/useExportPreview";
|
|||||||
import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
|
import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
|
||||||
import { compilePdf } from "../ipc";
|
import { compilePdf } from "../ipc";
|
||||||
import { saveBytes } from "../project";
|
import { saveBytes } from "../project";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { useFocusTrap } from "../focus";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||||
@@ -81,13 +83,8 @@ function ExportPreviewModal() {
|
|||||||
};
|
};
|
||||||
}, [book]);
|
}, [book]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEscapeLayer(true, close);
|
||||||
const onKey = (e: KeyboardEvent) => {
|
useFocusTrap(panelRef);
|
||||||
if (e.key === "Escape") close();
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, [close]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = panelRef.current;
|
const el = panelRef.current;
|
||||||
@@ -173,7 +170,7 @@ function ExportPreviewModal() {
|
|||||||
<button className="icon-btn" disabled={!pdf || zoom <= ZOOM_MIN} onClick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out">
|
<button className="icon-btn" disabled={!pdf || zoom <= ZOOM_MIN} onClick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out">
|
||||||
<Icon d="M5 12h14" />
|
<Icon d="M5 12h14" />
|
||||||
</button>
|
</button>
|
||||||
{!compact && <span>{Math.round(zoom * 100)}%</span>}
|
{!compact && <span>{zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`}</span>}
|
||||||
<button className="icon-btn" disabled={!pdf || zoom >= ZOOM_MAX} onClick={() => adjustZoom(ZOOM_STEP)} title="Zoom in">
|
<button className="icon-btn" disabled={!pdf || zoom >= ZOOM_MAX} onClick={() => adjustZoom(ZOOM_STEP)} title="Zoom in">
|
||||||
<Icon d="M12 5v14M5 12h14" />
|
<Icon d="M12 5v14M5 12h14" />
|
||||||
</button>
|
</button>
|
||||||
@@ -199,7 +196,7 @@ function ExportPreviewModal() {
|
|||||||
<div className="preview-stage">
|
<div className="preview-stage">
|
||||||
<div className="preview-loading">
|
<div className="preview-loading">
|
||||||
<div className="spinner spinner-dark" />
|
<div className="spinner spinner-dark" />
|
||||||
<p>Typesetting your book…</p>
|
<p>Typesetting your project…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -212,7 +209,7 @@ function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; on
|
|||||||
const stageRef = useRef<HTMLDivElement>(null);
|
const stageRef = useRef<HTMLDivElement>(null);
|
||||||
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
|
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
|
||||||
const [baseRatio, setBaseRatio] = useState<number | null>(null);
|
const [baseRatio, setBaseRatio] = useState<number | null>(null);
|
||||||
const [stageWidth, setStageWidth] = useState(0);
|
const [stage, setStage] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -234,21 +231,21 @@ function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; on
|
|||||||
}, [data, onPages]);
|
}, [data, onPages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stage = stageRef.current;
|
const el = stageRef.current;
|
||||||
if (!stage) return;
|
if (!el) return;
|
||||||
const update = () => setStageWidth(stage.clientWidth);
|
const update = () => setStage({ width: el.clientWidth, height: el.clientHeight });
|
||||||
update();
|
update();
|
||||||
const observer = new ResizeObserver(update);
|
const observer = new ResizeObserver(update);
|
||||||
observer.observe(stage);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [doc]);
|
}, [doc]);
|
||||||
|
|
||||||
const fit = Math.max(280, stageWidth - 56);
|
const pageFit = Math.max(240, Math.min(stage.width - 56, (stage.height - 56) / (baseRatio ?? 1)));
|
||||||
const displayWidth = Math.round(fit * zoom);
|
const displayWidth = Math.round(pageFit * zoom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="preview-stage" ref={stageRef}>
|
<div className="preview-stage" ref={stageRef}>
|
||||||
{doc && baseRatio !== null && stageWidth > 0 && (
|
{doc && baseRatio !== null && stage.width > 0 && (
|
||||||
<div className="preview-col">
|
<div className="preview-col">
|
||||||
{Array.from({ length: doc.numPages }, (_, i) => (
|
{Array.from({ length: doc.numPages }, (_, i) => (
|
||||||
<PdfPage
|
<PdfPage
|
||||||
|
|||||||
+12
-10
@@ -3,6 +3,7 @@ import type { Editor as TiptapEditor } from "@tiptap/react";
|
|||||||
import { buildRegex, getSearchState } from "../editor/search";
|
import { buildRegex, getSearchState } from "../editor/search";
|
||||||
import { countMatches, replaceInContent } from "../search-book";
|
import { countMatches, replaceInContent } from "../search-book";
|
||||||
import { useBook } from "../store/useBook";
|
import { useBook } from "../store/useBook";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
interface FindBarProps {
|
interface FindBarProps {
|
||||||
@@ -58,7 +59,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
|||||||
return () => {
|
return () => {
|
||||||
editor.off("transaction", update);
|
editor.off("transaction", update);
|
||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor, activeChapterId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
@@ -67,7 +68,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
|||||||
} else {
|
} else {
|
||||||
editor.commands.clearSearch();
|
editor.commands.clearSearch();
|
||||||
}
|
}
|
||||||
}, [editor, open, query, caseSensitive, wholeWord]);
|
}, [editor, open, query, caseSensitive, wholeWord, activeChapterId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -95,6 +96,13 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
|||||||
};
|
};
|
||||||
}, [editor, pendingNav, activeChapterId]);
|
}, [editor, pendingNav, activeChapterId]);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
editor?.commands.focus();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEscapeLayer(open, close);
|
||||||
|
|
||||||
if (!open || !editor) return null;
|
if (!open || !editor) return null;
|
||||||
|
|
||||||
const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0);
|
const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0);
|
||||||
@@ -163,9 +171,6 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
|||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.shiftKey ? prev() : next();
|
e.shiftKey ? prev() : next();
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -239,7 +244,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
|||||||
<button className="find-toggle" data-on={wholeWord} title="Whole word" onClick={() => setWholeWord((v) => !v)}>
|
<button className="find-toggle" data-on={wholeWord} title="Whole word" onClick={() => setWholeWord((v) => !v)}>
|
||||||
<span className="find-ww">ab</span>
|
<span className="find-ww">ab</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="find-btn" title="Close (Esc)" onClick={onClose}>
|
<button className="find-btn" title="Close (Esc)" onClick={close}>
|
||||||
<Icon d="M18 6L6 18M6 6l12 12" size={14} />
|
<Icon d="M18 6L6 18M6 6l12 12" size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,9 +261,6 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
|||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
replaceOne();
|
replaceOne();
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -273,7 +275,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
|||||||
title="Replace All scope"
|
title="Replace All scope"
|
||||||
onClick={() => setScope((s) => (s === "chapter" ? "book" : "chapter"))}
|
onClick={() => setScope((s) => (s === "chapter" ? "book" : "chapter"))}
|
||||||
>
|
>
|
||||||
{scope === "chapter" ? "This chapter" : "Whole book"}
|
{scope === "chapter" ? "This chapter" : "Whole project"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library";
|
import {
|
||||||
|
type BookSummary,
|
||||||
|
createAndOpenBook,
|
||||||
|
deleteBook,
|
||||||
|
exampleBook,
|
||||||
|
lastBookId,
|
||||||
|
listBooks,
|
||||||
|
loadBook,
|
||||||
|
rememberLastBook,
|
||||||
|
saveBook,
|
||||||
|
} from "../library";
|
||||||
import type { Book } from "../model/book";
|
import type { Book } from "../model/book";
|
||||||
import { importEpub } from "../import/epub";
|
import { importEpub } from "../import/epub";
|
||||||
import { clearPositions } from "../editor/positions";
|
import { clearPositions } from "../editor/positions";
|
||||||
|
import { clearChapterStates } from "../editor/session";
|
||||||
import { isDesktop } from "../ipc";
|
import { isDesktop } from "../ipc";
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
@@ -10,10 +21,12 @@ import { RowMenu } from "./RowMenu";
|
|||||||
import { BackupButton } from "./BackupButton";
|
import { BackupButton } from "./BackupButton";
|
||||||
import { useBackup } from "../store/useBackup";
|
import { useBackup } from "../store/useBackup";
|
||||||
import { useBook } from "../store/useBook";
|
import { useBook } from "../store/useBook";
|
||||||
|
import { relativeTime } from "../time";
|
||||||
|
|
||||||
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||||
const [books, setBooks] = useState<BookSummary[]>([]);
|
const [books, setBooks] = useState<BookSummary[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
||||||
const notice = useBook((s) => s.notice);
|
const notice = useBook((s) => s.notice);
|
||||||
@@ -30,6 +43,10 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (restoreNonce) refresh();
|
if (restoreNonce) refresh();
|
||||||
}, [restoreNonce]);
|
}, [restoreNonce]);
|
||||||
@@ -51,10 +68,17 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
onOpen(copy);
|
onOpen(copy);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openById = (id: string) =>
|
||||||
|
loadBook(id)
|
||||||
|
.then(onOpen)
|
||||||
|
.catch((e) => setNotice(`Could not open project: ${e}`));
|
||||||
|
|
||||||
const removeBook = async () => {
|
const removeBook = async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
await deleteBook(pendingDelete.id);
|
await deleteBook(pendingDelete.id);
|
||||||
clearPositions(pendingDelete.id);
|
clearPositions(pendingDelete.id);
|
||||||
|
clearChapterStates(pendingDelete.id);
|
||||||
|
if (lastBookId() === pendingDelete.id) rememberLastBook(null);
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
@@ -85,7 +109,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
<div className="shelf">
|
<div className="shelf">
|
||||||
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
||||||
<Icon d="M12 5v14M5 12h14" size={20} />
|
<Icon d="M12 5v14M5 12h14" size={20} />
|
||||||
<span>New book</span>
|
<span>New project</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="card card-action" onClick={handleImport} disabled={busy}>
|
<button className="card card-action" onClick={handleImport} disabled={busy}>
|
||||||
<Icon d="M12 3v10m0 0l-4-4m4 4l4-4M5 19h14" size={20} />
|
<Icon d="M12 3v10m0 0l-4-4m4 4l4-4M5 19h14" size={20} />
|
||||||
@@ -102,17 +126,26 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
<div key={b.id} className="card card-book card-corrupt">
|
<div key={b.id} className="card card-book card-corrupt">
|
||||||
<span className="card-title">{b.title}</span>
|
<span className="card-title">{b.title}</span>
|
||||||
<span className="card-author">Couldn't be read; a .bak backup may sit beside it.</span>
|
<span className="card-author">Couldn't be read; a .bak backup may sit beside it.</span>
|
||||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
<RowMenu label="Project options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
key={b.id}
|
key={b.id}
|
||||||
className="card card-book"
|
className="card card-book"
|
||||||
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))}
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => openById(b.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
openById(b.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="card-title">{b.title || "Untitled"}</span>
|
<span className="card-title">{b.title || "Untitled"}</span>
|
||||||
{b.author && <span className="card-author">{b.author}</span>}
|
{b.author && <span className="card-author">{b.author}</span>}
|
||||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
{b.updatedAt > 0 && <span className="card-meta">Edited {relativeTime(b.updatedAt, now)}</span>}
|
||||||
|
<RowMenu label="Project options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -132,10 +165,10 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
)}
|
)}
|
||||||
{pendingDelete && (
|
{pendingDelete && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete book"
|
title="Delete project"
|
||||||
message={
|
message={
|
||||||
<>
|
<>
|
||||||
Delete <strong>{pendingDelete.title || "Untitled"}</strong>? This permanently removes the book and all its
|
Delete <strong>{pendingDelete.title || "Untitled"}</strong>? This permanently removes the project and all its
|
||||||
chapters.
|
chapters.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useRef, type ReactNode } from "react";
|
||||||
|
import { useFocusTrap } from "../focus";
|
||||||
|
|
||||||
|
interface MenuProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Menu({ open, onClose, children }: MenuProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useFocusTrap(ref, open);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
|
||||||
|
e.preventDefault();
|
||||||
|
const items = Array.from(ref.current?.querySelectorAll<HTMLElement>("button") ?? []);
|
||||||
|
if (!items.length) return;
|
||||||
|
const at = items.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const next =
|
||||||
|
e.key === "Home"
|
||||||
|
? 0
|
||||||
|
: e.key === "End"
|
||||||
|
? items.length - 1
|
||||||
|
: e.key === "ArrowDown"
|
||||||
|
? (at + 1) % items.length
|
||||||
|
: (at - 1 + items.length) % items.length;
|
||||||
|
items[next]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="menu-backdrop" onClick={onClose} />
|
||||||
|
<div ref={ref} className="menu" onKeyDown={onKeyDown}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Chapter } from "../model/book";
|
||||||
|
import { type BookSummary, listBooks, moveChapterToBook, saveBook } from "../library";
|
||||||
|
import { useBook } from "../store/useBook";
|
||||||
|
import { useEscapeLayer } from "../escape";
|
||||||
|
import { useFocusTrap } from "../focus";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
interface MoveChapterDialogProps {
|
||||||
|
chapter: Chapter;
|
||||||
|
label: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoveChapterDialog({ chapter, label, onClose }: MoveChapterDialogProps) {
|
||||||
|
const bookId = useBook((s) => s.book?.id ?? "");
|
||||||
|
const deleteChapter = useBook((s) => s.deleteChapter);
|
||||||
|
const setNotice = useBook((s) => s.setNotice);
|
||||||
|
const [books, setBooks] = useState<BookSummary[] | null>(null);
|
||||||
|
const [moving, setMoving] = useState(false);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEscapeLayer(!moving, onClose);
|
||||||
|
useFocusTrap(panelRef, books !== null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listBooks()
|
||||||
|
.then((list) => setBooks(list.filter((b) => b.id !== bookId && !b.corrupt)))
|
||||||
|
.catch(() => setBooks([]));
|
||||||
|
}, [bookId]);
|
||||||
|
|
||||||
|
const move = async (target: BookSummary) => {
|
||||||
|
setMoving(true);
|
||||||
|
try {
|
||||||
|
const source = useBook.getState().book;
|
||||||
|
if (source) await saveBook(source);
|
||||||
|
await moveChapterToBook(chapter, target.id);
|
||||||
|
deleteChapter(chapter.id);
|
||||||
|
const trimmed = useBook.getState().book;
|
||||||
|
if (trimmed) await saveBook(trimmed);
|
||||||
|
setNotice(`Moved to ${target.title || "Untitled"}`);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setNotice(`Could not move: ${e}`);
|
||||||
|
setMoving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay" onClick={moving ? undefined : onClose}>
|
||||||
|
<div ref={panelRef} className="panel panel-move" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="panel-head">
|
||||||
|
<h2>Move to project</h2>
|
||||||
|
<button className="icon-btn" onClick={onClose} title="Close">
|
||||||
|
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
|
<p className="confirm-text">
|
||||||
|
Move <strong>{label}</strong> out of this project and into:
|
||||||
|
</p>
|
||||||
|
{books === null ? (
|
||||||
|
<p className="move-empty">Loading projects…</p>
|
||||||
|
) : books.length === 0 ? (
|
||||||
|
<p className="move-empty">There is no other project to move this into yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="move-list">
|
||||||
|
{books.map((b) => (
|
||||||
|
<li key={b.id}>
|
||||||
|
<button className="move-target" disabled={moving} onClick={() => move(b)}>
|
||||||
|
<span className="move-title">{b.title || "Untitled"}</span>
|
||||||
|
{b.author && <span className="move-author">{b.author}</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="panel-foot">
|
||||||
|
<button className="btn-ghost" onClick={onClose} disabled={moving}>
|
||||||
|
{moving ? "Moving…" : "Cancel"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useFocusTrap } from "../focus";
|
||||||
import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing";
|
import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing";
|
||||||
|
|
||||||
function humanize(category: string): string {
|
function humanize(category: string): string {
|
||||||
@@ -20,6 +21,8 @@ const POP_WIDTH = 264;
|
|||||||
export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) {
|
export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) {
|
||||||
const popRef = useRef<HTMLDivElement>(null);
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useFocusTrap(popRef);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
if (popRef.current?.contains(e.target as Node)) return;
|
if (popRef.current?.contains(e.target as Node)) return;
|
||||||
|
|||||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user