Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 55 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,56 @@ on:
- "v*"
workflow_dispatch:
inputs:
version:
description: inklet Portal for macOS version
required: true
default: "0.2.0"
# No default: a stale one once rebuilt main under an old version's name.
# The version comes from the tag, and the build from the tag's commit.
release_tag:
description: Existing GitHub Release that receives the macOS assets
description: Tag of the existing GitHub Release to rebuild (e.g. v0.2.4-alpha.4)
required: true
default: "v0.2.0-alpha.1"

permissions:
contents: write

jobs:
# Settles what is being released before anything is created, deleted or
# built: the tag, its version, and the exact commit every later job uses.
resolve-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
version: ${{ steps.tag.outputs.version }}
sha: ${{ steps.tag.outputs.sha }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.ref }}

- name: Check the tag
id: tag
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "::error::$TAG is not a release tag (vMAJOR.MINOR.PATCH, optionally -alpha.N or -beta.N)"
exit 1
fi
# Stable releases reach every user, so they ship only what main has.
# Pre-releases may come from any branch; alphas ship from feature branches.
if [[ "$TAG" != *-* ]] && ! git merge-base --is-ancestor HEAD origin/main; then
echo "::error::$TAG is a stable tag, but $(git rev-parse --short HEAD) is not on main"
exit 1
fi
# A rebuild has nowhere to put its assets without the Release.
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

create-release:
needs: resolve-tag
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
Expand All @@ -44,25 +80,30 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

build-mac:
needs: create-release
needs: [resolve-tag, create-release]
if: >-
always() &&
needs.resolve-tag.result == 'success' &&
(github.event_name == 'workflow_dispatch' || needs.create-release.result == 'success')
runs-on: macos-26
steps:
# The commit resolve-tag checked, not whatever the branch or tag points
# at by now: the build must be exactly the release it is labelled as.
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve-tag.outputs.sha }}

- name: Resolve release metadata
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_RELEASE_TAG: ${{ inputs.release_tag }}
TAG: ${{ needs.resolve-tag.outputs.tag }}
VERSION: ${{ needs.resolve-tag.outputs.version }}
run: |
VERSION="$INPUT_VERSION"
RELEASE_TAG="$INPUT_RELEASE_TAG"
if [[ -z "$VERSION" ]]; then VERSION="${GITHUB_REF_NAME#v}"; fi
if [[ -z "$RELEASE_TAG" ]]; then RELEASE_TAG="$GITHUB_REF_NAME"; fi
echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV"
echo "RELEASE_TAG=$RELEASE_TAG" >> "$GITHUB_ENV"
echo "RELEASE_TAG=$TAG" >> "$GITHUB_ENV"

# Before the signing certificate is on the runner. Same step as ci.yml.
- name: Test native shared data and widget routing
run: xcrun swift test

- name: Import Developer ID certificate
env:
Expand Down
42 changes: 30 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Useful environment variables:
| `INKLET_TEAM_ID` | Required when the identity string does not end with the Team ID |
| `INKLET_APP_GROUP` | Overrides the App Group derived from the Team ID |
| `INKLET_RELEASE=1` | Compiles with `-DINKLET_RELEASE` |
| `INKLET_API_BASE_URL` | API host for the app and the widget, written to both Info.plists as `InkletAPIBaseURL`; must be `https://`. Unset, both use the default in `Sources/InkletPresentationKit/InkletServer.swift` (`https://dev.iminklet.com`) |
| `INKLET_OUTPUT_DIR` | Output directory, default `build/` |

Ad-hoc builds use per-app widget storage. Only signed builds share live data
Expand All @@ -83,14 +84,20 @@ renders every widget in light and dark appearance for visual review.

Pushing an annotated `v*` tag runs `.github/workflows/release.yml`:

1. create a draft GitHub Release from the tag message;
2. build the universal app, sign it and the widget with Developer ID;
3. create and sign `dist/inklet-portal-<version>-mac-universal.dmg`;
4. notarize with Apple, staple the ticket, upload the versioned disk image and
1. check the tag: `vMAJOR.MINOR.PATCH` with an optional pre-release suffix
(`-alpha.N`, `-beta.N`). A stable tag must point at a commit on `main`;
pre-release tags may come from any branch;
2. create a draft GitHub Release from the tag message;
3. check out the tagged commit, run the tests, build the universal app, sign it
and the widget with Developer ID;
4. create and sign `dist/inklet-portal-<version>-mac-universal.dmg`;
5. notarize with Apple, staple the ticket, upload the versioned disk image and
a stable `inklet-portal-macOS.dmg`;
5. publish the Release.
6. publish the Release.

`workflow_dispatch` can rebuild the macOS assets for an existing Release.
`workflow_dispatch` rebuilds the macOS assets for an existing Release from its
tag: it takes the tag, builds that tag's commit, and derives the version from
the tag name.

Required repository secrets:

Expand All @@ -113,9 +120,10 @@ a universal ad-hoc build on every pull request and push to `main`.
| Launch at login | `SMAppService.mainApp`; the toggle reads the system's status, and a "requires approval" state links to System Settings |
| Show in Dock | Switches the activation policy between regular and accessory. The menu bar item is always present, so the app stays reachable with the Dock icon off |
| Notifications | `UNUserNotificationCenter`: a card on its way, a failed send, a display that went offline. Permission is requested the first time an alert is turned on |
| Weather on Home | CoreLocation for an approximate position, conditions from Open-Meteo (no key, no WeatherKit entitlement) |
| Weather on Home | Off by default. CoreLocation for an approximate position, asked for when the setting is turned on; conditions from Open-Meteo (no key, no WeatherKit entitlement). The hardened runtime needs `com.apple.security.personal-information.location` in `Resources/InkletMac.entitlements` |
| Highlighted text | The composer's selected-text suggestion needs Accessibility access. Settings shows whether it is granted, asks once, then links to Privacy & Security → Accessibility |

All four need a packaged `.app`; under `swift run` they show as unavailable.
The first four need a packaged `.app`; under `swift run` they show as unavailable.

## Automatic updates

Expand Down Expand Up @@ -146,7 +154,8 @@ ID build.

## Backend dependencies

The app talks to `https://dev.iminklet.com` with an inklet user access token:
The app and the widget talk to `https://dev.iminklet.com` (`INKLET_API_BASE_URL`
overrides it at build time) with an inklet user access token:

- `/api/app/v1/contents`, `/api/app/v1/analyses`, `/api/app/v1/presentations`:
the Content → Analysis → Presentation pipeline. Every send from the composer
Expand All @@ -166,9 +175,18 @@ The app talks to `https://dev.iminklet.com` with an inklet user access token:
[docs/virtual-displays.md](docs/virtual-displays.md).

Identifiers that must stay stable across releases: bundle ID
`com.iminklet.mac`, widget bundle ID `com.iminklet.mac.widgets`, URL schemes
`inklet` and `inklet-mac`, and the App Group derived in
`Scripts/widget-configuration.sh`.
`com.iminklet.mac`, widget bundle ID `com.iminklet.mac.widgets`, the App Group
derived in `Scripts/widget-configuration.sh`, and two URL schemes:

- `inklet-mac`, the only scheme the app registers (`CFBundleURLTypes`). Widget
clicks open `inklet-mac://send`, `inklet-mac://activity` and
`inklet-mac://display/<id>`.
- `inklet`, which the app does not register. Google and Apple sign-in end at the
portal's desktop callback page, which redirects to
`inklet://auth/callback?…`; `ASWebAuthenticationSession` claims the scheme
for the length of that sign-in only. The widget link parsers also accept
`inklet:` (iOS's registered scheme, in code shared with it), but macOS only
delivers the scheme the app registers.

## Related repositories

Expand Down
2 changes: 2 additions & 0 deletions Resources/InkletMac.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.iminklet.portal</string>
Expand Down
10 changes: 8 additions & 2 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ SPARKLE_FEED_URL="${INKLET_SPARKLE_FEED_URL:-https://raw.githubusercontent.com/i
SPARKLE_PUBLIC_KEY="${INKLET_SPARKLE_PUBLIC_KEY:-V8ABE8pxqFcEA8x5OycPwX+42/hPESXcBaReS2HXEPA=}"
source "$SCRIPT_DIR/widget-configuration.sh"
inklet_configure_widget_group
inklet_configure_api_base

if [[ -z "${DEVELOPER_DIR:-}" && -d /Applications/Xcode.app/Contents/Developer ]]; then
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
Expand Down Expand Up @@ -93,6 +94,10 @@ if [[ -f "$ICON_SOURCE" ]]; then
rm -rf "$(dirname "$ICONSET")"
fi

# Automatic and sudden termination are both off. With "Show in Dock" off and
# no window open the app looks idle to macOS, but it is the global shortcut,
# the Services receiver, the runs being followed and their notifications; the
# system must not quit it to reclaim memory, or kill it without asking.
cat > "$APP/Contents/Info.plist" <<PLIST
<?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">
Expand All @@ -110,6 +115,7 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>NSHighResolutionCapable</key><true/>
<key>InkletAppGroupIdentifier</key><string>$INKLET_APP_GROUP</string>
<key>InkletWidgetStorageMode</key><string>$INKLET_WIDGET_STORAGE_MODE</string>
<key>InkletAPIBaseURL</key><string>$INKLET_API_BASE_URL</string>
<key>CFBundleURLTypes</key>
<array><dict>
<key>CFBundleURLName</key><string>com.iminklet.mac.widgets</string>
Expand All @@ -133,7 +139,6 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<string>public.utf8-plain-text</string>
<string>public.file-url</string>
<string>public.url</string>
<string>public.image</string>
</array>
</dict>
</array>
Expand All @@ -142,7 +147,8 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>SUEnableAutomaticChecks</key><true/>
<key>SUScheduledCheckInterval</key><integer>86400</integer>
<key>NSPrincipalClass</key><string>NSApplication</string>
<key>NSSupportsAutomaticTermination</key><true/>
<key>NSSupportsAutomaticTermination</key><false/>
<key>NSSupportsSuddenTermination</key><false/>
</dict>
</plist>
PLIST
Expand Down
2 changes: 2 additions & 0 deletions Scripts/build-widgets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ OUTPUT_DIR="${INKLET_OUTPUT_DIR:-$ROOT_DIR/build}"
DERIVED_DATA="${INKLET_WIDGET_DERIVED_DATA:-$OUTPUT_DIR/WidgetDerivedData}"
source "$SCRIPT_DIR/widget-configuration.sh"
inklet_configure_widget_group
inklet_configure_api_base

# Use the full SDK without changing the user's xcode-select preference.
if ! xcodebuild -version >/dev/null 2>&1; then
Expand All @@ -33,6 +34,7 @@ BUILD_ARGS=(-project "$ROOT_DIR/WidgetExtension/InkletWidgets.xcodeproj"
CODE_SIGNING_ALLOWED=NO ENABLE_DEBUG_DYLIB=NO
"INKLET_APP_GROUP=$INKLET_APP_GROUP"
"INKLET_WIDGET_STORAGE_MODE=$INKLET_WIDGET_STORAGE_MODE"
"INKLET_API_BASE_URL=$INKLET_API_BASE_URL"
"MARKETING_VERSION=${INKLET_VERSION:-0.2.0}"
"CURRENT_PROJECT_VERSION=${INKLET_BUILD_NUMBER:-1}")
if [[ -n "${INKLET_ARCHS:-}" ]]; then
Expand Down
14 changes: 14 additions & 0 deletions Scripts/widget-configuration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,17 @@ inklet_configure_widget_group() {
return 1
fi
}

# The API host for both bundles, so the widget cannot read frames from a
# different server than the app publishes them to. Empty means the default in
# Sources/InkletPresentationKit/InkletServer.swift, the only place it is written.
inklet_configure_api_base() {
export INKLET_API_BASE_URL="${INKLET_API_BASE_URL:-}"
INKLET_API_BASE_URL="${INKLET_API_BASE_URL%/}"
# The app would quietly fall back to the default on anything else; a build
# that asked for another host should fail instead.
if [[ -n "$INKLET_API_BASE_URL" && ! "$INKLET_API_BASE_URL" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$ ]]; then
echo "INKLET_API_BASE_URL must be an https:// URL with no query or credentials, got: $INKLET_API_BASE_URL" >&2
return 1
fi
}
29 changes: 15 additions & 14 deletions Sources/InkletMac/InkletMacApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ struct InkletMacApp: App {
// menus but also greys out the window's traffic lights, so it's not worth it.
// The clean fix is an `AccentColor` asset (app-scoped, leaves traffic lights
// alone) — that needs Xcode's actool, so it lands when we move to an Xcode project.
init() { BrandFonts.register() }
init() {
BrandFonts.register()
// Photos exports from a previous run belong to nobody now.
Task.detached(priority: .utility) { AppContext.discardStaleExports() }
}

static let mainWindowID = "main"

Expand Down Expand Up @@ -84,15 +88,15 @@ struct InkletMacApp: App {
private struct AppGate: View {
@Environment(Session.self) private var session
@Environment(AppModel.self) private var model

/// AppKit keeps `servicesProvider` weakly, so this holds the only reference.
@State private var services: ServicesProvider?
@Environment(WidgetRouter.self) private var widgetRouter

var body: some View {
Group {
switch session.state {
case .restoring:
SplashView()
case .unreachable:
UnreachableView()
case .signedOut:
LoginView()
case .signedIn:
Expand All @@ -101,7 +105,11 @@ private struct AppGate: View {
}
.task {
DockVisibility.install()
await session.restore()
ServicesProvider.install(model: model, session: session) { widgetRouter.openMainWindow?() }
// Only a launch checks the stored session. This view goes away with
// its window and comes back with the next one, and a session that
// is already settled must not be put through the check again.
if session.state == .restoring { await session.restore() }
}
.onChange(of: session.state) { _, state in
switch state {
Expand All @@ -110,12 +118,12 @@ private struct AppGate: View {
model.account = Account(dto: user)
Task { await model.load() }
installHotKey()
installServices()
ServicesProvider.installed?.deliverPending()
case .signedOut:
model.reset()
ComposerPanelController.shared.hide()
ShortcutStore.shared.deactivate()
case .restoring:
case .restoring, .unreachable:
break
}
}
Expand All @@ -126,11 +134,4 @@ private struct AppGate: View {
private func installHotKey() {
ShortcutStore.shared.activate { model.toggleComposer() }
}

private func installServices() {
guard services == nil else { return }
let provider = ServicesProvider(model: model)
provider.install()
services = provider
}
}
16 changes: 15 additions & 1 deletion Sources/InkletMac/Models/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class AppModel {
virtualDisplays = VirtualDisplayController { path, method, body, headers in
try await InkletAPI.shared.virtualDisplayRequest(path, method: method, body: body, headers: headers)
}
history.onSessionExpired = { [weak self] in await self?.session?.invalidate() }
}
var account = Account(username: "", email: "", plan: "free")
var devices: [Device] = []
Expand Down Expand Up @@ -93,6 +94,7 @@ final class AppModel {
WidgetCenter.shared.reloadAllTimelines()
account = Account(username: "", email: "", plan: "free")
suggestion = nil
AppContext.discardExports()
composerTarget = nil
composerVirtualTargetID = nil
devices = []
Expand Down Expand Up @@ -278,7 +280,16 @@ final class AppModel {
/// name, and a name that fails to stick is a rename away — the bind is the
/// part that must not be lost. An empty name leaves the serial number.
func bindQuote0(apiKey: String, serial: String, nickname: String = "") async throws -> Device {
let device = Device(dto: try await InkletAPI.shared.bindQuote0(apiKey: apiKey, serial: serial))
let dto: DeviceDTO
do {
dto = try await InkletAPI.shared.bindQuote0(apiKey: apiKey, serial: serial)
} catch {
// A dead session ends here the way it does everywhere else; the
// form is left with only the refusals that are about the panel.
if APIError.endsSession(error) { await session?.invalidate() }
throw error
}
let device = Device(dto: dto)
if let index = devices.firstIndex(where: { $0.id == device.id }) {
devices[index] = device
} else {
Expand Down Expand Up @@ -549,6 +560,9 @@ extension AppModel {
/// focus — then goes to fetch its contents in the background.
private func captureContext() {
suggestion = nil
// The last summon's suggestion is gone, and so are the Photos exports
// behind it; anything already attached was read into memory.
AppContext.discardExports()
guard let source = AppContext.frontmost(), source.bundleID != Bundle.main.bundleIdentifier
else { return }

Expand Down
Loading
Loading