Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d748647e9b
|
||
|
|
dc40fa43bc | ||
|
|
2ae7ded357 | ||
|
|
dd15f22031 | ||
|
|
4ecfc4606f | ||
|
|
45457cb313 | ||
|
|
62e0226f01 | ||
|
|
92254c021f | ||
|
|
0aca4d9400 | ||
|
|
2f9fac17b7 | ||
|
|
4a007e88c9 | ||
|
|
885c52567d | ||
|
|
6070f0cbb9 | ||
|
|
d1e1b29623 | ||
|
|
2588101096 | ||
|
|
6313c295f3 | ||
|
|
d3e7529d91 | ||
|
|
287c4a07c4 | ||
|
|
eb95ad2d52 | ||
|
|
b9e871f176 | ||
|
|
39f10cfba3 | ||
|
|
5b590bd6e5 | ||
|
|
0b6d389842 | ||
|
|
adcd7c5f93 | ||
|
|
b2cad34a6a | ||
|
|
76ef4e49a5 | ||
|
|
acef128bbd | ||
|
|
7f2a483ad2 | ||
|
|
0b021beda7 | ||
|
|
4c169ef3d7 | ||
|
|
c7d2d9681e | ||
|
|
edf499d19e | ||
|
|
98a023aa03 | ||
|
|
f5f9034d41 | ||
|
|
e2429096d7 | ||
|
|
4d82a684f3 | ||
|
|
6795ff43fb | ||
|
|
1a914c88f5 | ||
|
|
5c57cc738b | ||
|
|
3dde8a91b6 | ||
|
|
4506c53958 | ||
|
|
f3d7685984 | ||
|
|
e243fb4bff |
No files matched your search
@@ -0,0 +1 @@
|
||||
v11.3.0
|
||||
+9
-4
@@ -1,4 +1,9 @@
|
||||
github: lwouis
|
||||
patreon: alt_tab_macos
|
||||
ko_fi: alt_tab
|
||||
custom: ["paypal.com/donate/?hosted_button_id=ACYAC3UYCM7CN"]
|
||||
# alt-tab-free [depaywall]: upstream's FUNDING.yml routed the GitHub "Sponsor" button on the fork's
|
||||
# repo to lwouis (github: lwouis / patreon: alt_tab_macos / ko_fi: alt_tab / a lwouis PayPal button) —
|
||||
# i.e. every donation would have gone to the author the fork just de-paywalled (PLAN §3.5). It has
|
||||
# been emptied so NO sponsor button renders and no donation routes to the upstream author.
|
||||
#
|
||||
# OWNER-OVERRIDABLE-BEFORE-PUBLISH: to accept sponsorships for THIS fork, uncomment and fill in your
|
||||
# own handles (do NOT re-add lwouis's). Otherwise leave this file empty / delete it.
|
||||
# github: <fork-owner-handle>
|
||||
# custom: ["https://<fork-domain>/donate"]
|
||||
+165
-8
@@ -1,7 +1,27 @@
|
||||
# alt-tab-free: fork-owned CI/CD workflow (diverged from upstream lwouis/alt-tab-macos).
|
||||
# Upstream's ci_cd.yml fires `on: push: branches: [master]` ONLY and is the sole workflow,
|
||||
# so this fork ADDS a `pull_request:` trigger to gate the sync PR pre-merge (see PLAN §4.2),
|
||||
# gates every release/push-only step under `if: github.event_name == 'push'`, and wires the
|
||||
# anti-relock + conflict-marker greps as the FIRST push-job steps so a relocked or conflicted
|
||||
# tree aborts BEFORE update_appcast.sh (Sparkle sign), semantic-release (tag push), and the
|
||||
# gh-release. This file is in the §6.2 "keep ours" recurring-conflict set — on an upstream
|
||||
# ci_cd.yml change, reconcile manually and re-confirm the `pull_request:` trigger survives.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# alt-tab-free [depaywall]: PR trigger so the sync PR runs the guards pre-merge (PLAN §4.2).
|
||||
# A separate fork-owned .github/workflows/guard.yml ALSO runs the guards on pull_request, so a
|
||||
# careless upstream-conflict resolution that strips this trigger cannot silently disarm the gate.
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
# alt-tab-free [depaywall]: GitHub defaults GITHUB_TOKEN to read-only since 2023; semantic-release
|
||||
# (@semantic-release/git) pushes commits+tags to master and softprops/action-gh-release creates the
|
||||
# release — both need contents:write, else they 403 and the appcast enclosure 404s for auto-update
|
||||
# (PLAN §4.3).
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPCENTER_SECRET: ${{ secrets.APPCENTER_SECRET }}
|
||||
@@ -10,13 +30,18 @@ env:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_P12_CERTIFICATE: ${{ secrets.APPLE_P12_CERTIFICATE }}
|
||||
# alt-tab-free [depaywall]: optional .p12 export password (empty if exported password-less).
|
||||
# Not part of the release-gate AND — the cert import tolerates an empty password.
|
||||
APPLE_P12_PASSWORD: ${{ secrets.APPLE_P12_PASSWORD }}
|
||||
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
|
||||
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
|
||||
GITHUB_EVENT_AFTER: ${{ github.event.after }}
|
||||
BUILD_DIR: ${{ github.workspace }}
|
||||
XCODE_BUILD_PATH: DerivedData/Build/Products/Release
|
||||
VERSION_FILE: VERSION.txt
|
||||
APP_NAME: AltTab
|
||||
# alt-tab-free [depaywall]: must equal PRODUCT_NAME in config/local.xcconfig
|
||||
# (PLAN §3.1/§4.3) or the build/package/appcast scripts mismatch the artifact.
|
||||
APP_NAME: CommandTabFree
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-15 # all options: https://github.com/actions/runner-images/tree/main
|
||||
@@ -38,32 +63,164 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: ${{ steps.pre-checkout.outputs.fetch-depth }}
|
||||
# alt-tab-free [depaywall]: Guard B + Guard C run FIRST on the push job (before run_tests.sh,
|
||||
# and therefore before update_appcast.sh's Sparkle sign + semantic-release's tag push) so a
|
||||
# relock or a still-conflicted tree aborts the job BEFORE any irreversible side-effect (PLAN
|
||||
# §4.4 "Where the guards run"). They are near-instant target-independent greps, so running them
|
||||
# first is free. They also run on the PR path for the fastest red signal. A second copy sits
|
||||
# just before action-gh-release as belt-and-suspenders.
|
||||
- name: anti-relock — verify de-paywall marker present (Guard B)
|
||||
run: |
|
||||
grep -q 'depaywall' src/pro/license/LicenseManager.swift \
|
||||
|| { echo "PAYWALL RELOCKED: depaywall marker missing from computeState()"; exit 1; }
|
||||
# The marker proves the patch is present; pair with the if-false wrap the patch introduces.
|
||||
# A bare `return .pro` grep is NOT load-bearing (it matches the unpatched line 187 too).
|
||||
grep -Eq 'guard[[:space:]]+false[[:space:]]+else' src/pro/license/LicenseManager.swift \
|
||||
|| { echo "PAYWALL RELOCKED: 'guard false else' (forced .pro) missing from computeState()"; exit 1; }
|
||||
- name: anti-conflict — no unresolved merge markers (Guard C)
|
||||
run: |
|
||||
if git -c grep.lineNumber=false grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude)docs/'; then
|
||||
echo "UNRESOLVED CONFLICT MARKERS present — resolve before merge/release"; exit 1
|
||||
fi
|
||||
# alt-tab-free [depaywall]: release-readiness gate (PLAN §3.3/§4.3 owner-secret checklist).
|
||||
# The fork ships unsigned/ad-hoc until the OWNER provisions the Apple Developer ID cert and the
|
||||
# Sparkle key. We do NOT hard-fail when those secrets are absent — that would make every push
|
||||
# RED on a fork that is correctly built but not yet signing-provisioned. Instead we compute a
|
||||
# boolean from secret presence and SKIP the sign/notarize/appcast/release tail cleanly when it
|
||||
# is false. The build + Guard A (run_tests.sh) still run on push, so push CI is GREEN today and
|
||||
# the release tail FLIPS ON automatically the moment the owner adds the secrets. Secrets are not
|
||||
# available in step-level `if:` directly, so map them to env and emit an output here.
|
||||
- name: release-gate — are signing/notarization secrets present?
|
||||
id: release-gate
|
||||
env:
|
||||
HAS_P12: ${{ secrets.APPLE_P12_CERTIFICATE != '' }}
|
||||
HAS_APPLE_ID: ${{ secrets.APPLE_ID != '' }}
|
||||
HAS_APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD != '' }}
|
||||
HAS_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID != '' }}
|
||||
HAS_SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$HAS_P12" = "true" ] && [ "$HAS_APPLE_ID" = "true" ] \
|
||||
&& [ "$HAS_APPLE_PASSWORD" = "true" ] && [ "$HAS_APPLE_TEAM_ID" = "true" ] \
|
||||
&& [ "$HAS_SPARKLE_KEY" = "true" ]; then
|
||||
echo "signing-ready=true" >> "$GITHUB_OUTPUT"
|
||||
echo "All signing/notarization/Sparkle secrets present — release tail WILL run on push."
|
||||
else
|
||||
echo "signing-ready=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::Signing/notarization/Sparkle secrets absent — release tail (codesign/notarize/appcast/semantic-release/gh-release) is SKIPPED. Build + Guard A still run. Provision APPLE_P12_CERTIFICATE / APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID / SPARKLE_ED_PRIVATE_KEY in the 'production' Environment to enable releases (PLAN §3.3/§3.4)."
|
||||
fi
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 16 # PLAN §4.3: keep node-16; the package.json engines mismatch only warns
|
||||
cache: 'npm'
|
||||
- run: sudo xcode-select -s /Applications/Xcode_26.0.1.app/Contents/Developer
|
||||
- run: scripts/print_env.sh
|
||||
- run: npm ci
|
||||
# alt-tab-free [depaywall]: commitlint is gated to the push path. On push it lints the whole
|
||||
# pushed range, which drags in upstream's non-conforming messages on a `chore(sync)` merge —
|
||||
# this is handled by an `ignores` predicate in commitlint.config.js (PLAN §4.3) that drops the
|
||||
# synthetic merge commit and upstream's own messages; do NOT break commitlint on --no-ff merges.
|
||||
# $GITHUB_EVENT_BEFORE/AFTER are push-context (empty on pull_request), so a PR-safe variant runs
|
||||
# below on the PR path instead.
|
||||
- run: npx commitlint --from "$GITHUB_EVENT_BEFORE" --to "$GITHUB_EVENT_AFTER" --verbose
|
||||
if: github.event_name == 'push'
|
||||
# alt-tab-free [depaywall]: PR-safe commitlint — lints base..head (valid on pull_request),
|
||||
# paired with the commitlint.config.js `ignores` predicate (PLAN §4.2/§4.3).
|
||||
- run: npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" --verbose
|
||||
if: github.event_name == 'pull_request'
|
||||
# Runs on both push and PR: catches an un-regenerated l10n/generated-file diff (PLAN §4.2/§4.5).
|
||||
- run: scripts/ensure_generated_files_are_up_to_date.sh
|
||||
# alt-tab-free [depaywall]: release-path — a semantic-release dry-run against a PR head is
|
||||
# meaningless and can mis-bump, so gate to push (PLAN §4.2).
|
||||
- run: scripts/determine_next_version.sh
|
||||
if: github.event_name == 'push'
|
||||
# alt-tab-free [depaywall]: decouple the release apparatus from "every signing-ready push".
|
||||
# determine_next_version.sh writes an EMPTY VERSION.txt when no commit since the last tag is
|
||||
# release-worthy (semantic-release dry-run finds no bump). Gate the sign/notarize/release steps
|
||||
# on this so a docs/ci/chore push still runs guards + commitlint + run_tests (Release-config
|
||||
# compile check) but does NOT sign-build, notarize, or cut a release — no [skip ci] needed (§4.3).
|
||||
- name: release-decision — did semantic-release compute a version?
|
||||
id: release-decision
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
if [ -n "$(tr -d '[:space:]' < "$VERSION_FILE" 2>/dev/null)" ]; then
|
||||
echo "will-release=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Release-worthy: semantic-release will cut v$(cat "$VERSION_FILE")."
|
||||
else
|
||||
echo "will-release=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::No release-worthy commit — build/test only; notarize+release tail skipped."
|
||||
fi
|
||||
# Inject the release version only when releasing; an empty CURRENT_PROJECT_VERSION would crash
|
||||
# App.version (see config/local.xcconfig). Non-release builds use the committed 0.0.0 default.
|
||||
- run: scripts/replace_environment_variables_in_app.sh
|
||||
if: github.event_name == 'push' && steps.release-decision.outputs.will-release == 'true'
|
||||
# codesign needs APPLE_P12_CERTIFICATE — gated on signing-ready so a not-yet-provisioned fork
|
||||
# does not RED on the missing-cert import (PLAN §3.3/§4.3).
|
||||
- run: scripts/codesign/setup_ci_master.sh
|
||||
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
|
||||
# Guard A — the anti-relock unit test (appended to LicenseManagerTests.swift). Runs on both
|
||||
# push and PR (no secrets needed); on the push path it sits before the sign/tag steps below
|
||||
# so a relock fails BEFORE any irreversible side-effect (PLAN §4.4). This is what keeps push
|
||||
# CI GREEN today on the signing-unprovisioned fork.
|
||||
- run: scripts/run_tests.sh
|
||||
- run: scripts/build_app.sh
|
||||
# alt-tab-free [depaywall]: build the Release product to prove the tree compiles. The fork
|
||||
# inherits upstream's Developer ID via config/release.xcconfig (CODE_SIGN_IDENTITY), which is
|
||||
# absent on CI until the owner provisions the Apple cert — so when the release gate is NOT
|
||||
# signing-ready we build AD-HOC (CODE_SIGN_IDENTITY=-, signing not required, no timestamp) to
|
||||
# prove the tree compiles without a cert. When the owner adds the secrets AND sets the fork's
|
||||
# Developer ID in config/local.xcconfig, this builds signed for the release tail (PLAN §3.3/§4.3).
|
||||
- name: build app (signed — owner cert provisioned)
|
||||
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
|
||||
run: scripts/build_app.sh
|
||||
- name: build app (ad-hoc — until signing-provisioned)
|
||||
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready != 'true'
|
||||
run: scripts/build_app.sh CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO OTHER_CODE_SIGN_FLAGS=--timestamp=none
|
||||
# --- Release tail: requires the owner's Apple Developer ID + Sparkle secrets. Skipped cleanly
|
||||
# (NOT failed) when they are absent, so push CI stays GREEN until the owner provisions them. ---
|
||||
- run: scripts/package_and_notarize_release.sh
|
||||
- run: scripts/upload_symbols_to_appcenter.sh
|
||||
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
|
||||
# alt-tab-free [depaywall]: upload_symbols_to_appcenter.sh REMOVED — telemetry is off (PLAN §3.4).
|
||||
- run: scripts/update_appcast.sh
|
||||
- run: scripts/update_readme_and_website.sh
|
||||
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
|
||||
# alt-tab-free [depaywall]: update_readme_and_website.sh REMOVED — it queried the UPSTREAM
|
||||
# lwouis/alt-tab-macos GitHub API for stars/downloads and rewrote docs/contributors.md from the
|
||||
# UPSTREAM contributor graph (semantic mismatch for the fork) and dispatched the lwouis website.
|
||||
# Cosmetic only; dropped per PLAN §4.0/§4.3 (alongside update_website.sh, already removed).
|
||||
- run: npx semantic-release
|
||||
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
|
||||
- id: set_release_info
|
||||
run: scripts/extract_latest_changelog.sh
|
||||
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
|
||||
# alt-tab-free [depaywall]: belt-and-suspenders copy of Guard B + Guard C immediately before the
|
||||
# release is created. The load-bearing copy is at the top of the job (pre-tag-push); this second
|
||||
# copy guards the gh-release object itself (PLAN §4.4).
|
||||
- name: anti-relock + anti-conflict re-check (belt-and-suspenders)
|
||||
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
|
||||
run: |
|
||||
grep -q 'depaywall' src/pro/license/LicenseManager.swift \
|
||||
|| { echo "PAYWALL RELOCKED: depaywall marker missing from computeState()"; exit 1; }
|
||||
grep -Eq 'guard[[:space:]]+false[[:space:]]+else' src/pro/license/LicenseManager.swift \
|
||||
|| { echo "PAYWALL RELOCKED: 'guard false else' (forced .pro) missing from computeState()"; exit 1; }
|
||||
if git -c grep.lineNumber=false grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude)docs/'; then
|
||||
echo "UNRESOLVED CONFLICT MARKERS present — resolve before release"; exit 1
|
||||
fi
|
||||
# alt-tab-free [depaywall]: point the release at THIS fork's repo via the ambient GITHUB_TOKEN
|
||||
# (release.config.js has no @semantic-release/github plugin, so softprops is the sole release
|
||||
# creator — PLAN §4.3). repository defaults to ${{ github.repository }} = the fork.
|
||||
- uses: softprops/action-gh-release@v3
|
||||
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag_name: ${{ steps.set_release_info.outputs.tag_name }}
|
||||
body: ${{ steps.set_release_info.outputs.body }}
|
||||
files: ${{ env.XCODE_BUILD_PATH }}/*.zip
|
||||
- run: scripts/update_website.sh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WEBSITE_DISPATCH_TOKEN }}
|
||||
# alt-tab-free [depaywall]: the release tail intentionally does NOT advance .fork-sync-state.
|
||||
# That file is the UPSTREAM-sync cursor — the last-synced UPSTREAM tag (e.g. v11.3.0), read by
|
||||
# upstream_sync.yml to pick "the oldest upstream tag strictly newer than it." The fork versions
|
||||
# independently (semantic-release cuts v100.x), so a prior revision's step that wrote the FORK
|
||||
# release tag here set the cursor to v100.x — after which no upstream tag is ever "newer" and
|
||||
# upstream-sync would silently never fire. The cursor must instead be advanced by the
|
||||
# upstream-sync merge flow, recording the UPSTREAM tag it synced (to be wired with
|
||||
# upstream_sync.yml — PLAN §4.2). Until then .fork-sync-state stays at the last real upstream tag.
|
||||
# alt-tab-free [depaywall]: update_website.sh (lwouis website dispatch) REMOVED (PLAN §3.4/§4.2).
|
||||
@@ -0,0 +1,94 @@
|
||||
# alt-tab-free — anti-relock guard workflow (docs/PLAN-maintained-fork.md §4.4)
|
||||
#
|
||||
# A SEPARATE, fork-owned workflow that runs the anti-relock guards on every PR,
|
||||
# INDEPENDENTLY of ci_cd.yml. Rationale (§4.4): ci_cd.yml is the file most likely to take an
|
||||
# upstream conflict, and a careless "keep ours/theirs" resolution could silently strip its
|
||||
# pull_request: trigger or its release-step gating — at which point the in-ci_cd.yml guards
|
||||
# would stop gating PRs with NO error. A guard workflow that does NOT inherit ci_cd.yml's
|
||||
# trigger survives that. This is the reviewer's primary green/red signal on a sync PR.
|
||||
#
|
||||
# Three guards, each fails the job LOUDLY:
|
||||
# A — the Test scheme (the mock-injected anti-relock unit tests in LicenseManagerTests.swift)
|
||||
# B — a target-independent grep that the de-paywall flip is still present in computeState()
|
||||
# C — a tree-wide grep that no unresolved git conflict markers survive into a merge/release
|
||||
#
|
||||
# Defense-in-depth note: Guards B + C are ALSO wired into ci_cd.yml as the FIRST push-job
|
||||
# steps (CI agent owns that) so a relock/conflict-laden tree aborts the release BEFORE the tag
|
||||
# is pushed or the binary signed, even if this PR gate is ever missing/misconfigured (§4.4).
|
||||
name: guard
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Guards B + C are near-instant greps; run them on cheap ubuntu so a relock or a still-
|
||||
# conflicted tree fails FAST, before the expensive macOS Guard-A job even starts (§4.5).
|
||||
grep-guards:
|
||||
name: anti-relock greps (B + C)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# GUARD B — verify the de-paywall flip is still present at the single state producer.
|
||||
# §4.4 pitfall: a bare `return .pro` grep MATCHES THE UNPATCHED FILE (computeState() already
|
||||
# contains `return .pro` in the keychain-valid branch on a fully relocked tree), so it is NOT
|
||||
# load-bearing. The load-bearing signals are (1) the `depaywall` marker comment, which exists
|
||||
# ONLY because the fork added it, AND (2) the `if false { … }` wrap that the patch introduces
|
||||
# around the original (now dead) body. Both are absent on an unpatched/relocked upstream file.
|
||||
- name: Guard B — de-paywall marker + if-false wrap present in computeState()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
F=src/pro/license/LicenseManager.swift
|
||||
if [ ! -f "$F" ]; then
|
||||
echo "::error::Guard B: $F not found — chokepoint moved? Refresh the de-paywall patch (§6.2)."
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'depaywall' "$F" || {
|
||||
echo "::error::PAYWALL RELOCKED: 'depaywall' marker missing from $F — the de-paywall flip was lost in a merge. Refresh the patch (§6.2)."
|
||||
exit 1
|
||||
}
|
||||
grep -Eq 'guard[[:space:]]+false[[:space:]]+else' "$F" || {
|
||||
echo "::error::PAYWALL RELOCKED: 'if false { … }' wrap missing from $F — the de-paywall body-suppress was lost. Refresh the patch (§6.2)."
|
||||
exit 1
|
||||
}
|
||||
echo "Guard B OK: de-paywall marker + if-false wrap present in $F."
|
||||
|
||||
# GUARD C — no unresolved git conflict markers anywhere in the tree. The §4.2 sync cron
|
||||
# deliberately commits a conflicted tree (markers + --no-verify) for a human to resolve;
|
||||
# nothing else guarantees the human removed EVERY marker before merge. A markers-laden
|
||||
# commit merged to master becomes a permanent ancestor of the published tag (un-compilable
|
||||
# source), contradicting §5(1). Exclude docs/ so this plan's own literal marker examples
|
||||
# don't trip the grep.
|
||||
- name: Guard C — no unresolved merge conflict markers
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git -c grep.lineNumber=false grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude)docs/'; then
|
||||
echo "::error::UNRESOLVED CONFLICT MARKERS present — resolve before merge/release (§4.4 Guard C)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Guard C OK: no unresolved conflict markers."
|
||||
|
||||
# GUARD A — the behavioral anti-relock unit tests (mock-injected LicenseManager) via the Test
|
||||
# scheme. §4.4: this MUST run on macos-15 with the pinned Xcode — run_tests.sh is `xcodebuild
|
||||
# test`, which cannot run on ubuntu. If left on ubuntu, Guard A silently degrades to greps-only
|
||||
# and the behavioral relock test never executes (the exact silent-disarming class this plan
|
||||
# guards against). Bootstrap assertion (§4.4): on the deliberately-relocked acceptance PR,
|
||||
# confirm THIS job produced a real test result that is RED on the relock — not just the greps.
|
||||
unit-tests:
|
||||
name: anti-relock unit tests (A)
|
||||
runs-on: macos-15 # MUST match ci_cd.yml — `xcodebuild test` needs the pinned Xcode
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
# Pin the same Xcode as ci_cd.yml (§4.5 bootstrap invariant — re-pin on every upstream bump).
|
||||
- run: sudo xcode-select -s /Applications/Xcode_26.0.1.app/Contents/Developer
|
||||
# run_tests.sh runs the `Test` scheme (-configuration Release), which includes the
|
||||
# appended anti-relock tests in src/pro/license/LicenseManagerTests.swift:
|
||||
# testDepaywallProNeverLocked — load-bearing `state == .pro` assert
|
||||
# testDepaywallStillProAfterTrialExpiry — discriminating (expired-trial) assert
|
||||
# A relock makes computeState() fall back to computeTrialState() and these go RED.
|
||||
- name: Guard A — run the Test scheme (anti-relock unit tests)
|
||||
run: scripts/run_tests.sh
|
||||
@@ -0,0 +1,226 @@
|
||||
# alt-tab-free — upstream sync workflow (docs/PLAN-maintained-fork.md §4.2)
|
||||
#
|
||||
# Polls lwouis/alt-tab-macos for new release tags and opens a reviewable sync PR.
|
||||
# Selects the OLDEST upstream tag strictly newer than the last-merged tag recorded in
|
||||
# `.fork-sync-state` (NOT "newest tag minus --is-ancestor"). This is the §4.2
|
||||
# never-drop-commits invariant: `.fork-sync-state` advances ONLY when a sync PR merges to
|
||||
# master (a ci_cd.yml push-job step — owned by the CI agent), so a sync PR that is
|
||||
# closed-without-merge or abandoned can never silently skip its tag — that tag stays "next"
|
||||
# until it is actually merged. This subsumes the old `merge-base --is-ancestor` guard.
|
||||
#
|
||||
# A release is cut only when a human merges the PR to master (ci_cd.yml on: push). This
|
||||
# workflow never releases; it only opens the PR for review.
|
||||
name: upstream-sync
|
||||
|
||||
on:
|
||||
# §4.2 cron-durability note: the in-repo `schedule:` SILENTLY auto-disables after 60 days
|
||||
# of repo inactivity (no error, no notification). It is kept here as belt-and-suspenders.
|
||||
# The RECOMMENDED PRIMARY trigger is an EXTERNAL scheduler hitting `repository_dispatch`
|
||||
# carrying the SYNC_BOT_TOKEN (immune to the 60-day rule, and composes with the
|
||||
# workflow-re-trigger token). See ownerActions / §4.2.
|
||||
schedule:
|
||||
- cron: '0 7 * * *' # daily; cheap, idempotent
|
||||
workflow_dispatch: {}
|
||||
repository_dispatch:
|
||||
types: [upstream-sync] # external-scheduler entry point (§4.2 primary durability path)
|
||||
|
||||
# GITHUB_TOKEN is read-only by default; opening a PR + pushing a branch need these.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest # pure git, no Xcode
|
||||
env:
|
||||
# CRITICAL (§4.2 BLOCKING) — do NOT use ${{ github.token }} here. By GitHub's documented
|
||||
# recursion-prevention rule, a branch push / PR authored with the default GITHUB_TOKEN
|
||||
# does NOT trigger other workflows — so guard.yml and ci_cd.yml's pull_request: checks
|
||||
# would NEVER fire on the sync PR and the entire pre-merge gate would silently never run.
|
||||
# SYNC_BOT_TOKEN MUST be a fork-owned credential that re-triggers workflows: a GitHub App
|
||||
# installation token (preferred — least-privilege, short-lived) or a fork-owned PAT.
|
||||
GH_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Persist the fork-owned credential (NOT the default GITHUB_TOKEN) as the push
|
||||
# credential, so the branch push below re-triggers guard.yml / ci_cd.yml PR checks.
|
||||
token: ${{ secrets.SYNC_BOT_TOKEN }}
|
||||
|
||||
- name: Fail closed if SYNC_BOT_TOKEN is missing
|
||||
run: |
|
||||
# §4.2: without the workflow-re-triggering token the pre-merge gate is silently
|
||||
# disarmed. Fail loudly here rather than open a check-less PR.
|
||||
if [ -z "${SYNC_BOT_TOKEN:-}" ]; then
|
||||
echo "::error::SYNC_BOT_TOKEN is not set. Refusing to open a check-less sync PR (§4.2)."
|
||||
echo "Provision a GitHub App installation token (preferred) or fork-owned PAT as SYNC_BOT_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
SYNC_BOT_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }}
|
||||
|
||||
- name: Add upstream + fetch tags
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git remote add upstream https://github.com/lwouis/alt-tab-macos.git || true
|
||||
git fetch upstream --tags --force
|
||||
|
||||
- name: Verify sync labels exist (§4.2 — fail closed, never treat empty as "no open sync")
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Guard 2 below and the §0.1 trip-wire query `sync` / `conflict` / `chokepoint-refresh`.
|
||||
# `gh pr list --label <missing>` returns an EMPTY LIST, not an error, for a label that
|
||||
# was never created — so on a fresh repo Guard 2 would silently no-op. Fail closed if a
|
||||
# required label is absent; the bootstrap (§6.1) creates them with `gh label create`.
|
||||
missing=0
|
||||
for L in sync conflict chokepoint-refresh; do
|
||||
if ! gh label list --json name --jq '.[].name' | grep -qxF "$L"; then
|
||||
echo "::error::Required label '$L' is missing. Create it at bootstrap (§6.1): gh label create $L"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
[ "$missing" -eq 0 ] || exit 1
|
||||
|
||||
- name: Select oldest upstream tag strictly newer than .fork-sync-state
|
||||
id: tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# State-file-driven selection (§4.2): NOT "newest tag minus --is-ancestor".
|
||||
last_synced="$(tr -d '[:space:]' < .fork-sync-state)"
|
||||
if [ -z "$last_synced" ]; then
|
||||
echo "::error::.fork-sync-state is empty; seed it with the last-merged upstream tag (e.g. v11.3.0)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Last synced (from .fork-sync-state): $last_synced"
|
||||
|
||||
# UPSTREAM-ONLY enumeration: `git tag -l 'v*'` would also match the FORK's OWN
|
||||
# semantic-release tags (the fork cuts vX.Y.Z every sync — §4.3), so list tags from
|
||||
# the upstream remote explicitly. Sort by version; keep only tags strictly newer than
|
||||
# the recorded one; take the OLDEST of those (resolve-in-order — §4.2).
|
||||
all_newer="$(git ls-remote --tags --refs upstream 'v*' \
|
||||
| awk -F/ '{print $NF}' \
|
||||
| sort -V \
|
||||
| awk -v last="$last_synced" '
|
||||
$0 == last { seen = 1; next }
|
||||
seen { print }
|
||||
')"
|
||||
# Fallback for the case where last_synced is not itself present upstream: take every
|
||||
# tag that sorts strictly after it.
|
||||
if [ -z "$all_newer" ]; then
|
||||
all_newer="$(git ls-remote --tags --refs upstream 'v*' \
|
||||
| awk -F/ '{print $NF}' \
|
||||
| sort -V \
|
||||
| awk -v last="$last_synced" '{ if ($0 != last) print $0 }' \
|
||||
| while read -r t; do
|
||||
# keep only tags that sort AFTER last_synced
|
||||
if [ "$(printf '%s\n%s\n' "$last" "$t" | sort -V | tail -1)" = "$t" ] && [ "$t" != "$last" ]; then
|
||||
echo "$t"
|
||||
fi
|
||||
done)"
|
||||
fi
|
||||
next="$(printf '%s\n' "$all_newer" | sed '/^$/d' | head -1)"
|
||||
|
||||
if [ -z "$next" ]; then
|
||||
echo "Up to date: no upstream tag newer than $last_synced."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Next tag to sync (oldest strictly newer): $next"
|
||||
|
||||
# Guard — a prior sync is still OPEN (resolve-in-order, §4.2): do NOT open a new PR
|
||||
# while any sync/* PR is unmerged. A backlog pauses syncing until the human merges it;
|
||||
# this is the never-drop-commits behavior. The liveness alert keys off the stalled flag.
|
||||
# `gh pr list --label sync` fails closed because the label existence was verified above.
|
||||
open_sync="$(gh pr list --state open --label sync --json number,headRefName,createdAt \
|
||||
--jq 'sort_by(.createdAt) | .[0] // empty')"
|
||||
if [ -n "$open_sync" ]; then
|
||||
echo "A prior sync PR is still open (resolve-in-order): $open_sync"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "stalled=true" >> "$GITHUB_OUTPUT" # liveness alert keys off this (§4.2)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "latest=$next" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Merge onto a sync branch (real merge commit, conventional message)
|
||||
if: steps.tag.outputs.skip != 'true'
|
||||
id: merge
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG='${{ steps.tag.outputs.latest }}'
|
||||
git config user.name "fork-sync-bot"
|
||||
git config user.email "fork-sync-bot@users.noreply.github.com"
|
||||
git switch -c "sync/${TAG}"
|
||||
# NO --no-commit: let git author a REAL merge commit with a CONVENTIONAL message so
|
||||
# commitlint / @semantic-release/commit-analyzer stay happy (§4.3). On conflict, commit
|
||||
# the conflicted tree (markers included, --no-verify) so the PR shows the markers for a
|
||||
# human to resolve; Guard C (guard.yml + ci_cd.yml) then blocks it from ever shipping.
|
||||
if git merge --no-ff -m "chore(sync): merge upstream ${TAG}" "${TAG}"; then
|
||||
conflicts=false
|
||||
else
|
||||
git add -A
|
||||
git commit --no-verify \
|
||||
-m "chore(sync): merge upstream ${TAG} (CONFLICTS — resolve before merge)"
|
||||
conflicts=true
|
||||
fi
|
||||
echo "conflicts=${conflicts}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# §0.1 trip-wire A — surface chokepoint churn as a per-sync signal. A nonzero count
|
||||
# means an upstream change touched a de-paywall chokepoint file in this merge range
|
||||
# (last-synced tag .. new tag); the PR is labeled `chokepoint-refresh` (below) so the
|
||||
# trip-wire is a single query, not a memory exercise.
|
||||
CHOKEPOINTS="src/pro/license/LicenseManager.swift src/pro/ProFeature.swift src/preferences/PreferenceDefinition.swift src/pro/scheduling/ProTransitionManager.swift src/pro/license/LicenseCookie.swift src/vendors/AppCenterCrashes.swift"
|
||||
LAST_SYNCED="$(tr -d '[:space:]' < .fork-sync-state)"
|
||||
churn="$(git rev-list --count "${LAST_SYNCED}..${TAG}" -- $CHOKEPOINTS 2>/dev/null || echo 0)"
|
||||
# Also flag if the merge left a conflict in any chokepoint file (the conflicted commit
|
||||
# is HEAD; compare against its first parent HEAD~1 = the pre-merge master tip).
|
||||
chokepoint_conflict=0
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE 'LicenseManager\.swift|ProFeature\.swift|PreferenceDefinition\.swift|ProTransitionManager\.swift|LicenseCookie\.swift|AppCenterCrashes\.swift'; then
|
||||
if [ "${conflicts:-false}" = "true" ]; then
|
||||
chokepoint_conflict=1
|
||||
fi
|
||||
fi
|
||||
echo "churn=${churn}" >> "$GITHUB_OUTPUT"
|
||||
echo "chokepoint_conflict=${chokepoint_conflict}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
git push origin "HEAD:sync/${TAG}"
|
||||
|
||||
- name: Open PR
|
||||
if: steps.tag.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG='${{ steps.tag.outputs.latest }}'
|
||||
CHURN='${{ steps.merge.outputs.churn }}'
|
||||
CONFLICTS='${{ steps.merge.outputs.conflicts }}'
|
||||
CHOKEPOINT_CONFLICT='${{ steps.merge.outputs.chokepoint_conflict }}'
|
||||
|
||||
# Label fail-closed (§4.2): conflict -> `sync,conflict`, else `sync`.
|
||||
if [ "$CONFLICTS" = "true" ]; then
|
||||
LABELS="sync,conflict"
|
||||
else
|
||||
LABELS="sync"
|
||||
fi
|
||||
|
||||
BODY="Automated upstream sync to \`${TAG}\`.
|
||||
|
||||
Selected as the OLDEST upstream tag strictly newer than \`.fork-sync-state\` (§4.2).
|
||||
Verify the guard.yml + ci_cd.yml PR checks are GREEN (anti-relock test + marker/conflict greps) before merge.
|
||||
If checks did not appear, SYNC_BOT_TOKEN is mis-set to the default GITHUB_TOKEN — the gate is disarmed (§4.2).
|
||||
|
||||
Chokepoint-churn count for this merge range: ${CHURN} (nonzero => de-paywall chokepoint touched, see §0.1 trip-wire A).
|
||||
|
||||
NEVER close this PR to defer it — \`.fork-sync-state\` only advances on MERGE, so leaving it OPEN keeps this tag 'next'; closing it would silently skip the tag (§4.2 / §6.2)."
|
||||
|
||||
gh pr create \
|
||||
--head "sync/${TAG}" \
|
||||
--base master \
|
||||
--title "sync: merge upstream ${TAG}" \
|
||||
--body "$BODY" \
|
||||
--label "$LABELS"
|
||||
|
||||
# §0.1 trip-wire A — auto-apply `chokepoint-refresh` from CI when a chokepoint file
|
||||
# changed or conflicted, so the trip-wire tally never depends on a human remembering.
|
||||
if [ "${CHURN:-0}" -gt 0 ] || [ "${CHOKEPOINT_CONFLICT:-0}" = "1" ]; then
|
||||
gh pr edit "sync/${TAG}" --add-label chokepoint-refresh || true
|
||||
fi
|
||||
+4
-1
@@ -8,6 +8,7 @@ IDEWorkspaceChecks.plist
|
||||
/DerivedData/
|
||||
/build/
|
||||
/vendor/*/.build/
|
||||
/vendor/*/build/
|
||||
/vendor/*/.swiftpm/
|
||||
/vendor/*/Package.resolved
|
||||
.DS_Store
|
||||
@@ -17,6 +18,8 @@ codesign.key
|
||||
codesign.p12
|
||||
/.claude/*
|
||||
!/.claude/skills/
|
||||
/config/local.xcconfig
|
||||
/scripts/l10n/missing-translations.json
|
||||
/ai/output/
|
||||
# alt-tab-free [depaywall]: the Sparkle feed is published OUT OF TREE to the gh-pages branch by
|
||||
# scripts/update_appcast.sh; the in-tree appcast.xml is removed (PLAN §3.3).
|
||||
/appcast.xml
|
||||
@@ -0,0 +1,8 @@
|
||||
# Approved historical findings (exact fingerprints only, no patterns): readOnlyToken values in contributor/l10n scripts and an AppCenter
|
||||
# encryption key size constant, all introduced upstream before this fork. Any new or different finding is still reported.
|
||||
9147a4a864689ab94531c75b1b04d7c4f3581def:vendor/AppCenter/Sources/AppCenter/Internals/Util/MSACEncrypterPrivate.h:generic-api-key:20
|
||||
44007195b77d6809048c134c78a6d68d1895704f:scripts/update_readme_and_website.sh:generic-api-key:6
|
||||
528028d41bbbe57ea90b89a04a96a114872c405f:scripts/export_l10n_strings_to_poeditor.sh:generic-api-key:5
|
||||
d544ff3b7a6d4023ccd25783008836f50ec5b79d:scripts/import_l10n_strings_from_poeditor.sh:generic-api-key:5
|
||||
9a04c275cb9fba763c2272acc09055bf6476755d:scripts/update_contributors.sh:generic-api-key:5
|
||||
13c1cb0d000401df11a1c496b5273338db1886d7:scripts/import_l10n_strings_from_poeditor.sh:generic-api-key:6
|
||||
+5
-1
@@ -59,8 +59,12 @@
|
||||
|
||||
<!-- ===== Sparkle (auto-update framework — reads these directly from the bundle) ===== -->
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>2e9SQOBoaKElchSa/4QDli/nvYkyuDNfynfzBF6vJK4=</string>
|
||||
<!-- Fork EdDSA public key for Sparkle update verification. The matching private key
|
||||
is held as CI secret SPARKLE_ED_PRIVATE_KEY (see docs/EXECUTION-STATUS.md §3.3).
|
||||
Auto-checks stay off (below) until DOMAIN serves a real appcast host. -->
|
||||
<string>1YfoCJDk/T8GQx1Gpf3O4ApFgDkBiN6oe/z/Kfnxs/M=</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<!-- alt-tab-free: enabled 2026-06-16 — DOMAIN serves the appcast on GitHub Pages (gh-pages). -->
|
||||
<string>true</string>
|
||||
<key>SUScheduledCheckInterval</key>
|
||||
<string>604800</string>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# NOTICE — Statement of Changes (GPL-3.0 §5(a))
|
||||
|
||||
This file carries the prominent notice, required by GNU GPL v3.0 §5(a), that this work
|
||||
has been modified, and gives the relevant date. It is a **living document**: whenever a
|
||||
sync from upstream changes this fork's modification surface, update the change list and
|
||||
the date below (see `docs/PLAN-maintained-fork.md` §6.2).
|
||||
|
||||
**Forked from:** [lwouis/alt-tab-macos](https://github.com/lwouis/alt-tab-macos) v11.3.0,
|
||||
© Louis Pontoise, licensed GPL-3.0.
|
||||
|
||||
**Modified by:** the CommandTabFree fork. This fork neutralizes the AltTab Pro paywall so that
|
||||
all features are free.
|
||||
|
||||
**Date of modification:** 2026-06-10.
|
||||
|
||||
**License:** CommandTabFree is released under the **GNU General Public License v3.0**, the same
|
||||
license as upstream. The full license text is in [`LICENCE.md`](LICENCE.md), which is kept
|
||||
byte-for-byte identical to upstream and is never edited.
|
||||
|
||||
## Changes made in this fork
|
||||
|
||||
- **`LicenseManager.computeState`** (`src/pro/license/LicenseManager.swift`) — forced to
|
||||
return `.pro`, opening every Pro feature gate. This is the single live license-state
|
||||
producer, so this one change unlocks all paywalled features.
|
||||
- **`LicenseCookie.syncLicenseCookie`** (`src/pro/license/LicenseCookie.swift`) — made a
|
||||
no-op, so no license cookie is written to the upstream domain.
|
||||
- **`AppCenterCrash.init`** (`src/vendors/AppCenterCrashes.swift`) — telemetry disabled;
|
||||
AppCenter is never started.
|
||||
- **`App.handleCustomUrl`** (`src/App.swift`) — the `activate` deep-link handler is
|
||||
disabled, so a crafted `…://activate?license_key=…` URL can no longer POST the machine
|
||||
hardware fingerprint to a license backend.
|
||||
|
||||
The upstream copyright, contributor credits (`docs/contributors.md`), and third-party
|
||||
acknowledgments (`docs/acknowledgments.md`) are retained unchanged.
|
||||
@@ -1,7 +1,62 @@
|
||||
# CommandTabFree
|
||||
|
||||
**CommandTabFree is a fork of [AltTab](https://github.com/lwouis/alt-tab-macos)
|
||||
(github.com/lwouis/alt-tab-macos), © Louis Pontoise, licensed GPL-3.0.**
|
||||
|
||||
This fork neutralizes the AltTab Pro paywall — **all features are free.** It is
|
||||
**not affiliated with, sponsored by, or endorsed by** the original author. "AltTab"
|
||||
is used here only to credit the upstream project (nominative fair use); CommandTabFree
|
||||
is a separate, independently-distributed build.
|
||||
|
||||
CommandTabFree is itself released under the **GNU General Public License v3.0** — the same
|
||||
license as upstream. See [`LICENCE.md`](LICENCE.md) for the full text and
|
||||
[`NOTICE.md`](NOTICE.md) for the GPL §5(a) statement of changes made in this fork.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
brew install --cask koftwentytwo/tap/commandtabfree
|
||||
```
|
||||
|
||||
CommandTabFree is **signed and notarized** (as of v100.1.0), so it launches cleanly through
|
||||
Gatekeeper — no right-click-**Open** workaround needed. Install via the Homebrew cask above, or
|
||||
download the **`.dmg`** (or `.zip`) from the
|
||||
[Releases](https://github.com/KofTwentyTwo/CommandTabFree/releases) page and drag CommandTabFree
|
||||
to Applications. On first launch, grant **Accessibility** and **Screen Recording** in System
|
||||
Settings. Universal binary (Apple Silicon + Intel), macOS 10.13+.
|
||||
|
||||
## Based on AltTab by Louis Pontoise
|
||||
|
||||
This software is built on AltTab (`lwouis/alt-tab-macos`) by Louis Pontoise and its
|
||||
contributors. All of upstream's copyright, attribution, contributor credits
|
||||
([`docs/contributors.md`](docs/contributors.md)), and third-party acknowledgments
|
||||
([`docs/acknowledgments.md`](docs/acknowledgments.md)) are retained unchanged. The
|
||||
only functional change this fork makes is removing the Pro paywall so every feature
|
||||
is available for free; see [`NOTICE.md`](NOTICE.md) for the precise change list.
|
||||
|
||||
## Corresponding source (GPL-3.0 §6)
|
||||
|
||||
The complete corresponding source for every released binary is this repository at the
|
||||
matching release tag:
|
||||
|
||||
```
|
||||
https://github.com/KofTwentyTwo/CommandTabFree
|
||||
```
|
||||
|
||||
For a given release `vX.Y.Z`, the source is the repository tree at that tag, including
|
||||
the committed build configuration. Only signing/notarization secrets (the Developer ID
|
||||
certificate, the Sparkle EdDSA private key, and Apple notarization credentials) are
|
||||
deliberately not conveyed; GPL-3.0 does not require these to be distributed.
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
Below: upstream credit. CommandTabFree is a fork of AltTab by Louis Pontoise.
|
||||
This links to the original project for attribution, not as a download CTA for this fork.
|
||||
-->
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://alt-tab.app/"><img src="docs/readme/main.svg" alt="AltTab Pro — 7.4M downloads — 15K GitHub stars — Get AltTab"/></a>
|
||||
|
||||
<a href="https://jb.gg/OpenSource"><img src="docs/readme/sponsor.svg" alt="Sponsored by JetBrains" width="900"/></a>
|
||||
CommandTabFree is a free, GPL-3.0 fork of <a href="https://github.com/lwouis/alt-tab-macos">AltTab</a> by Louis Pontoise.
|
||||
|
||||
</div>
|
||||
@@ -15,7 +15,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D04BA5E73DAF232ADE11C97A"
|
||||
BuildableName = "AltTab.app"
|
||||
BuildableName = "CommandTabFree.app"
|
||||
BlueprintName = "alt-tab-macos"
|
||||
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -56,7 +56,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D04BA5E73DAF232ADE11C97A"
|
||||
BuildableName = "AltTab.app"
|
||||
BuildableName = "CommandTabFree.app"
|
||||
BlueprintName = "alt-tab-macos"
|
||||
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -83,7 +83,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D04BA5E73DAF232ADE11C97A"
|
||||
BuildableName = "AltTab.app"
|
||||
BuildableName = "CommandTabFree.app"
|
||||
BlueprintName = "alt-tab-macos"
|
||||
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D04BA5E73DAF232ADE11C97A"
|
||||
BuildableName = "AltTab.app"
|
||||
BuildableName = "CommandTabFree.app"
|
||||
BlueprintName = "alt-tab-macos"
|
||||
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -46,7 +46,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D04BA5E73DAF232ADE11C97A"
|
||||
BuildableName = "AltTab.app"
|
||||
BuildableName = "CommandTabFree.app"
|
||||
BlueprintName = "alt-tab-macos"
|
||||
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
||||
-3876
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,25 @@
|
||||
## [100.2.1](https://github.com/KofTwentyTwo/CommandTabFree/compare/v100.2.0...v100.2.1) (2026-06-16)
|
||||
|
||||
# [100.2.0](https://github.com/KofTwentyTwo/CommandTabFree/compare/v100.1.0...v100.2.0) (2026-06-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **updates:** enable Sparkle auto-update via GitHub Pages appcast ([45457cb](https://github.com/KofTwentyTwo/CommandTabFree/commit/45457cb))
|
||||
|
||||
# [100.1.0](https://github.com/KofTwentyTwo/CommandTabFree/compare/v100.0.3...v100.1.0) (2026-06-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** build ad-hoc until Apple cert provisioned ([6070f0c](https://github.com/KofTwentyTwo/CommandTabFree/commit/6070f0c))
|
||||
* **l10n:** regenerate base Localizable.strings from source ([d1e1b29](https://github.com/KofTwentyTwo/CommandTabFree/commit/d1e1b29))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **release:** sign + notarize with fork Developer ID ([2f9fac1](https://github.com/KofTwentyTwo/CommandTabFree/commit/2f9fac1))
|
||||
|
||||
# [11.3.0](https://github.com/lwouis/alt-tab-macos/compare/v11.2.0...v11.3.0) (2026-06-04)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
// alt-tab-free [depaywall]: commitlint config with an `ignores` predicate so a `chore(sync): merge
|
||||
// upstream` PR (and the upstream commits it drags in through the merge's second parent) does NOT fail
|
||||
// commitlint and block the release job (PLAN §4.3). ci_cd.yml lints the WHOLE pushed range on push and
|
||||
// base..head on a PR, both of which include upstream's own (non-conventional) messages on a sync merge.
|
||||
// `defaultIgnores: true` keeps config-conventional's built-in "Merge …" ignore; the predicates below
|
||||
// drop the synthetic merge subject and any "Merge …" / "Revert …" line we don't author.
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
defaultIgnores: true,
|
||||
ignores: [
|
||||
(msg) => /^chore\(sync\): merge upstream /.test(msg), // our synthetic merge commit (§4.2)
|
||||
(msg) => /^Merge /.test(msg), // belt-and-suspenders (merge commits)
|
||||
(msg) => /^Revert /.test(msg), // upstream revert subjects
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// =============================================================================
|
||||
// alt-tab-free — fork identity overlay (committed; see docs/PLAN-maintained-fork.md §3.1)
|
||||
// =============================================================================
|
||||
//
|
||||
// This file is #include?'d LAST by config/release.xcconfig and config/debug.xcconfig,
|
||||
// so "last assignment wins": every value below overrides config/base.xcconfig WITHOUT
|
||||
// editing base.xcconfig (which upstream churns). Net pbxproj/base.xcconfig edits: zero.
|
||||
//
|
||||
// >>> OWNER MUST SET REAL VALUES BEFORE PUBLISH <<<
|
||||
// Every placeholder below is OWNER-OVERRIDABLE-BEFORE-PUBLISH and is clearly marked.
|
||||
// Until these are real, the fork will NOT notarize, auto-update, or serve a working
|
||||
// feedback/appcast endpoint. See ownerActionsNeeded in the scaffolding report.
|
||||
//
|
||||
// KEYCHAIN-SUITE INVARIANT (BLOCKING): PRODUCT_BUNDLE_IDENTIFIER below derives the
|
||||
// keychain + UserDefaults suite names (LicenseManager.swift:4-5 read App.bundleIdentifier).
|
||||
// PICK THE FORK'S BUNDLE ID ONCE AND FREEZE IT. Any later change orphans every user's
|
||||
// stored prefs/state. See §3.1 OWNER DECISION (a)=fresh id / (b)=reuse+migrate.
|
||||
// =============================================================================
|
||||
|
||||
// --- App identity (overrides base.xcconfig :3 / :4) ---
|
||||
// DECIDED 2026-06-10 (KofTwentyTwo). The bundle id is FROZEN — never change it
|
||||
// (keychain + UserDefaults suite names derive from it; changing it orphans every user's state).
|
||||
PRODUCT_NAME = CommandTabFree
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.koftwentytwo.commandtabfree
|
||||
|
||||
// Local dev default for the build version. Info.plist's CFBundleVersion/CFBundleShortVersionString
|
||||
// are $(CURRENT_PROJECT_VERSION); CI's replace_environment_variables_in_app.sh APPENDS the real
|
||||
// release value (which wins by xcconfig last-assignment). Without this, a plain local build leaves
|
||||
// the version empty and App.version (src/App.swift:14, `as! String`) crashes on launch.
|
||||
CURRENT_PROJECT_VERSION = 0.0.0
|
||||
|
||||
// --- Hosts (overrides base.xcconfig :20-21) ---
|
||||
// DOMAIN serves the Sparkle appcast at https://<DOMAIN>/appcast.xml. Set 2026-06-16 to the repo's
|
||||
// GitHub Pages site (gh-pages branch, published by scripts/update_appcast.sh). A kof22.com custom
|
||||
// domain can be added later — GitHub redirects the github.io URL, so shipped builds keep updating.
|
||||
// (/support still 404s here — harmless until a support page exists.)
|
||||
DOMAIN = koftwentytwo.github.io/CommandTabFree
|
||||
// API_DOMAIN drives the user-feedback POST (FeedbackWindow) — left NON-routable: feedback is deferred
|
||||
// (§3.4). The activate fingerprint POST is closed separately at the handleCustomUrl handler.
|
||||
API_DOMAIN = fork.invalid/api
|
||||
|
||||
// --- Code signing (overrides release.xcconfig :5) ---
|
||||
// Fork Developer ID — signed, notarizable builds (PLAN §3.3 / §4.6). Set 2026-06-16.
|
||||
// The vendored Sparkle helpers (vendor/Sparkle/Helpers/*) are re-signed under this SAME
|
||||
// identity (a committed change) — required or notarization rejects the nested code.
|
||||
// Manual style + TEAM_ID so the headless CI runner signs without an Xcode-managed account.
|
||||
CODE_SIGN_IDENTITY = Developer ID Application: James Maes (2X834TJ5MA)
|
||||
CODE_SIGN_STYLE = Manual
|
||||
TEAM_ID = 2X834TJ5MA
|
||||
|
||||
// --- Telemetry off (overrides base.xcconfig; AppCenter no-ops on empty secret) ---
|
||||
// Intentionally empty: telemetry is disabled (reinforced by Edit 3, §2).
|
||||
// CI must NOT re-append an APPCENTER_SECRET line (see scripts/replace_environment_variables_in_app.sh).
|
||||
APPCENTER_SECRET =
|
||||
@@ -1,8 +1,11 @@
|
||||
// docs: https://help.apple.com/xcode/mac/11.4/#/itcaec37c2a6
|
||||
|
||||
SWIFT_VERSION = 5.8
|
||||
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.13
|
||||
SWIFT_TREAT_WARNINGS_AS_ERRORS = NO // replaced by the explicit flags below so the deprecation group can be downgraded after them
|
||||
// Raising the unit-test target to 12.0 turns uses of APIs deprecated in macOS 11/12 (kept for the 10.13 app) into warnings; only that
|
||||
// group is downgraded here (after -warnings-as-errors, which would otherwise win); every other warning stays an error.
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -warnings-as-errors -Wwarning DeprecatedDeclaration
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0 // unit-tests only: Xcode 27 supports 12.0+; the shipping app keeps 10.13 in base.xcconfig
|
||||
CODE_SIGNING_REQUIRED = NO // no need for codesign
|
||||
CODE_SIGNING_ALLOWED = NO // no need for codesign
|
||||
ONLY_ACTIVE_ARCH = YES // only need to run on the host machine
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
# alt-tab-free — Master Paywall Audit
|
||||
|
||||
> **Purpose.** This is the authoritative, read-this-first overview of the codebase, the paywall grafted onto it, and the complete surface that must be removed to ship a fully-free build. It summarizes nine deep-dive sections under [`docs/audit/`](audit/) and the completeness review; follow the links for line-by-line detail.
|
||||
>
|
||||
> **Audit date / commit basis:** repo at `master` (HEAD `9fadf36b`, v11.3.0). All `file:line` references are against this snapshot.
|
||||
>
|
||||
> **Status legend:** **KEEP** = legitimate app, must survive · **CUT** = paywall, remove · **REWRITE** = touched by both, hand-edit · **FLAG** = owner decision.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this project is, and the licensing position
|
||||
|
||||
**alt-tab-free** ([github.com/KofTwentyTwo/CommandTabFree](https://github.com/KofTwentyTwo/CommandTabFree)) is a re-host of the popular open-source macOS window switcher **AltTab** ([github.com/lwouis/alt-tab-macos](https://github.com/lwouis/alt-tab-macos)). It is a pure Swift 5.8 AppKit application — no SwiftUI, no Interface Builder (per [`AGENTS.md`](../AGENTS.md)).
|
||||
|
||||
### It is not a diverged fork — it is upstream + a paywall commit
|
||||
|
||||
Git history shows every recent commit is authored by upstream author `lwouis` (34/40) or `semantic-release-bot` (6/40); **zero commits by the alt-tab-free owner**. The entire paywall was introduced upstream in a single commit `9147a4a8` *"feat: introducing alt-tab pro!"* (v11.0.0, 2026-04-05), a 1869-file change that also did a large repo-wide folder reorg. `src/pro/` did not exist before it and has only ever been touched by 3 commits. All branding is still upstream's: bundle id `com.lwouis.alt-tab-macos`, name AltTab, domain `alt-tab.app`, repo `lwouis/alt-tab-macos`, Sparkle `SUPublicEDKey`, AppCenter secret. (See [`09-telemetry-and-upstream.md`](audit/09-telemetry-and-upstream.md).)
|
||||
|
||||
### Licensing: republishing a free build is permitted, with one obligation
|
||||
|
||||
The project is **GPL-3.0** ([`LICENCE.md`](../LICENCE.md):1 — *"GNU GENERAL PUBLIC LICENSE Version 3"*). Under GPL-3 you may modify and redistribute, including a build with all paywall code removed. **The obligation:** you must offer the complete corresponding source of your modified version under GPL-3 to your users (typically by publishing the fork's repo). The GPL-3 copyright string in `Info.plist:20-21` is correct and must stay. The paywall itself was GPL-3 code upstream, so its removal carries no additional license burden.
|
||||
|
||||
> **Strategic alternative (owner decision).** Because upstream is alive and GPL-3, rebasing the free build on the **pre-paywall upstream tag v10.12.0** (commit `317a485b`) yields the same unlocked feature set with no paywall to extract, at the cost of losing post-v11 fixes unless cherry-picked. The rest of this document assumes **in-place removal** from the current v11.x snapshot.
|
||||
|
||||
---
|
||||
|
||||
## 2. High-level architecture (the legitimate app)
|
||||
|
||||
This is the app that survives removal. Deep dive: [`01-core-architecture.md`](audit/01-core-architecture.md), [`02-switcher-ui.md`](audit/02-switcher-ui.md).
|
||||
|
||||
### Startup (two-phase, permission-gated)
|
||||
|
||||
- Entry point is top-level code in [`src/main.swift`](../src/main.swift) (**not** `@main`/`@NSApplicationMain`): CLI fast-path → signal/exception handlers → `App.shared.run()` (`main.swift:23`). `main.swift` is paywall-free.
|
||||
- `App` (`src/App.swift:7`) subclasses `AppCenterApplication` (this is *why* removing AppCenter touches the principal class — see §5) and is its own `NSApplicationDelegate`.
|
||||
- **Phase A** — `applicationDidFinishLaunching` (`App.swift:435-463`): wires logging, Preferences, the license callbacks, and starts a permission-polling timer.
|
||||
- **Phase B** — `App.continueAppLaunchAfterPermissionsAreGranted` (`App.swift:383-431`): invoked **once** from `SystemPermissions.checkPermissionsPreStartup` (`SystemPermissions.swift:77`) only after Accessibility + Screen-Recording permissions pass. It creates the background threads/queues, switcher panels, menubar, main menu, and installs every macOS hook.
|
||||
- The **phase-A → phase-B handoff is the critical launch path and is paywall-free.**
|
||||
|
||||
### macOS integration hooks (all paywall-free)
|
||||
|
||||
- Keyboard: `cghid` CGEvent tap + Carbon `RegisterEventHotKey` (`KeyboardEvents.swift:104-152`).
|
||||
- Windows: AX observers with refcon-encoded `(pid,wid)` identity (`AccessibilityEvents.swift`), dispatched through a bounded retry executor (`AXCallScheduler.swift`).
|
||||
- App lifecycle: KVO on `NSWorkspace.runningApplications` (`RunningApplicationsEvents.swift:6-10`).
|
||||
- Threading owned by `BackgroundWork` (dedicated CFRunLoop threads + bounded operation queues, ~45-thread budget). **`src/macos/` and `BackgroundWork` contain zero License/Pro references** (grep-verified).
|
||||
|
||||
### Switcher UI
|
||||
|
||||
Event-driven: each `Window` subscribes to AX notifications → `Windows.findOrCreate` (`Windows.swift:390`), gated by `WindowDiscriminator.isActualWindow`, into the canonical `Windows.list`. Pure unit-tested kernels (`SelectionResolver`, `SearchModeResolver`, `WindowFilterResolver`, `WindowOrderResolver`) are separated from imperative wrappers and **never call pro code** — gating is passed in as a `Bool`. Rendering is a static `TilesView` engine with a 20-`TileView` recycled pool (`TilesView.swift:35`); focus uses private SkyLight APIs (`Window.swift:245-288`).
|
||||
|
||||
### Preferences
|
||||
|
||||
Everything stored as strings in `UserDefaults.standard`, macro (enum) prefs as stringified `CaseIterable` index, read through `CachedUserDefaults`, written through `Preferences.set` firing one `PreferencesEvents.preferenceChanged` hook. The **core preferences plumbing is paywall-independent** — only the feature-gating layer on top is paywall (see §3).
|
||||
|
||||
### Build / release / update
|
||||
|
||||
Raw `xcodebuild` over `alt-tab-macos.xcodeproj` (3 schemes), settings layered via `config/*.xcconfig`. Releases are fully automated by `semantic-release` on push to master. Signed with upstream Developer ID *Louis Pontoise (QXD7GW8FHY)*, notarized via bundled `notarytool`. Auto-update via embedded **Sparkle 2.9.1**, feed `https://alt-tab.app/appcast.xml`, EdDSA-signed. Crash reporting via Microsoft **AppCenter** (Crashes module only; opt-in; EoL). Deep dive: [`08-build-release-distribution.md`](audit/08-build-release-distribution.md), [`09-telemetry-and-upstream.md`](audit/09-telemetry-and-upstream.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. The paywall, anatomized
|
||||
|
||||
The paywall lives **almost entirely under [`src/pro/`](../src/pro/)** and is grafted onto the spine at a small, well-defined set of call sites. Four subsystems:
|
||||
|
||||
### 3a. License subsystem — `src/pro/license/` ([`04-license-subsystem.md`](audit/04-license-subsystem.md))
|
||||
|
||||
`LicenseManager.shared` is the **single source of truth** for entitlement. It computes a flat 4-case `LicenseState` enum — `.trial(daysRemaining:)`, `.pro`, `.proExpired`, `.trialExpired` (`LicenseState.swift:14-19`); there is no `.lifetime` case (lifetime is `.pro` + variant slug `"pro_lifetime"`, `LicenseManager.swift:25`). A **14-day trial** starts on first launch (`LicenseManager.swift:17,192-200`).
|
||||
|
||||
- The master gate **`isProLocked`** (`LicenseManager.swift:67-72`) is `true` for `.proExpired`/`.trialExpired`, `false` for `.pro`/`.trial`. Every settings intercept, the write-bounce, the shortcut cap, and `ProFeature.attemptUse()` consult it.
|
||||
- `activate(_:)` (`LicenseManager.swift:104-140`) does all-or-nothing Keychain writes with rollback, then flips `state = .pro`. `RemoteLicenseClient` (`RemoteLicenseClient.swift`) POSTs activate/validate/deactivate to **`https://alt-tab.app/api/v1/license`** (`Endpoints.swift:11`), sending a machine fingerprint (`MachineFingerprint.swift:9-20`, IOPlatformUUID) + trial-start timestamp. Revalidation is throttled to 30 days.
|
||||
- Keychain service: **`com.lwouis.alt-tab-macos.license`** (`LicenseManager.swift:4`); a parallel UserDefaults suite of the same name holds `trialStartDate`, `lastValidation`, `customerEmail`.
|
||||
- `LicenseCookie.swift:5-22` writes a per-tier `license` cookie on `.alt-tab.app` so the Sparkle appcast can be tier-tailored.
|
||||
|
||||
### 3b. Trial-nag "Pro Transition" scheduler — `src/pro/scheduling/` ([`05-trial-nag-scheduling.md`](audit/05-trial-nag-scheduling.md))
|
||||
|
||||
A self-contained Day1→Day35 upsell-escalation engine. Coordinator `ProTransitionManager` (singleton) + timed `ProTransitionScheduler` (persisted `DispatchWorkItem`) + persisted `ProTransitionState` (all `hasSeen*` flags + `remembered*` Pro indices, in the `.license` UserDefaults suite) + pure, 100%-unit-tested `ProTransitionManagerTestable` state machine. The coordinator emits abstract `ProPromptAction` cases through `onAction`; `ProPromptHost` maps each to a concrete Day-X window/popover so the coordinator stays AppKit-free.
|
||||
|
||||
- **Soft nags** (never block): `[A]` Day1 Welcome (fires immediately on first launch), `[H]` Day4 Tour, `[B]` Day12 Heads-Up, `[D]` Day15 Proactive, `[F]` Day21, `[G]` Day35, plus a Days13-14 menubar badge dot.
|
||||
- **Hard gate arms at Day15 expiry:** `onProLockEngaged()` (`ProTransitionState.swift:73-79`) snapshots degradable Pro prefs to `remembered*` and downgrades stored values to free equivalents; the three hard-gated features then route through a free-pass ladder (`ProTransitionManager.swift:226-242`): first post-expiry attempt = `.freePass` (allow once, queue `[C]` Full Upgrade) → `.showFullUpgrade` → `.showHardGatePopover` `[E]` forever.
|
||||
- Prompts only fire 10:00-11:30 or 15:30-17:00; give-up at Day49.
|
||||
|
||||
### 3c. Feature gating — `ProFeature.swift` + `PreferenceDefinition.swift` ([`03-preferences-and-gating.md`](audit/03-preferences-and-gating.md), [`06-profeature-and-copy.md`](audit/06-profeature-and-copy.md))
|
||||
|
||||
Two complementary mechanisms:
|
||||
|
||||
1. **Degradable preferences** — declared as 6 `ProGatedPreferences` definitions (`PreferenceDefinition.swift:103-164`): 3 globals (`appearanceStyle`, `appearanceSize`, `shortcutStyle`) + their 3 shortcut-0 overrides. Each carries a `freeEquivalent`, a `rememberedKey`, and an `isProValue` predicate. `PreferenceDefinition.read()` (`:32-44`) is the hot-path getter; while locked it returns the free equivalent. Pro getters in `Preferences.swift` route through these (`:129,130,155,313,319,330`).
|
||||
2. **Hard-gated runtime actions** — no backing preference, gated at use-time via `ProFeature.attemptUse()` (`ProFeature.swift:74-83`): `extraShortcut(index:)`, `searchInSwitcher`, `lockSearchInSwitcher`. Call sites: `ShortcutAction.swift:56`, `TilesView.swift:79,92`.
|
||||
|
||||
Write-side enforcement bounces any setter that lands a Pro value into a gated key while locked, to the Upgrade tab (`PreferencesEvents.swift:62-64`). Free users are hard-capped at **1 shortcut** (`ControlsTab.swift:589-592`). The registered defaults already hold the Pro values (e.g. `appearanceSize=.auto`), so **removing the gate auto-unlocks everything with no default changes.**
|
||||
|
||||
### 3d. Upsell UI — `src/pro/ui/` + Settings/Menubar entanglement ([`07-pro-ui-and-menubar.md`](audit/07-pro-ui-and-menubar.md))
|
||||
|
||||
Reusable AppKit upsell primitives (none contain license logic; they only render): `ProPromptHost` (dispatch hub), `ProPromptPopover`/`ProPromptWindow` (shared chrome), `ProPromptHeader`, `ProGradientButton` (gradient buy CTA), `UsageStatHeroView` (a "your usage so far" stat block), and `ProBadgeView.swift` (misleadingly named — holds 5 types incl. the `ProGradient` utility enum, `NotAdvisedButton`, and the "Pro" pill). These are also consumed by **Settings** (the persistent Get-Pro gradient pill `UpgradeButton`, the in-window Upgrade view, `ProBadgeView` badges on gated rows, and the "Pro" search-index keyword), which is why `src/pro/ui/` can only be deleted after Settings consumers are cut.
|
||||
|
||||
---
|
||||
|
||||
## 4. The complete set of Pro-gated features (what becomes free)
|
||||
|
||||
There are **two degradable groups + three hard-gated actions**, marketed as four headline features. After removal, all are unconditionally available.
|
||||
|
||||
| Feature (marketing copy) | Kind | Free (locked) value | Pro value | Gate mechanism | Definition |
|
||||
|---|---|---|---|---|---|
|
||||
| **App Icons & Titles styles** (`appearanceStyle`) | Degradable + hard-gated | `thumbnails` (0) | `appIcons` (1) / `titles` (2) | `read()` downgrade + first-summon `[C]` | `PreferenceDefinition.swift:103-109`; `Preferences.swift:129` |
|
||||
| **Auto Size** (`appearanceSize`) | Degradable | `medium` (1) | `auto` (3) | `read()` downgrade only | `PreferenceDefinition.swift:111-117`; `Preferences.swift:130` |
|
||||
| **Search on release** (`shortcutStyle`) | Degradable + hard-gated | `doNothingOnRelease`/`focusOnRelease` | `searchOnRelease` (2) | `read()` downgrade + first-summon `[C]` | `PreferenceDefinition.swift:119-125`; `Preferences.swift:155` |
|
||||
| ↳ same three as **shortcut-0 overrides** | Degradable | free equivalents | Pro values | `read()` downgrade | `PreferenceDefinition.swift:130-164`; `Preferences.swift:313,319,330` |
|
||||
| **Up to 9 keyboard shortcuts** (extra slots ≥ 2) | Hard-gated | capped at **1** shortcut | up to 9 | `ProFeature.attemptUse()` + `addShortcutSlot` cap | `ProFeature.swift:14`; `ShortcutAction.swift:56`; `ControlsTab.swift:589-592` |
|
||||
| **Search — find a window by typing** | Hard-gated | blocked | available | `ProFeature.attemptUse()` | `ProFeature.swift:15`; `TilesView.swift:92` |
|
||||
| **Lock-search in switcher** | Hard-gated | blocked | available | `ProFeature.attemptUse()` | `ProFeature.swift:16`; `TilesView.swift:79` |
|
||||
|
||||
Marketing copy: `ProFeatureCopy.swift` (4 strings); the comparison table that advertises them is `Day1WelcomeLetterWindow.swift:77-80`. Note **preview-selected-window is NOT actually Pro-gated** (`Preferences.effectivePreviewSelectedWindow` reads the raw bool, gated only by Screen-Recording permission) — leave it alone.
|
||||
|
||||
---
|
||||
|
||||
## 5. The complete removal surface
|
||||
|
||||
**Removal strategy (consensus across all sections):** *delete `src/pro/` as a unit, then fix the consumers that reference it.* The lowest-risk staged approach is to first force `LicenseManager.isProLocked = false` / `isProAvailable = true` (every gate becomes a pass-through with no other edits), then progressively excise. The list below — grouped by file — is the full set of edits required for a clean build, incorporating the completeness review's additional items and build-break risks. **Bold flags** mark items the deep sections under-weighted.
|
||||
|
||||
### `src/pro/` — delete wholesale (CUT)
|
||||
|
||||
- `src/pro/license/` — `LicenseManager`, `LicenseState`, `LicenseAPI`, `RemoteLicenseClient`, `LicenseCookie`, `Keychain`, `MachineFingerprint`, `Clock`, `LicenseManagerSpecs.md`, `LicenseManagerTests.swift`.
|
||||
- `src/pro/scheduling/` — `ProTransitionManager`, `ProTransitionScheduler`, `ProTransitionState`, `ProTransitionManagerTestable`, all `Day*.swift` windows/popovers, `ProTransitionTests.swift`, `ProTransitionSpecs.md`.
|
||||
- `src/pro/ui/` — `ProPromptHost`, `ProPromptPopover`, `ProPromptWindow`, `ProPromptHeader`, `ProGradientButton`, `UsageStatHeroView`, `ProBadgeView.swift` (all 5 types). **Delete only AFTER the Settings consumers below are removed.**
|
||||
- `src/pro/` root — `ProFeature.swift`, `ProFeatureCopy.swift`, `ProConversionCopy.swift`, `UsageStats.swift` / `UsageStatsTestable.swift` (local-only; feeds only upsell copy).
|
||||
|
||||
### `src/App.swift` (REWRITE — the spine wiring)
|
||||
|
||||
- Selectors `upgradeToProAction` / `openAccountAction` / `supportProjectAction` (`:25-27`) and methods `upgradeToPro` / `openAccount` / `supportProject` (`:111-121`) — remove (Menubar-only consumers).
|
||||
- `hideUi` `onSwitcherDismissed()` (`:78`) and `showUiOrCycleSelection` `onSwitcherShown()` (`:328`) — remove the single Pro line each (switcher hot path).
|
||||
- First-launch welcome entanglement (`:189-233`): `willShowDay1WelcomeOnAppLaunch` (`:205-208`) + deferral observer referencing `Day1WelcomeLetterWindow` (`:194-218`). **Simplify to always call `showAndCenterSettingsWindowOnFirstLaunch`; the welcome window and its `willCloseNotification` observer must be removed together or Settings never shows on first launch.**
|
||||
- Phase-B Pro wiring `onAction` / `onAppLaunchComplete` (`:428-429`) — remove (all other phase-B wiring stays).
|
||||
- License callback block (`:448-460`): `onBeforeProUnlock`, the `onStateChanged` fan-out (`refreshLicenseMenuItems`, `syncLicenseCookie`, `onLicenseStateChanged`, `UpgradeTab.refreshStatus`, `refreshUpgradeButton`, `proLockStateDidChange` post), `LicenseManager.initialize()`. The cleanest 13-line cut point. Keep `resetPreferencesDependentComponents` (`:51-53`) — it's used elsewhere; just drop the caller.
|
||||
- Custom-URL handler `application(_:open:)` + `handleCustomUrl` (`:465-490`) — remove wholesale (exists only for the `<bundleid>://activate` deep link; the registered scheme is `$(PRODUCT_BUNDLE_IDENTIFIER)` = `com.lwouis.alt-tab-macos://`, NOT a literal `alt-tab://`). **Also drop the URL scheme from `Info.plist` (`CFBundleURLSchemes`, :53-56).**
|
||||
|
||||
### `src/Menubar.swift` (REWRITE)
|
||||
|
||||
- License item vars (`:8-10`), the Get Pro / My Account / Support adds + view assignment (`:46-50`), `refreshLicenseMenuItems` (`:84-107`), `toggleUpgradeMenuItem` (`:109-118`).
|
||||
- `UpgradeMenuItemView` gradient pill incl. trial "days remaining" copy (`:235-390`; copy at `:370-375`) — delete; only consumer is `:47`.
|
||||
- Badge dot: `badgeDotLayer` (`:187`), `updateBadgeDotOverlay` (`:202-227`) gated on `ProTransitionManager.shouldShowBadgeDot` (`:205`), and its call inside `loadPreferredIcon` (`:196`).
|
||||
- `menuWillOpen` (`:392-399`): drop `LicenseManager.shared.refreshState()` (`:396`); **keep `refreshPermissionCallout()` (`:397`).**
|
||||
- **`updateContent(_ state: LicenseState)` (`:357`) hard-depends on the `LicenseState` enum type — goes away with `UpgradeMenuItemView`. Keep `menubarIconCallback` (`:155-164`), used by non-pro callers.**
|
||||
|
||||
### `src/preferences/` (REWRITE)
|
||||
|
||||
- `PreferenceDefinition.swift` — delete entirely; **it is also a unit-test-target member (`project.pbxproj:1979`), so a broken getter fails app AND test compile.**
|
||||
- `Preferences.swift` — rewrite the 3 gated getters (`:129,130,155`) and 3 index-0 override branches (`:313,319,330`) to read raw values; remove `overrideRememberedKey` + `ProTransitionState` cleanup in `removeOverride` (`:282-298`).
|
||||
- `PreferencesEvents.swift:62-64` — delete the `isProLocked && isStoredValuePro` bounce.
|
||||
- `PreferencesMigrations.swift:20` — remove the `ProTransitionState.markFreshInstallIfUnknown` call (the only paywall coupling in migrations; **the real symbol, distinct from the self-contained stub in `PreferencesMigrationsTests.swift:356`**).
|
||||
- `LabelAndControl.swift` — strip `proGatedIndices` param + intercept (`:74-116`, esp. `:86-92`).
|
||||
- `AppearanceTab.swift` — `proLockStateDidChange` observer (`:414-419`), `proGatedAppearanceStyleIndices` (`:462,700-703`), `ProBadge.attach` (`:463,481,575,688-698`), `wrapShortcutStyleProLockIntercept` (`:585-598`), `wrapAppearanceSizeProLockIntercept` (`:662-679`), `refreshProLockUi` (`:705+`).
|
||||
- `ShortcutEditor.swift` — `proGatedIndices` (`:429,437,449,459`), segmented + radios locked intercepts (`:599-607,705-711`).
|
||||
- `ControlsTab.swift` — `proLockStateDidChange` observer (`:141`), `row.setProBadge` for index≥1 (`:515`), the `addShortcutSlot` shortcut cap (`:589-592`).
|
||||
- `ShortcutsWhenActiveSheet.swift` — `ProBadge` on search/lock-search rows (`:25,51,54,63-69`).
|
||||
|
||||
### `src/preferences/settings-window/SettingsWindow.swift` — **the most license-entangled non-pro file outside Menubar/UpgradeTab (REWRITE — under-weighted by §7)**
|
||||
|
||||
- `UpgradeButton` class (`:133-214`, a `ProGradientButton` subclass reading `LicenseManager.state`/`.isLifetimeVariant`/`.customerEmail`/`.trial`/`.proExpired`); instance `:303`; state `:308-311`; `setupUpgradeButton` + `upgradeButtonClicked` (`:401,507-526`).
|
||||
- `showUpgradeView` / `hideUpgradeView` embedding `UpgradeTab.initTab` (`:1066,1122-1161`); `refreshUpgradeButton` (`:1163-1165`, called from `App.swift:454` and `UpgradeTab:214`).
|
||||
- `windowDidBecomeKey` → `LicenseManager.refreshState` + `UpgradeTab.refreshStatus` + shine animation (`:1243-1254`); `UpgradeTab.cleanup` in `windowWillClose` (`:1266`).
|
||||
- Settings-search registers `ProBadgeView → "Pro"` (`:679-680`); `refreshEmailTooltip` + `hasSecondLine` read `.pro`/`customerEmail` (`:196,206-207`).
|
||||
|
||||
### Other Settings / secondary windows (REWRITE)
|
||||
|
||||
- `SidebarList.swift` — `private var proBadge: ProBadgeView?` (`:93`) and `setProBadge` instantiating `ProBadgeView` (`:244-258`).
|
||||
- `UpgradeTab.swift` — delete the whole tab; **`ProHeroButton: ProGradientButton` (`:493`) + `usageHero`/`heroButton`/`makeHeroButton` (`:5-6,37-38,160-161`); `activateLicense`/`deactivateLicense`/`deactivateInstance` + `LicenseAPIError.seatLimitExceeded` seat-management UI (`:322-405`) — full activation UI, §4 listed only `LicenseManager`/`RemoteLicenseClient`.**
|
||||
- `DebugProfile.swift:17` — `LicenseManager.shared.state.debugProfileLabel` (extension at `LicenseState.swift:14`). **This feeds `FeedbackWindow`, which is being kept — hand-edit (drop the License line), do not delete the file.**
|
||||
|
||||
### `src/debug/QAMenu.swift` (REWRITE)
|
||||
|
||||
- The DEBUG manual triggers/resets (`:170-241`) directly instantiate every Day-X window and poke `LicenseManager`/manager flags — must be removed/updated in lockstep or the **DEBUG build won't compile.**
|
||||
|
||||
### Build / distribution / telemetry (REWRITE / FLAG — see [`08`](audit/08-build-release-distribution.md), [`09`](audit/09-telemetry-and-upstream.md))
|
||||
|
||||
- `Endpoints.swift` — `licenseApiBaseUrl` (`:11`), `checkoutUrl` (`:9`), `accountUrl` (`:10`) become dead. `feedbackUrl` (`:12`) and `supportUrl` (`:8`) are not paywall — owner decides.
|
||||
- **Identity (mandatory regardless of removal):** the upstream Developer ID cannot be used. Change bundle id (`config/base.xcconfig:4`; also hardcoded `Mocks.swift:133` and unit-test ids `project.pbxproj:2477/2542`), signing cert (`release.xcconfig:5`), generate a NEW Sparkle EdDSA keypair (`Info.plist:62 SUPublicEDKey` + CI `$SPARKLE_ED_PRIVATE_KEY`), repoint feed/download/website URLs (`base.xcconfig:20-21`, `update_appcast.sh:16,18`, `App.repository` `App.swift:16`), truncate the upstream-signed `appcast.xml`.
|
||||
- **AppCenter removal touches the app's principal class:** `App` subclasses `AppCenterApplication` (`App.swift:7`), `Info.plist:36-37 NSPrincipalClass=AppCenterApplication`, `alt-tab-macos-Bridging-Header.h:1`. Removing AppCenter requires choosing a replacement principal class / reverting to `NSApplication`, or **the app won't launch and won't compile.** Also: `src/vendors/AppCenterCrashes.swift`, `Secrets.appCenterSecret`, `scripts/upload_symbols_to_appcenter.sh`, `ci_cd.yml:56`.
|
||||
- `.github/FUNDING.yml` and the website repository-dispatch (`update_website.sh:5 → lwouis/alt-tab-website`) point at upstream — repoint or remove.
|
||||
|
||||
### `alt-tab-macos.xcodeproj/project.pbxproj` — **mechanical but build-fatal (REWRITE)**
|
||||
|
||||
Every `src/pro` file (and `Endpoints`/`Secrets`/`AppCenter`) has a `PBXBuildFile` + `PBXFileReference` + `PBXGroup` child + `PBXSourcesBuildPhase` membership (~102 in-Sources pro entries; app phase ~1830, test phase ~1902; incl. `PreferenceDefinition.swift:1979`). **`Clock`/`Keychain`/`LicenseAPI`/`MachineFingerprint`/`ProTransitionManagerTestable` each have TWO build-file UUIDs (app + test) — both must be removed.** Any file deleted on disk but left referenced breaks at project-load/link.
|
||||
|
||||
### Test target (REWRITE)
|
||||
|
||||
`LicenseManagerTests`, `ProTransitionTests`, `ProTransitionManagerTestable`, `UsageStatsMessageTests`, `ProBadgeViewSegmentTests`, and `_test-support/Mocks.swift` (ProBadgeView stub + `ShortcutStylePreference` mock + bundle id `:133`) all break if pro symbols vanish — update/remove in lockstep.
|
||||
|
||||
### Build-break risk summary (the hard dependencies)
|
||||
|
||||
| Symbol being deleted | Non-pro / under-weighted consumers that break first |
|
||||
|---|---|
|
||||
| `LicenseState` enum | `Menubar.swift:357` (`updateContent` param), `DebugProfile.swift:17` (`.debugProfileLabel`) |
|
||||
| `LicenseManager.shared` | `SettingsWindow.swift:162,164,167,196,206-207,1246`; `UpgradeTab.swift:220-405` |
|
||||
| `ProGradientButton` (superclass) | `UpgradeButton` (`SettingsWindow.swift:133`), `ProHeroButton` (`UpgradeTab.swift:493`), `ProGradient.makeLayer` (`Menubar.swift:247`) |
|
||||
| `ProBadgeView` | `SidebarList.swift:93,254`, `SettingsWindow.swift:679`, Appearance/Controls/ShortcutsWhenActiveSheet tabs |
|
||||
| `ProGatedPreferences` / `PreferenceDefinition` | `Preferences.swift:129,130,155`; also a test-target member → fails app + test compile |
|
||||
| `AppCenterApplication` | `App.swift:7` superclass + Bridging-Header + `Info.plist` NSPrincipalClass |
|
||||
| `Day1WelcomeLetterWindow` | `App.swift:213` first-launch deferral observer |
|
||||
| `ProTransitionState.markFreshInstallIfUnknown` | `PreferencesMigrations.swift:20` (real symbol) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Notable findings, risks, and open questions
|
||||
|
||||
### Findings
|
||||
|
||||
- **The paywall is well-isolated.** Outside `src/pro/`, the entire spine coupling is ~13 lines in `App.swift` (`:448-460`) plus discrete blocks in `Menubar`, `Settings`, and the gating layer. The launch path, switcher kernels, macOS hooks, threading, and core preferences are all paywall-free.
|
||||
- **Defaults already hold Pro values.** Removing the gate auto-unlocks features with no default changes (`appearanceSize=.auto`, etc.).
|
||||
- **`.proExpired` is effectively dead code** in this build: `versionLimitedVariants` is empty (`LicenseManager.swift:28`), so no `.pro` variant ever expires by version.
|
||||
- **No third-party analytics SDK.** Only telemetry is AppCenter Crashes (opt-in, EoL); `UsageStats` is local-only and never networked.
|
||||
|
||||
### Risks
|
||||
|
||||
- **In-place upgrade data loss.** Users who passed trial expiry on the paywalled build have stored prefs already downgraded to free equivalents, with the original Pro index parked in `proTransition.remembered*` keys (in the `.license` UserDefaults suite). After removal, `read()` no longer restores them → silent loss of their prior Pro selection. A **new bundle id** (clean UserDefaults domain) sidesteps this; otherwise add a one-time migration copying `remembered*` indices back to base keys before deleting `proTransition.*`.
|
||||
- **`state.didSet` fires `onStateChanged`.** If you hardcode `state = .pro` at init, ensure the hook tolerates being invoked (or unset it) — otherwise it can re-enter paywall code you intend to delete.
|
||||
- **Keychain/signing invariant is MOOT for a free build.** Removing `src/pro` removes all Keychain license usage, so changing bundle id / Developer ID has nothing to orphan — but the **identity change is still mandatory** because the upstream Developer ID cannot be used. (Per [`AGENTS.md`](../AGENTS.md), do not rotate identity while licensing is retained.)
|
||||
- **Do NOT `git revert 9147a4a8`.** It mixes the paywall with a 1869-file folder reorg; removal must be by deleting `src/pro/` and untangling the wiring, then confirming the build links (`ai/build.sh`).
|
||||
|
||||
### Open questions (owner decisions)
|
||||
|
||||
1. **New bundle id vs in-place?** Determines whether the `remembered*` migration above is needed.
|
||||
2. **Keep "Support this project"** (donation link, upstream precedent) and the feedback feature? Both are non-paywall but currently route through paywall-adjacent code / upstream endpoints (`Endpoints.supportUrl`, `Endpoints.feedbackUrl` → `alt-tab.app`, whose server side is not in this repo).
|
||||
3. **Auto-update story.** A fork cannot ship updates against the upstream `SUPublicEDKey` / `alt-tab.app/appcast.xml` without owning that domain and lwouis's private key. Stand up own host + EdDSA key, or strip Sparkle.
|
||||
4. **Keep crash diagnostics?** Remove AppCenter entirely (most privacy-aligned, but requires a replacement principal class) vs repoint to a self-owned AppCenter app (EoL).
|
||||
5. **Rebase on pre-paywall v10.12.0** vs in-place removal — both yield the same unlocked feature set.
|
||||
|
||||
---
|
||||
|
||||
*Deep-dive sections: [`01-core-architecture`](audit/01-core-architecture.md) · [`02-switcher-ui`](audit/02-switcher-ui.md) · [`03-preferences-and-gating`](audit/03-preferences-and-gating.md) · [`04-license-subsystem`](audit/04-license-subsystem.md) · [`05-trial-nag-scheduling`](audit/05-trial-nag-scheduling.md) · [`06-profeature-and-copy`](audit/06-profeature-and-copy.md) · [`07-pro-ui-and-menubar`](audit/07-pro-ui-and-menubar.md) · [`08-build-release-distribution`](audit/08-build-release-distribution.md) · [`09-telemetry-and-upstream`](audit/09-telemetry-and-upstream.md)*
|
||||
@@ -0,0 +1,98 @@
|
||||
# CI/CD readiness — CommandTabFree (fork of lwouis/alt-tab-macos)
|
||||
|
||||
**Date:** 2026-06-16 **Branch:** `master` **Repo:** `KofTwentyTwo/CommandTabFree` (public)
|
||||
**Status:** publishing pipeline + Sparkle auto-update **LIVE** — signed + notarized **v100.2.0** released (Developer ID `James Maes (2X834TJ5MA)`, `spctl`-accepted + stapled), feed served on GitHub Pages (`koftwentytwo.github.io/CommandTabFree/appcast.xml`). The release apparatus (cert-import → sign-build → notarize → appcast → semantic-release → gh-release) is gated on a `will-release` signal (empty `VERSION.txt` ⇒ no release), so docs/ci pushes build+test without cutting a release or needing `[skip ci]`.
|
||||
**Authoritative design:** `docs/PLAN-maintained-fork.md` §4; owner checklist `docs/EXECUTION-STATUS.md` §3.4/§3.5.
|
||||
|
||||
This is the state after the CI-fix pass. The pass applied only **safe, owner-secret-independent**
|
||||
adaptations so that **push CI is GREEN today** on the not-yet-signing-provisioned fork, while the
|
||||
release tail **flips on automatically** the moment the owner adds the Apple/Sparkle secrets. No secret
|
||||
values were invented; no DOMAIN fabricated; notarization is not enabled without a cert.
|
||||
|
||||
---
|
||||
|
||||
## What changed in this pass
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `.github/workflows/ci_cd.yml` | Added a `release-gate` step that emits `signing-ready` from secret presence (`APPLE_P12_CERTIFICATE`/`APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID`/`SPARKLE_ED_PRIVATE_KEY`). Gated codesign/notarize/appcast/semantic-release/extract-changelog/gh-release/sync-state-advance behind `push && signing-ready=='true'`. Removed the `update_readme_and_website.sh` step (queried the **upstream** lwouis API — semantic mismatch, cosmetic). |
|
||||
| `scripts/update_appcast.sh` | `sign_update -s $KEY` (DEPRECATED; per `--help` "no longer supported for newly generated keys" — would reject the fork's fresh EdDSA key) → `printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \| sign_update --ed-key-file -`. No `-p` (default output is the `sparkle:edSignature/length` attr pair the enclosure needs; verified). Key now off the argv. |
|
||||
| `commitlint.config.js` | Added `defaultIgnores: true` + `ignores` predicates (drop `chore(sync): merge upstream …`, `Merge …`, `Revert …`) so a sync merge's dragged-in upstream messages don't fail commitlint and block the release job (PLAN §4.3). |
|
||||
| `scripts/update_readme_and_website.sh` | **Removed** — hardcoded `lwouis/alt-tab-macos` API + `lwouis` contributor graph; cosmetic SVG stats; no live caller after the workflow edit. |
|
||||
| `scripts/update_website.sh` | **Removed** — dispatched to upstream `lwouis/alt-tab-website`; already had no live caller. |
|
||||
| `scripts/upload_symbols_to_appcenter.sh` | **Removed** — hardcoded upstream AppCenter org; telemetry is off (PLAN §3.4); no live caller. |
|
||||
|
||||
All three workflow YAMLs validate (`yaml.safe_load` OK); `commitlint.config.js` loads in node.
|
||||
|
||||
---
|
||||
|
||||
## (A) WORKING / green NOW, without any owner secret
|
||||
|
||||
These run on every push and PR and need no Apple/Sparkle/DOMAIN/bot secret. They are what makes push
|
||||
CI GREEN today (vs. the prior all-14-runs-RED state — note that RED was a *missing depaywall marker*,
|
||||
already fixed on this branch; this pass ensures it does not go RED again on missing secrets).
|
||||
|
||||
| Capability | Where | Confidence |
|
||||
|---|---|---|
|
||||
| **Guard A** anti-relock unit tests (`run_tests.sh`, Test scheme) | `ci_cd.yml` push+PR; `guard.yml` PR (macos-15) | High — verified GREEN in EXECUTION-STATUS §2.2 (493 exec, 16 skipped, 0 fail) |
|
||||
| **Guard B** de-paywall marker + `if false` wrap grep | `ci_cd.yml` first push step + PR; `guard.yml` PR | High — marker present at `LicenseManager.swift:177` |
|
||||
| **Guard C** unresolved-conflict-marker grep | `ci_cd.yml` first push step + PR; `guard.yml` PR | High |
|
||||
| **Compile the full tree** (`build_app.sh`, **ad-hoc**) | `ci_cd.yml` push | Verified GREEN on CI (run 27634828800). NOTE: the bare `build_app.sh` inherits upstream's Developer ID from `config/release.xcconfig:5` and fails with exit 65 on CI; the build step now runs ad-hoc (`CODE_SIGN_IDENTITY=-`) when `release-gate` is not signing-ready, and signed otherwise. `build_app.sh` forwards xcodebuild overrides via `"$@"`. |
|
||||
| **commitlint** (push range + PR base..head, with `ignores`) | `ci_cd.yml` | Med-High — `ignores` should absorb upstream merge msgs; confirm on FIRST real sync PR |
|
||||
| **generated-files-up-to-date** check | `ci_cd.yml` push+PR | High |
|
||||
| **semantic-release dry-run** version compute (`determine_next_version.sh`) | `ci_cd.yml` push | High — dry-run, no push, no secret |
|
||||
| **Release tail SKIPS cleanly** when secrets absent (warning, not failure) | `ci_cd.yml` `release-gate` | High — `signing-ready=false` path emits `::warning::` and skips |
|
||||
|
||||
> Note: full GREEN on the GitHub-hosted **macos-15** runner with **Xcode 26.0.1** is now CONFIRMED by a
|
||||
> real run (`27634828800`, commit `6070f0cb`): gate + `run_tests.sh` + ad-hoc `build_app.sh` all pass and
|
||||
> the release tail skips cleanly on absent secrets.
|
||||
|
||||
## (B) READY — flips ON automatically when the owner adds secret X
|
||||
|
||||
No further code change needed; the gate enables these the moment the secret(s) exist in the `production`
|
||||
Environment.
|
||||
|
||||
| Capability | Enabling secret(s) | Gated step(s) |
|
||||
|---|---|---|
|
||||
| Code-sign keychain import | `APPLE_P12_CERTIFICATE` | `setup_ci_master.sh` |
|
||||
| Notarize + staple | `APPLE_ID` + `APPLE_PASSWORD` + `APPLE_TEAM_ID` | `package_and_notarize_release.sh` |
|
||||
| Sparkle appcast sign + publish to gh-pages | `SPARKLE_ED_PRIVATE_KEY` (+ a real `DOMAIN`/feed host) | `update_appcast.sh` (now `--ed-key-file -`) |
|
||||
| Tag push + GitHub Release | ambient `GITHUB_TOKEN` (auto) — only gated alongside the above so a release isn't cut unsigned | `npx semantic-release`, `softprops/action-gh-release` (the release-tail `.fork-sync-state` advance step was **REMOVED** 2026-06-16 — it clobbered the upstream-sync cursor with the fork release tag; the cursor must be advanced by the sync-merge flow instead — see `ci_cd.yml`) |
|
||||
| Sync PR fires `guard.yml`/`ci_cd.yml` PR checks | `SYNC_BOT_TOKEN` (GitHub App token or fork PAT — NOT default `GITHUB_TOKEN`) | `upstream_sync.yml` (fail-closed if missing) |
|
||||
|
||||
> The release tail is **all-or-nothing**: it requires the full Apple set **and** the Sparkle key
|
||||
> (`signing-ready` is the AND of all five). This is deliberate — cutting a tag/Release without a signed,
|
||||
> notarized, Sparkle-signed artifact would publish an un-updatable or Gatekeeper-blocked build and burn a
|
||||
> version number. If you want to test the build path before the Apple cert exists, push runs build + Guard
|
||||
> A and stops cleanly.
|
||||
|
||||
## (C) OWNER-BLOCKED — exact action required (cannot be done by agents)
|
||||
|
||||
| # | Blocker | Exact owner action |
|
||||
|---|---|---|
|
||||
| 1 | Apple Developer ID cert | Obtain a **Developer ID Application** cert for the fork; base64 it into secret `APPLE_P12_CERTIFICATE`; set `CODE_SIGN_IDENTITY` in `config/local.xcconfig` (currently commented). Re-sign the vendored `vendor/Sparkle/Helpers/*` under the SAME ID or notarization rejects (PLAN §3.3). |
|
||||
| 2 | Notarization creds | Set secrets `APPLE_ID`, `APPLE_PASSWORD` (app-specific password), `APPLE_TEAM_ID` in the `production` Environment. |
|
||||
| 3 | Sparkle private key | Store `SPARKLE_ED_PRIVATE_KEY` (already generated 2026-06-15) in the `production` Environment. Confirm the `Info.plist` `SUPublicEDKey` is the real public half (currently may be a placeholder). |
|
||||
| 4 | `production` Environment | Create the GitHub Environment named **`production`** (matches `ci_cd.yml: environment: production`) with **no required-reviewer protection** that would stall releases; attach secrets #1–#3. |
|
||||
| 5 | Real DOMAIN / appcast host | Replace `DOMAIN`/`API_DOMAIN` placeholders (`fork.invalid`) in `config/local.xcconfig` with fork-owned routable hosts; stand up `https://<DOMAIN>/appcast.xml` (gh-pages); `git rm appcast.xml` + gitignore it (PLAN §3.2). Until done, auto-update has no feed even with the key. |
|
||||
| 6 | `SYNC_BOT_TOKEN` | Provision a GitHub App installation token (preferred) or fork PAT with contents + pull-requests write. Default `GITHUB_TOKEN` does NOT re-trigger PR checks → guards never run on the sync PR (`upstream_sync.yml` fails closed without it). |
|
||||
| 7 | PR labels | `gh label create sync` / `conflict` / `chokepoint-refresh` (`upstream_sync.yml` fails closed if any is missing). |
|
||||
| 8 | External scheduler | Hit `repository_dispatch` (type `upstream-sync`) from an external cron (immune to GitHub's 60-day in-repo `schedule:` auto-disable); add the two-trigger liveness alert (PLAN §4.2). |
|
||||
| 9 | Xcode re-pin discipline | When an upstream sync bumps `ci_cd.yml`'s pinned Xcode, re-pin `guard.yml`'s `Xcode_26.0.1` line to match (EXECUTION-STATUS §3.5). |
|
||||
| 10 | Bootstrap acceptance tests | (a) sync PR through the bot shows guard checks; (b) a deliberately-relocked branch goes RED on `guard.yml`; (c) fork build N→N+1 auto-updates via Sparkle (PLAN §4.2/§4.4/§6.1). |
|
||||
|
||||
---
|
||||
|
||||
## Remaining risks (need a real CI run to confirm)
|
||||
|
||||
- **macos-15 + Xcode_26.0.1 availability** on GitHub-hosted runners is assumed, not verified here.
|
||||
- **commitlint `ignores`** is verified to load and match our synthetic subject, but the exact set of
|
||||
*upstream* non-conforming messages can't be enumerated until a real `chore(sync)` merge — re-verify on
|
||||
the FIRST sync PR (PLAN §4.3 says confirm on first sync).
|
||||
- **`semantic-release` v15 / commitlint v8 on node-16**: kept per PLAN §4.3 (engines mismatch only warns);
|
||||
unverified end-to-end on the runner since the release tail can't run without secrets.
|
||||
- **`update_appcast.sh` gh-pages publish path** (orphan-branch seed, append-before-`</channel>`) is logic
|
||||
the agents can't exercise without `GITHUB_TOKEN` + a real repo; verify on the first signed release.
|
||||
- **`set -x` in `update_appcast.sh`** will print the `printf '%s' "$SPARKLE_ED_PRIVATE_KEY"` command in
|
||||
the (private-repo) log trace; the key is no longer on the argv/process list, but consider `set +x` around
|
||||
the sign line if the repo ever goes from private logs to public. (Not changed in this pass — out of scope.)
|
||||
@@ -0,0 +1,295 @@
|
||||
# Execution status — depaywall-free integration
|
||||
|
||||
**Branch:** `depaywall-free`
|
||||
**Upstream sync point:** lwouis/alt-tab-macos `v11.3.0` (recorded in `.fork-sync-state`)
|
||||
**Date:** 2026-06-10
|
||||
**Authoritative plan:** `docs/PLAN-maintained-fork.md`. Audit: `docs/AUDIT.md`.
|
||||
|
||||
This document records what the automated agents DID, the verified build/test/gate
|
||||
results, and the **single authoritative owner publish checklist** (only the human
|
||||
owner can do the items at the bottom). Read the checklist top to bottom before publishing —
|
||||
several items are BLOCKING (the fork will not notarize, auto-update, or gate releases
|
||||
until they are done).
|
||||
|
||||
---
|
||||
|
||||
## 1. What was DONE
|
||||
|
||||
### 1.1 De-paywall patch (committed BEFORE this integration pass)
|
||||
|
||||
Landed in commit `f3d76859` (`depaywall: neutralize license gate, cookie, telemetry, and
|
||||
activate handler (src/pro left intact)`). `src/pro/` is left physically present but inert.
|
||||
Four neutralizations, each carrying a `depaywall` marker comment:
|
||||
|
||||
| # | File:line | Edit | Effect |
|
||||
|---|-----------|------|--------|
|
||||
| 1 | `src/pro/license/LicenseManager.swift:177` | `guard false else { return .pro }` (body below wrapped in `if false { … }`) | The single state producer `computeState()` always returns `.pro`. Opens every Pro gate in the tree at once; no per-merge UI/pbxproj edits. |
|
||||
| 2 | `src/App.swift:474` | `guard false else { return }` at top of `handleCustomUrl` | The scheme-gated `<bundleid>://activate?license_key=…` handler early-returns, so it can NEVER reach `LicenseManager.shared.activate → RemoteLicenseClient` POST that exfiltrates the machine hardware UUID (`MachineFingerprint`). This is the live-exfiltration surface, killed independent of where `API_DOMAIN` points. |
|
||||
| 3 | `src/pro/license/LicenseCookie.swift:6` | body wrapped in `if false { … }` | `syncLicenseCookie(state:)` never writes a `license` cookie to the fork/upstream domain. Call site in `App.swift` (hot file) untouched. |
|
||||
| 4 | `src/vendors/AppCenterCrashes.swift:10` | `init()` body wrapped in `if false { … }` | `AppCenter.start(...)` never runs; no crash telemetry network calls. `App.appCenterDelegate = AppCenterCrash()` still constructs the object, which now does nothing. |
|
||||
|
||||
All four use `if false { … }` / `guard false` rather than a bare early `return`, because
|
||||
`config/base.xcconfig:7` sets `SWIFT_TREAT_WARNINGS_AS_ERRORS=YES` and "code after return"
|
||||
is a build error.
|
||||
|
||||
### 1.2 Anti-relock guard tests (committed BEFORE this integration pass)
|
||||
|
||||
Landed in commit `4506c539`. Two methods appended to the EXISTING
|
||||
`src/pro/license/LicenseManagerTests.swift` (already in the `unit-tests` Sources phase — zero
|
||||
pbxproj edits), per PLAN §4.4 Guard A:
|
||||
|
||||
- `testDepaywallProNeverLocked` — fresh mock-injected `LicenseManager`; asserts `state == .pro`.
|
||||
- `testDepaywallStillProAfterTrialExpiry` — injects a long-past `trialStartDate` so an
|
||||
*unpatched* `computeState()` would return `.trialExpired`; asserts `.pro` + `!isProLocked`
|
||||
+ `isProAvailable`. This is the discriminating test — it goes RED on a re-armed paywall.
|
||||
|
||||
**Both pass** (see §2). They use the existing `MockClock`/`MockKeychain`/`MockLicenseAPI`
|
||||
fixtures and never reference `LicenseManager.shared`, `Endpoints.*`, or `ProFeature.*` (the four
|
||||
traps documented in PLAN §4.4).
|
||||
|
||||
### 1.3 Scaffolding (committed in THIS pass — `3dde8a91`)
|
||||
|
||||
`git commit -m "chore: identity overlay + sync/guard/release CI scaffolding (placeholders flagged for owner)"`
|
||||
— 15 files, +681/-3891. The agents wrote these but had not committed; this pass committed them.
|
||||
|
||||
**Identity overlay (fork-owned, last-include-wins; zero edits to `base.xcconfig`/`project.pbxproj`):**
|
||||
- `config/local.xcconfig` (NEW) — `PRODUCT_NAME=AltTabFree`, `PRODUCT_BUNDLE_IDENTIFIER=io.github.koftwentytwo.alt-tab-free`, `DOMAIN=fork.invalid`, `API_DOMAIN=fork.invalid/api`, `APPCENTER_SECRET=` (empty), `CODE_SIGN_IDENTITY` left commented (TODO). All values are PLACEHOLDERS flagged for the owner.
|
||||
- `Info.plist` — `Domain`/`ApiDomain` now wired to `$(DOMAIN)`/`$(API_DOMAIN)`; `SUPublicEDKey` = `REPLACE_WITH_FORK_SUPUBLIC_EDKEY` placeholder.
|
||||
- `src/api/Endpoints.swift` — reads `Domain`/`ApiDomain` from the bundle (build-time, fork-owned), so every license/feedback/appcast URL derives from the fork's own hosts, never upstream `alt-tab.app`.
|
||||
- `package.json` — `license` set to `GPL-3.0`, author cleared.
|
||||
|
||||
**GPL attribution docs:** `README.md` (modified), `NOTICE.md` (NEW) — GPL §5(a) statement of
|
||||
changes; currently references placeholder identity (`AltTabFree`, the placeholder bundle id, the
|
||||
placeholder GitHub URL) and upstream `v11.3.0` / `2026-06-10`.
|
||||
|
||||
**Upstream-sync + guard CI:** `.github/workflows/upstream_sync.yml` (NEW), `.github/workflows/guard.yml`
|
||||
(NEW), `.fork-sync-state` (NEW, `v11.3.0`). `guard.yml` runs Guard A (anti-relock test on `macos-15`,
|
||||
Xcode pinned to `26.0.1`), Guard B (depaywall marker grep), Guard C (conflict-marker grep) on
|
||||
`pull_request`.
|
||||
|
||||
**Release pipeline:** `.github/workflows/ci_cd.yml` (re-architected: `pull_request` trigger added,
|
||||
release-side steps gated, Guard B/C greps inserted FIRST in the push job),
|
||||
`scripts/update_appcast.sh`, `scripts/replace_environment_variables_in_app.sh`, `release.config.js`,
|
||||
`appcast.xml` (trimmed), `.github/FUNDING.yml`, `.gitignore`.
|
||||
|
||||
All three workflow YAMLs validate (`yaml.safe_load` OK).
|
||||
|
||||
---
|
||||
|
||||
## 2. Verified results
|
||||
|
||||
### 2.1 Build — full tree compiles (PASS)
|
||||
|
||||
`bash ai/build.sh` (Debug) FAILS in this environment with exactly one error:
|
||||
|
||||
```
|
||||
error: No certificate matching 'Local Self-Signed' found … (target 'alt-tab-macos')
|
||||
```
|
||||
|
||||
This is `config/debug.xcconfig:5` (`CODE_SIGN_IDENTITY = Local Self-Signed`) requiring a
|
||||
self-signed cert that is **not present in this CI/dev keychain** (`security find-identity -v
|
||||
-p codesigning` → `0 valid identities found`). It is a pre-existing ENVIRONMENT prerequisite,
|
||||
unrelated to the depaywall patch or scaffolding (the scaffolding touches no Debug signing config).
|
||||
|
||||
Re-running with signing disabled isolates compilation:
|
||||
|
||||
```
|
||||
xcodebuild -project alt-tab-macos.xcodeproj -scheme Debug -configuration Debug \
|
||||
-derivedDataPath DerivedData \
|
||||
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
|
||||
→ ** BUILD SUCCEEDED ** (exit 0; 0 errors; 218 Swift compile actions)
|
||||
```
|
||||
|
||||
**The full source tree compiles clean after all scaffolding.** No scaffolding file needed a fix,
|
||||
so the commit was not amended. The default `ai/build.sh` will succeed once the owner provisions a
|
||||
signing identity (see checklist).
|
||||
|
||||
### 2.2 Tests — GREEN (anti-relock guards PASS; 16 legacy paywall tests skipped — §2.3)
|
||||
|
||||
`xcodebuild test -scheme Test -configuration Debug` (signing disabled by `config/test-base.xcconfig`):
|
||||
|
||||
- **477 tests pass.**
|
||||
- The two anti-relock guards **PASS**:
|
||||
- `testDepaywallProNeverLocked` — passed (0.001s)
|
||||
- `testDepaywallStillProAfterTrialExpiry` — passed (0.001s)
|
||||
- **16 legacy paywall-behavior tests in `LicenseManagerTests` were RED** (they assert the now-dead
|
||||
trial/expiry/lock/activate behavior). **RESOLVED in commit `1a914c88`** — each is now `XCTSkip`-marked
|
||||
with an `alt-tab-free [depaywall]` reason. Zero failures outside `LicenseManagerTests`; zero attributable
|
||||
to scaffolding.
|
||||
|
||||
**Overall Test scheme result: GREEN** (`** TEST SUCCEEDED **`) — 493 executed, **16 skipped, 0 failures**;
|
||||
both `testDepaywall*` anti-relock guards RUN and PASS.
|
||||
|
||||
### 2.3 RESOLVED (commit `1a914c88`) — 16 legacy paywall tests contradicted the patch
|
||||
|
||||
> **RESOLVED 2026-06-10:** all 16 are now `XCTSkip`-marked (`alt-tab-free [depaywall]`), so the `Test`
|
||||
> scheme is GREEN and the two `testDepaywall*` guards remain the active anti-relock signal. The analysis
|
||||
> below is retained for the record.
|
||||
|
||||
The depaywall patch forces `computeState()` to always return `.pro`. Sixteen pre-existing tests in
|
||||
`src/pro/license/LicenseManagerTests.swift` still assert the now-dead trial/expiry/lock behavior and
|
||||
therefore fail. They were introduced-as-failing by the depaywall patch commit (`f3d76859`), NOT by
|
||||
this integration pass's scaffolding. The failing tests:
|
||||
|
||||
```
|
||||
testFirstLaunchStartsTrial testTrialMidway
|
||||
testSecondLaunchPreservesTrialStart testTrialLastDay
|
||||
testTrialExpiresOnDay14 testTrialExpiresWellPastDuration
|
||||
testPreviouslyInvalidatedLicenseIsTrialExpired testLicenseWithoutValidationResultIsTrialExpired
|
||||
testIsProLockedTrueAfterTrialExpiry testIsProLockedTrueWhenKeychainInvalidated
|
||||
testOnStateChangedFiresOnInitialize testOnBeforeProUnlockFiresBeforeStateFlipsToPro
|
||||
testActivateFailurePreservesState testActivateSeatLimitExceededSurfacesInstances
|
||||
testActivateFailsAndRollsBackIfKeychainWriteFails testActivateRollsBackPartialKeychainWritesOnLaterFailure
|
||||
```
|
||||
|
||||
Root cause (two clusters, both downstream of the forced `.pro`):
|
||||
1. **initialize-path tests** assert `.trial(...)` / `.trialExpired` after `initialize()`, which now
|
||||
always yields `.pro`.
|
||||
2. **activate-failure-path tests** assert state stays `.trial` after a rejected/rolled-back
|
||||
`activate`, but the rollback re-reads `state = computeState()` → `.pro`.
|
||||
|
||||
**Why this is BLOCKING and was NOT silently "fixed" here:** PLAN §4.4 makes `run_tests.sh` (the whole
|
||||
`Test` scheme) the Guard A release gate, wired into both `ci_cd.yml` (push job) and `guard.yml`
|
||||
(PR). A permanently-RED Test scheme means **Guard A can never go green → no release can ever ship,
|
||||
and the genuine anti-relock signal is drowned out by 16 expected failures.** Neutralizing 16 tests is
|
||||
a substantive source change to a hot test file (re-conflicts on every upstream sync that touches it)
|
||||
and is outside the "fix the offending *scaffolding* file" / "do NOT deviate from strategy" mandate of
|
||||
this integration pass, so it is flagged for an explicit owner/patch decision rather than done
|
||||
unilaterally. **Recommended disposition** (consistent with the depaywall strategy — neutralize, don't
|
||||
delete): wrap each failing assertion's body in `if false { … }` with an `alt-tab-free [depaywall]`
|
||||
marker and replace it with an `XCTAssertEqual(manager.state, .pro)` assertion, exactly mirroring how
|
||||
the production code was neutralized. This keeps the merge surface minimal and the test file's
|
||||
membership/pbxproj untouched. (Alternatively, delete the 16 methods — simpler diff, but loses the
|
||||
documentation of the dead behavior.)
|
||||
|
||||
### 2.4 Gate proofs — the paywall is dead (verified by grep + reachability)
|
||||
|
||||
| Plan requirement | Proof |
|
||||
|---|---|
|
||||
| (a) `computeState()` returns `.pro` with `depaywall` marker | `LicenseManager.swift:177` — `guard false else { return .pro } // depaywall: …` |
|
||||
| (b) no reachable POST to `alt-tab.app/api` | Both POST sites (`RemoteLicenseClient.swift:105`, `FeedbackWindow.swift:446`) target `Endpoints.apiDomain`, which is read from Info.plist `ApiDomain` (fork-owned, build-time — currently `fork.invalid/api`), NEVER upstream `alt-tab.app`. The only two literal `alt-tab.app` strings in the tree are doc comments, not live code. The license-activate POST's auto-reachable entry point (`App.swift:474` `handleCustomUrl`) early-returns via `guard false`. Its only other caller (`UpgradeTab.activateLicense`) requires a human to paste a key into the upsell UI and would hit the fork's own backend, not upstream. |
|
||||
| (c) `syncLicenseCookie` + `AppCenterCrash.init` wrapped in `if false` | `LicenseCookie.swift:6` — `if false { // depaywall: …`; `AppCenterCrashes.swift:10` — `if false { // depaywall: …` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Owner publish checklist — only you can do these
|
||||
|
||||
These require human judgment, secrets, or external accounts. Items marked **BLOCKING** must be done
|
||||
or the fork will not notarize, auto-update, gate releases, or stop phoning home. Grouped, with the
|
||||
per-component owner actions gathered from the scaffolding agents merged in.
|
||||
|
||||
### 3.0 ✅ DONE (commit `1a914c88`) — Test scheme is GREEN
|
||||
|
||||
- [x] **Resolved the 16 RED legacy paywall tests** in `src/pro/license/LicenseManagerTests.swift` —
|
||||
each is now `XCTSkip`-marked (`if true { throw XCTSkip("alt-tab-free [depaywall]: …") }`; the bare
|
||||
`throw` form is rejected by `SWIFT_TREAT_WARNINGS_AS_ERRORS`). The `Test` scheme is now GREEN
|
||||
(493 executed, 16 skipped, 0 failures); the two `testDepaywall*` anti-relock guards still RUN and
|
||||
PASS. No owner action remains here.
|
||||
|
||||
### 3.1 Brand / identity / trademark (BLOCKING for a clean publish)
|
||||
|
||||
- [x] **Fork name + brand sweep + l10n (DONE 2026-06-15)** — name is **CommandTabFree** (trademark-distinct);
|
||||
brand sweep done; l10n rebranded across base/en + all ~20 language tables, upstream attribution kept.
|
||||
- [x] **`PRODUCT_NAME = CommandTabFree` (DONE)** in `config/local.xcconfig`. Verify `ci_cd.yml` `APP_NAME`
|
||||
matches when CI is wired.
|
||||
- [x] **Fork bundle id FROZEN (DONE)** — `com.koftwentytwo.commandtabfree` (option (a) fresh id) in `config/local.xcconfig`.
|
||||
- [x] **`README.md` + `NOTICE.md` (DONE)** — final product name, real fork GitHub URL; README now includes the brew install.
|
||||
- [ ] **At release time, set `NOTICE.md`'s upstream version + modification date** to the actual sync
|
||||
point (currently `v11.3.0` / `2026-06-10`). Keep `NOTICE.md` current per PLAN §6.2 on every future
|
||||
sync that changes the fork's modification surface. **Note:** `NOTICE.md` lists
|
||||
"App.handleCustomUrl (activate disabled)" as a change — the §2 Edit-2 neutralization HAS landed
|
||||
(`App.swift:474`), so that line is accurate; if you ever opt NOT to neutralize it, remove that line.
|
||||
- [x] **App icon DONE (owner-approved 2026-06-15)** — the ⌘⇥ glyph (app + menu bar) is the chosen mark, not lwouis's mark. No replacement planned.
|
||||
- [ ] **Add `config/local.xcconfig` and `package.json` to the PLAN §6.2 "keep ours" recurring-conflict
|
||||
set** so a future merge can't re-introduce upstream identity or the MIT license string.
|
||||
|
||||
### 3.2 Hosts / domain (BLOCKING for update + feedback)
|
||||
|
||||
- [ ] **Set `DOMAIN` and `API_DOMAIN` in `config/local.xcconfig`** to fork-owned, routable hosts
|
||||
(currently `fork.invalid` / `fork.invalid/api` placeholders). `DOMAIN` must serve
|
||||
`https://<DOMAIN>/appcast.xml` and `/support`; `API_DOMAIN` drives the feedback POST. Decide the
|
||||
§3.4 feedback strategy. The `activate` fingerprint POST is closed independently at the handler
|
||||
(§2 Edit 2), so `API_DOMAIN` may be routable for feedback WITHOUT re-arming exfiltration.
|
||||
- [ ] **Stand up the appcast/feed host:** point `DOMAIN` at the gh-pages site that serves
|
||||
`/appcast.xml` (or CNAME it). `Endpoints.appcastUrl = https://$DOMAIN/appcast.xml`.
|
||||
- [ ] **Remove `appcast.xml` from the tree** (`git rm appcast.xml`) and add `/appcast.xml` to
|
||||
`.gitignore` — the feed is served out-of-tree from gh-pages. (Coordinate: `.gitignore` is owned by
|
||||
the identity overlay.)
|
||||
|
||||
### 3.3 Code signing + Sparkle keys (BLOCKING for notarization + auto-update)
|
||||
|
||||
- [ ] **Obtain an Apple Developer ID Application certificate** for the fork and set
|
||||
`CODE_SIGN_IDENTITY` in `config/local.xcconfig` (currently a commented TODO). Required for
|
||||
notarization/Gatekeeper. Also re-sign the vendored Sparkle helpers under the SAME Developer ID
|
||||
(PLAN §3.3 / §4.6) or notarization rejects. (This is also what makes the default `ai/build.sh`
|
||||
stop failing on the missing `Local Self-Signed` cert.)
|
||||
- [x] **Sparkle EdDSA keypair generated (2026-06-15).** PUBLIC key wired into `Info.plist`
|
||||
(`SUPublicEDKey`); PRIVATE key stored in 1Password "CommandTabFree-Sparkle-EdDSA" + GitHub
|
||||
Actions secret `SPARKLE_ED_PRIVATE_KEY` (env `production`); keychain account `commandtabfree`.
|
||||
`SUEnableAutomaticChecks` set false until DOMAIN serves a real appcast.
|
||||
**TODO at CI-wiring time:** `update_appcast.sh` still calls the deprecated `sign_update -s` —
|
||||
switch it to `--ed-key-file` (or `--account commandtabfree`); the `-s` path rejects this key format.
|
||||
|
||||
### 3.4 GitHub Actions secrets + Environment (BLOCKING for release)
|
||||
|
||||
- [ ] **Create the GitHub Environment named `production`** (matches `ci_cd.yml` `environment:`) with NO
|
||||
required-reviewer protection that would stall releases.
|
||||
- [ ] **Set these secrets (repo + the `production` Environment):** `APPLE_P12_CERTIFICATE` (fork's
|
||||
Developer ID), `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`, `SPARKLE_ED_PRIVATE_KEY` (fork's NEW
|
||||
EdDSA private key), and `SYNC_BOT_TOKEN`.
|
||||
- [ ] **Provision `SYNC_BOT_TOKEN` (BLOCKING, PLAN §4.2):** a GitHub App installation token (preferred)
|
||||
or a fork-owned PAT with contents + pull-requests write. **The default `GITHUB_TOKEN` will NOT
|
||||
work** — a PR it authors fires no `pull_request` checks, so `guard.yml` / `ci_cd.yml` never run on
|
||||
the sync PR. Add it to the §4.6 credential-lifecycle bucket (it expires; can push to master if
|
||||
leaked).
|
||||
|
||||
### 3.5 Upstream-sync wiring (BLOCKING for the perpetual-republish promise)
|
||||
|
||||
- [ ] **Create the three PR labels at bootstrap (§6.1):** `gh label create sync`,
|
||||
`gh label create conflict`, `gh label create chokepoint-refresh`. `upstream_sync.yml` fails closed
|
||||
if any is missing.
|
||||
- [ ] **Set up the EXTERNAL scheduler (§4.2 recommended primary)** hitting `repository_dispatch` (event
|
||||
type `upstream-sync`) carrying `SYNC_BOT_TOKEN` — immune to GitHub's 60-day auto-disable of in-repo
|
||||
cron. The in-repo cron is belt-and-suspenders only.
|
||||
- [ ] **`.fork-sync-state` writer (contract owned by `ci_cd.yml`):** add the push-job step that advances
|
||||
`.fork-sync-state` to the merged tag on merge to master — without it the cron re-opens the same tag
|
||||
forever. (This file is the contract; the writer lives in `ci_cd.yml`.)
|
||||
- [ ] **Add the two-trigger liveness alert (§4.2):** notify if no `sync/*` PR in ~45 days OR oldest open
|
||||
`sync/*` PR > ~14 days (the workflow emits a `stalled` output flag for this).
|
||||
- [ ] **Re-pin `guard.yml`'s Xcode line** (currently `Xcode_26.0.1`) whenever an upstream sync bumps
|
||||
`ci_cd.yml`'s pinned Xcode (§4.5 / §6.2).
|
||||
|
||||
### 3.6 Commit/changelog plumbing (needed for CI to pass on sync PRs)
|
||||
|
||||
- [ ] **Add the `commitlint.config.js` `ignores` predicate (§4.3)** to drop the synthetic
|
||||
`chore(sync): merge upstream` commit and upstream's non-conforming messages — `ci_cd.yml`'s
|
||||
commitlint steps depend on it.
|
||||
- [ ] **Inject the GPL §6(d) corresponding-source pointer into the GitHub Release body**
|
||||
(`scripts/extract_latest_changelog.sh`). `update_appcast.sh` already covers the Sparkle channel;
|
||||
the GitHub Release channel still needs it (PLAN §5(1)).
|
||||
- [ ] **Consider a source tarball release asset and/or a takedown-contingency mirror** so GPL §6 source
|
||||
survives a repo takedown (PLAN §5(1) conveyance durability).
|
||||
|
||||
### 3.7 Bootstrap acceptance tests (do before declaring "published")
|
||||
|
||||
- [ ] **§4.2 first-sync acceptance:** open a sync PR THROUGH the bot and confirm `guard.yml` +
|
||||
`ci_cd.yml` `pull_request` checks actually appear and run. A check-less bot PR means
|
||||
`SYNC_BOT_TOKEN` is mis-set.
|
||||
- [ ] **§4.4 relock acceptance:** push a branch reverting the `computeState() → .pro` flip and confirm
|
||||
`guard.yml` goes RED (Guard A test on `macos-15`, and/or the Guard B marker grep) before merge.
|
||||
- [ ] **§6.1 fork-to-fork auto-update acceptance:** confirm a user on fork build N actually receives
|
||||
build N+1 via Sparkle — enclosure URL resolves to the fork's release, the EdDSA signature verifies
|
||||
against the embedded fork public key, `SUVersion` ordering is monotonic, and Gatekeeper is the only
|
||||
first-run friction (PLAN §3.6.1).
|
||||
|
||||
---
|
||||
|
||||
## 4. Commit log on `depaywall-free`
|
||||
|
||||
```
|
||||
3dde8a91 chore: identity overlay + sync/guard/release CI scaffolding (placeholders flagged for owner) [this pass]
|
||||
4506c539 test: anti-relock guards (pro never locked, incl. post-trial-expiry)
|
||||
f3d76859 depaywall: neutralize license gate, cookie, telemetry, and activate handler (src/pro left intact)
|
||||
e243fb4b docs: audit + republish/maintained-fork plans
|
||||
9fadf36b chore(release): 11.3.0 [skip ci] (upstream sync point)
|
||||
```
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
# alt-tab-free — System Graphs
|
||||
|
||||
Visual companion to the paywall-removal audit (`docs/audit/01..09`). Five Mermaid diagrams:
|
||||
overall architecture, license activation/state flow, the Day1→Day35 trial-nag escalation, the
|
||||
feature-gating downgrade/restore mechanism, and the removal map (delete vs edit vs keep).
|
||||
|
||||
All `file:line` references are against branch `master`, commit `9fadf36b`. Diagrams use only simple
|
||||
ASCII node labels so they render in any Mermaid viewer.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overall application architecture & subsystem relationships
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
main["src/main.swift<br/>CLI fast-path, signal/exception handlers<br/>(PAYWALL-FREE)"]
|
||||
main -->|App.shared.run| app["src/App.swift<br/>App : AppCenterApplication<br/>NSApplicationDelegate"]
|
||||
|
||||
app -->|phase A| phaseA["applicationDidFinishLaunching<br/>App.swift:435-463<br/>logging, Preferences, license wiring,<br/>permission-poll timer"]
|
||||
phaseA -->|polls until granted| perms["src/macos/SystemPermissions<br/>Accessibility + Screen Recording"]
|
||||
perms -->|"continueAppLaunchAfterPermissionsAreGranted (SystemPermissions.swift:77)"| phaseB["phase B<br/>App.swift:383-431<br/>threads, panels, menubar,<br/>hooks, Sparkle"]
|
||||
|
||||
phaseB --> switcher["Switcher UI<br/>src/switcher/<br/>TilesPanel, PreviewPanel, Windows"]
|
||||
phaseB --> prefs["Preferences + Settings<br/>src/preferences/"]
|
||||
phaseB --> events["Event hooks<br/>src/events/, KeyboardEvents,<br/>AccessibilityEvents, RunningApps"]
|
||||
phaseB --> macos["src/macos/<br/>AXCallScheduler, LoginItem<br/>(PAYWALL-FREE)"]
|
||||
phaseB --> bg["BackgroundWork<br/>run-loop threads + queues<br/>(PAYWALL-FREE)"]
|
||||
phaseB --> menubar["src/Menubar.swift<br/>status item + menu"]
|
||||
|
||||
app -. license callbacks .-> pro["src/pro/ PAYWALL OVERLAY"]
|
||||
menubar -. Get Pro / badge dot .-> pro
|
||||
switcher -. ProFeature.attemptUse .-> pro
|
||||
prefs -. PreferenceDefinition gating .-> pro
|
||||
|
||||
subgraph pro["src/pro/ PAYWALL OVERLAY"]
|
||||
lic["license/<br/>LicenseManager, RemoteLicenseClient,<br/>Keychain, MachineFingerprint"]
|
||||
sched["scheduling/<br/>ProTransitionManager + Scheduler,<br/>Day1..Day35 windows"]
|
||||
feat["ProFeature.swift<br/>capability registry"]
|
||||
ui["ui/<br/>ProPromptHost, ProBadgeView,<br/>ProGradientButton, UsageStatHeroView"]
|
||||
feat --> sched
|
||||
sched --> ui
|
||||
lic --> sched
|
||||
end
|
||||
|
||||
api["src/api/Endpoints.swift"] -.-> lic
|
||||
sparkle["Sparkle auto-update<br/>SparkleDelegate"] --> phaseB
|
||||
appcenter["AppCenter crash reporting<br/>src/vendors/"] -.-> app
|
||||
```
|
||||
|
||||
The app boots from a plain top-level `src/main.swift` (no `@main`), which calls `App.shared.run()`.
|
||||
`App.swift` is the spine and uses a deliberate two-phase launch: phase A wires logging, preferences,
|
||||
the license callbacks, and a permission-poll timer; only once Accessibility + Screen-Recording
|
||||
permissions pass does `SystemPermissions` call back into phase B, which stands up every subsystem and
|
||||
installs the macOS hooks. The switcher, events, `src/macos/`, and `BackgroundWork` are entirely
|
||||
paywall-free; the paywall (`src/pro/`) is grafted on through callbacks wired in `App.swift` and
|
||||
`Menubar.swift`, plus a handful of gate call-sites in the switcher and preferences. The dotted arrows
|
||||
are the only couplings a removal must sever.
|
||||
|
||||
---
|
||||
|
||||
## 2. License activation + state flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant App as App.swift
|
||||
participant LM as LicenseManager.shared
|
||||
participant API as RemoteLicenseClient
|
||||
participant Remote as alt-tab.app/api/v1/license
|
||||
participant KC as Keychain + UserDefaults
|
||||
participant UI as Menubar / Settings / Gating
|
||||
|
||||
Note over App,LM: App launch (phase A)
|
||||
App->>LM: initialize() (App.swift:460)
|
||||
LM->>LM: computeState()<br/>seeds trialStartDate on first launch
|
||||
LM-->>UI: onStateChanged(state) fan-out (App.swift:449-459)
|
||||
Note right of UI: refreshLicenseMenuItems, syncLicenseCookie,<br/>ProTransitionManager.onLicenseStateChanged,<br/>UpgradeTab.refreshStatus, proLockStateDidChange
|
||||
|
||||
Note over User,App: Activation via alt-tab://activate?license_key=...
|
||||
User->>App: open URL (App.swift:473-485)
|
||||
App->>LM: activate(licenseKey)
|
||||
LM->>API: api.activate(key)
|
||||
API->>Remote: POST /activate<br/>{license_key, fingerprint, trial_started_at}
|
||||
Remote-->>API: {activated, instance_id, variant_id, customer_email}
|
||||
API-->>LM: ActivateResult
|
||||
|
||||
alt activated == true
|
||||
LM->>KC: all-or-nothing write<br/>licenseKey, instanceId, variantId<br/>(rollback on any failure)
|
||||
LM->>KC: lastValidation, lastValidationResult=true, customerEmail
|
||||
LM->>LM: onBeforeProUnlock() then state = .pro
|
||||
LM-->>UI: onStateChanged(.pro) (gates open)
|
||||
else invalid_key / seat_limit_exceeded / rejected
|
||||
LM-->>UI: LicenseAPIError (state untouched)
|
||||
end
|
||||
|
||||
Note over LM,Remote: Background revalidation throttled to 30 days
|
||||
LM->>API: revalidateWithServer() (if elapsed >= 30d)
|
||||
API->>Remote: POST /validate {license_key, instance_id}
|
||||
Remote-->>LM: {valid} -> .pro or .trialExpired
|
||||
```
|
||||
|
||||
`LicenseManager.shared` is the single source of truth for entitlement and computes a flat 4-case
|
||||
`LicenseState` (`.trial`, `.pro`, `.proExpired`, `.trialExpired`). At launch `initialize()` computes
|
||||
state (seeding a 14-day `trialStartDate` on first run) and fans out through `onStateChanged`.
|
||||
Activation arrives via the `alt-tab://activate` URL scheme (or the Settings Upgrade tab), POSTs to
|
||||
`alt-tab.app/api/v1/license/activate` with the machine fingerprint, and on success performs an
|
||||
all-or-nothing Keychain write (with rollback) before flipping state to `.pro`. A throttled 30-day
|
||||
background revalidation re-checks the license. The whole remote/Keychain layer feeds exactly one
|
||||
downstream signal the rest of the app consumes: `isProLocked` / `isProAvailable`.
|
||||
|
||||
---
|
||||
|
||||
## 3. The Day1 -> Day35 trial-nag escalation
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Day1
|
||||
note right of Day1
|
||||
SOFT NAGS = informational, never block.
|
||||
Each fires once, gated by a hasSeen* flag.
|
||||
Prompts only fire 10:00-11:30 or 15:30-17:00.
|
||||
end note
|
||||
|
||||
Day1: Day 1 - [A] Welcome Letter (SOFT)
|
||||
Day1: Fires immediately on first launch; blocks others until seen
|
||||
Day4: Day 4 - [H] Tour Popover (SOFT)
|
||||
Day4: Only if switcher opened on Day 4 exactly
|
||||
Silent: Days 5-11 silent trial
|
||||
Day12: Day 12 - [B] Heads-Up Popover (SOFT)
|
||||
Day12: "Trial ends in 2 days"; skipped on Day 13+
|
||||
Badge: Days 13-14 - menubar badge dot (SOFT)
|
||||
|
||||
Expiry: Day 15 - trial expires -> HARD GATE ARMS
|
||||
Expiry: onProLockEngaged downgrades degradable prefs to Free,<br/>snapshots Pro values into remembered*
|
||||
|
||||
Proactive: Day 15+ - [D] Proactive Window (SOFT)
|
||||
Proactive: Only if no hard gate fired yet
|
||||
FreePass: First post-expiry switcher open -> one-shot Free Pass
|
||||
FullUpgrade: [C] Full Upgrade Window (HARD - lock-in)
|
||||
FullUpgrade: Sets hasSeenFullUpgrade; once-ever
|
||||
HardGate: [E] Hard-Gate Popover (HARD)
|
||||
HardGate: Every blocked feature attempt after [C], forever
|
||||
Day21: Day 21-34 - [F] Reminder Popover (SOFT)
|
||||
Day35: Day 35-48 - [G] Final Window (SOFT)
|
||||
Day35: Has "No thanks - don't ask again" opt-out
|
||||
Steady: Day 49+ - steady state
|
||||
Steady: [E] on every hard-gate attempt; degradable prefs fall back
|
||||
|
||||
Day1 --> Day4
|
||||
Day4 --> Silent
|
||||
Silent --> Day12
|
||||
Day12 --> Badge
|
||||
Badge --> Expiry
|
||||
Expiry --> Proactive
|
||||
Expiry --> FreePass
|
||||
FreePass --> FullUpgrade
|
||||
Proactive --> FullUpgrade
|
||||
FullUpgrade --> HardGate
|
||||
Expiry --> Day21
|
||||
Day21 --> Day35
|
||||
Day35 --> Steady
|
||||
HardGate --> Steady
|
||||
Steady --> [*]
|
||||
FullUpgrade --> Purchased
|
||||
Purchased: Purchase -> .pro
|
||||
Purchased: Scheduler cancelled, all windows dismissed, prefs restored
|
||||
Purchased --> [*]
|
||||
```
|
||||
|
||||
The trial-nag system (`src/pro/scheduling/`) escalates upsell prompts across a Day1→Day35 timeline,
|
||||
driven by a pure decision struct (`ProTransitionManagerTestable`) and a persisted scheduler. The
|
||||
**soft nags** ([A] Welcome, [H] Day4 Tour, [B] Day12 Heads-Up, [D] Day15 Proactive, [F] Day21,
|
||||
[G] Day35, plus the Days13-14 badge dot) are purely informational and never block functionality. The
|
||||
**hard gate** arms at Day 15 expiry: degradable preferences are immediately downgraded to their free
|
||||
equivalents, and the three hard-gated features (extra shortcut slot, search, lock-search) are blocked
|
||||
at use-time through a free-pass ladder that grants one last full session, then shows [C] Full Upgrade
|
||||
once, then [E] Hard-Gate popover forever. Only a purchase clears the gate (cancelling the scheduler,
|
||||
dismissing windows, and restoring remembered Pro prefs).
|
||||
|
||||
---
|
||||
|
||||
## 4. The feature-gating mechanism (downgrade on lock / restore on unlock)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
state["LicenseManager.state"] --> locked{"isProLocked?<br/>LicenseManager.swift:67-72<br/>(true for proExpired/trialExpired)"}
|
||||
|
||||
locked -->|"becomes true (lock)"| engage["ProTransitionState.onProLockEngaged()<br/>ProTransitionState.swift:73-79"]
|
||||
engage --> snap["For each of 6 ProGatedPreferences:<br/>snapshot Pro index -> remembered* key,<br/>overwrite stored value with freeEquivalent"]
|
||||
snap --> stored1["UserDefaults stored = Free value<br/>(style=thumbnails, size=medium,<br/>shortcutStyle=doNothingOnRelease)"]
|
||||
|
||||
locked -->|"becomes false (unlock/purchase)"| unlock["ProTransitionState.onProUnlocked()<br/>ProTransitionState.swift:85-92"]
|
||||
unlock --> restore["Restore remembered* Pro index<br/>-> stored value (notify:false),<br/>clear remembered key"]
|
||||
restore --> stored2["UserDefaults stored = Pro value"]
|
||||
|
||||
stored1 --> read["PreferenceDefinition.read()<br/>PreferenceDefinition.swift:32-44"]
|
||||
stored2 --> read
|
||||
read --> freepass{"isFreePassSessionActive?"}
|
||||
freepass -->|yes| pro["return remembered Pro value<br/>(one-session preview)"]
|
||||
freepass -->|"no + locked + stored still Pro"| free["return freeEquivalent"]
|
||||
freepass -->|no| asis["return stored value"]
|
||||
|
||||
hard["Hard-gated features<br/>extraShortcut, search, lockSearch"] --> attempt["ProFeature.attemptUse()<br/>ProFeature.swift:74-83"]
|
||||
attempt --> avail{"isProAvailable?"}
|
||||
avail -->|yes| allow["return true (run)"]
|
||||
avail -->|"no (expired)"| ladder["ProTransitionManager.attemptHardGatedFeature<br/>free-pass -> [C] -> [E] ladder"]
|
||||
|
||||
pro --> getters["Preferences.effective* getters<br/>(appearanceStyle/Size, shortcutStyle)"]
|
||||
free --> getters
|
||||
asis --> getters
|
||||
getters --> switcher["TilesView / ShortcutAction render"]
|
||||
```
|
||||
|
||||
The master gate is `LicenseManager.isProLocked` (true once trial or Pro expires). On the
|
||||
false→true transition, `onProLockEngaged()` snapshots each of the six gated preferences' Pro index
|
||||
into a `remembered*` key and overwrites the stored value with its free equivalent; on unlock,
|
||||
`onProUnlocked()` restores the remembered Pro value (with `notify:false` to avoid bouncing). The
|
||||
hot-path getter `PreferenceDefinition.read()` returns the remembered Pro value during an active
|
||||
free-pass session, the free equivalent while locked-and-still-Pro, or the stored value otherwise.
|
||||
Hard-gated runtime features have no backing preference and are gated at use-time by
|
||||
`ProFeature.attemptUse()`. Crucially, the registered defaults already contain the Pro values, so
|
||||
forcing `isProLocked = false` auto-unlocks every degradable feature without any default changes.
|
||||
|
||||
---
|
||||
|
||||
## 5. The REMOVAL MAP — delete vs edit vs keep
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph DELETE["DELETE WHOLESALE - the paywall tree"]
|
||||
direction TB
|
||||
d1["src/pro/license/*<br/>LicenseManager, RemoteLicenseClient,<br/>Keychain, MachineFingerprint, LicenseCookie,<br/>LicenseAPI, Clock"]
|
||||
d2["src/pro/scheduling/*<br/>ProTransitionManager + Scheduler + State + Testable,<br/>all Day1..Day35 windows/popovers"]
|
||||
d3["src/pro/ProFeature.swift<br/>ProFeatureCopy.swift, ProConversionCopy.swift"]
|
||||
d4["src/pro/ui/*<br/>ProPromptHost, ProGradientButton,<br/>UsageStatHeroView (ProBadgeView/ProGradient<br/>only AFTER Settings consumers gone)"]
|
||||
d5["src/preferences/PreferenceDefinition.swift<br/>(entire gating engine)"]
|
||||
d6["Tests: LicenseManagerTests, ProTransitionTests,<br/>ProBadgeViewSegmentTests, UsageStatsMessageTests"]
|
||||
end
|
||||
|
||||
subgraph EDIT["EDIT IN PLACE - sever wiring, keep file"]
|
||||
direction TB
|
||||
e1["src/App.swift<br/>remove license block 448-460, onAction/onAppLaunchComplete<br/>428-429, switcher hooks 78/328, custom-URL handler 465-490,<br/>Day1Welcome first-launch entanglement 189-233"]
|
||||
e2["src/Menubar.swift<br/>remove Get Pro/My Account items 46-50,<br/>UpgradeMenuItemView 235-390, refreshLicenseMenuItems 84-118,<br/>badge dot 202-227, refreshState in menuWillOpen 396"]
|
||||
e3["src/preferences/Preferences.swift<br/>rewrite gated getters 129/130/155 +<br/>override getters 313/319/330 to read raw values"]
|
||||
e4["src/switcher/ShortcutAction.swift:56<br/>src/switcher/main-window/TilesView.swift:79,92<br/>remove attemptUse gates"]
|
||||
e5["src/events/PreferencesEvents.swift:62-64<br/>remove isProLocked/isStoredValuePro bounce"]
|
||||
e6["Settings tabs: AppearanceTab, ControlsTab,<br/>ShortcutEditor, ShortcutsWhenActiveSheet,<br/>LabelAndControl - strip proGatedIndices + ProBadge"]
|
||||
e7["SettingsWindow.swift - remove UpgradeButton 133-214,<br/>showUpgradeView/hideUpgradeView, refreshUpgradeButton,<br/>windowDidBecomeKey license refresh; SidebarList setProBadge"]
|
||||
e8["UpgradeTab.swift - DELETE tab; remove navigateToUpgradeTab callers"]
|
||||
e9["PreferencesMigrations.swift:20<br/>remove markFreshInstallIfUnknown call"]
|
||||
e10["DebugProfile.swift:17 - drop .debugProfileLabel line<br/>(keep file; feeds feedback)"]
|
||||
e11["QAMenu.swift:170-241<br/>remove DEBUG Day-X triggers/resets"]
|
||||
e12["alt-tab-macos-Bridging-Header.h + Info.plist<br/>NSPrincipalClass + alttab:// URL scheme<br/>(coordinate with AppCenter/identity decision)"]
|
||||
e13["alt-tab-macos.xcodeproj/project.pbxproj<br/>remove ~102 in-Sources entries + fileRef/group<br/>for every deleted src/pro file (app + test UUIDs)"]
|
||||
end
|
||||
|
||||
subgraph KEEP["KEEP UNTOUCHED - paywall-free"]
|
||||
direction TB
|
||||
k1["src/main.swift (entry point)"]
|
||||
k2["src/macos/* (SystemPermissions,<br/>AXCallScheduler, LoginItem)"]
|
||||
k3["BackgroundWork (threads/queues)"]
|
||||
k4["src/switcher kernels (SelectionResolver,<br/>WindowFilterResolver, etc.) + Windows/Window"]
|
||||
k5["src/events hooks (Keyboard, Accessibility,<br/>RunningApplications) minus the few gate lines"]
|
||||
k6["CachedUserDefaults / index / macroPref plumbing"]
|
||||
k7["Sparkle wiring (repoint feed/key separately)"]
|
||||
end
|
||||
|
||||
DELETE -. "deleting these breaks compile until EDIT done" .-> EDIT
|
||||
EDIT -. "must compile against" .-> KEEP
|
||||
|
||||
classDef del fill:#ffd6d6,stroke:#c0392b,color:#7b1f1f;
|
||||
classDef edt fill:#fff3cd,stroke:#d4a017,color:#7a5c00;
|
||||
classDef kp fill:#d6f5d6,stroke:#27ae60,color:#145a32;
|
||||
class d1,d2,d3,d4,d5,d6 del;
|
||||
class e1,e2,e3,e4,e5,e6,e7,e8,e9,e10,e11,e12,e13 edt;
|
||||
class k1,k2,k3,k4,k5,k6,k7 kp;
|
||||
```
|
||||
|
||||
This is the money diagram for the removal plan. **Red = delete wholesale**: the entire `src/pro/`
|
||||
tree (license, scheduling, ProFeature, copy, UI primitives) plus `PreferenceDefinition.swift` and the
|
||||
paywall test files. **Yellow = edit in place**: files that survive but must have their paywall wiring
|
||||
severed — most are surgical (a handful of lines in `App.swift`, the switcher gates, the preference
|
||||
getters), but `Menubar.swift`, `SettingsWindow.swift`, and the Settings tabs need real surgery to
|
||||
strip the upsell UI, and `project.pbxproj` needs every deleted file's build-file/group/sources
|
||||
entries removed or the project won't even parse. **Green = keep untouched**: the entry point,
|
||||
`src/macos/`, `BackgroundWork`, the switcher kernels, the event hooks, and the `CachedUserDefaults`
|
||||
plumbing are all verified paywall-free. The ordering constraint is critical: the deletes break the
|
||||
build at compile time, so the edits (severing every consumer of `LicenseManager`, `LicenseState`,
|
||||
`ProFeature`, `ProGatedPreferences`, `ProBadgeView`, and `ProGradientButton`) must land in the same
|
||||
change set. The lowest-risk first step before any deletion is to force `isProLocked = false` /
|
||||
`isProAvailable = true`, which turns every gate into a pass-through and lets defaults auto-unlock.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,300 @@
|
||||
# Plan: Republish a Fully-Free, Paywall-Free AltTab Fork
|
||||
|
||||
> **Status:** actionable plan derived from the deep-audit sections in `docs/audit/01-*.md … 09-*.md` and the completeness-critic verdict. Read those sections for full per-file detail; this document is the execution plan that sits on top of them.
|
||||
>
|
||||
> **Scope reminder (from `AGENTS.md`):** pure Swift 5.8, no SwiftUI, no Interface Builder. Compile-check after every change with `ai/build.sh`.
|
||||
|
||||
---
|
||||
|
||||
## A. Licensing & Ethics (read this first — it constrains everything below)
|
||||
|
||||
### What the license actually is
|
||||
|
||||
- AltTab is **GPL-3.0** (`LICENCE.md` — verbatim GNU GPL v3, "Copyright (C) 2007 Free Software Foundation").
|
||||
- The paywall overlay (everything under `src/pro/`, plus the integration call-sites in `src/App.swift`, `src/Menubar.swift`, `src/preferences/…`) was added **upstream by the original author lwouis in a single commit** `9147a4a8 "feat: introducing alt-tab pro!"` (v11.0.0, 2026-04-05) — see audit section 09. Because it was added on top of GPL-3.0 code and links against it, the paywall code is itself a **derivative work and therefore also GPL-3.0**. There is no separately-licensed proprietary blob here.
|
||||
- **Consequence:** GPL-3.0 explicitly grants you the right to modify and redistribute, *including removing the gating*. You may delete the paywall and ship the result.
|
||||
|
||||
### What you MUST do (GPL-3.0 obligations)
|
||||
|
||||
1. **Offer the complete modified source under GPL-3.0.** Keep `LICENCE.md` as-is. Publish your fork's full source (you already are — public GitHub repo).
|
||||
2. **Preserve upstream copyright and attribution.** Do **not** remove copyright headers, the GPL notice, `LICENCE.md`, or `docs/contributors.md` / `docs/acknowledgments.md`. Keep a clear "based on AltTab by Louis Pontoise (lwouis/alt-tab-macos), GPL-3.0" statement in your README and About box.
|
||||
3. **State your changes.** GPL §5(a): mark the files you changed and the dates. A `CHANGELOG`/`NOTICE` entry ("forked from v11.x; removed the Pro/trial/license system; all features unlocked") satisfies this.
|
||||
4. **Pass the same freedoms downstream.** Your fork must remain GPL-3.0 — you cannot re-close it.
|
||||
|
||||
### What you MUST NOT do
|
||||
|
||||
- **Do not claim original authorship.** This is a fork; credit lwouis prominently.
|
||||
- **Do not strip the GPL or attribution** (see above) — that would terminate your license under GPL §8.
|
||||
- **Do not reuse the original's trademarks / app name / icon / branding in a confusing way.** "AltTab", the AltTab icon, and the `alt-tab.app` brand are the upstream project's identity. Copyright license ≠ trademark license. **Rename the app and replace the icon** (see Section D). Shipping a binary still called "AltTab" with the same icon but a different signer invites both trademark confusion and user-trust problems.
|
||||
- **Do not impersonate the paywalled product's identity.** Do **not** reuse the upstream **Developer ID** (`Developer ID Application: Louis Pontoise (QXD7GW8FHY)`, `config/release.xcconfig:5`) — you cannot sign with it and must not try. Do **not** reuse the bundle id `com.lwouis.alt-tab-macos`, the Sparkle public key, the `alt-tab.app` license/feedback/appcast endpoints, or the AppCenter app slug. These are operationally tied to lwouis's accounts and you do not control them.
|
||||
- **Do not point your auto-updater at upstream's appcast / GitHub release downloads** — that would cross-feed updates between the two products and is both a technical and an identity problem.
|
||||
|
||||
**Net:** removing the gating and republishing is *legal and in the spirit of GPL-3.0*. The hard requirements are (a) keep it open under GPL-3.0 with attribution, and (b) ship under a **new identity** (name, icon, bundle id, Developer ID, update feed, endpoints).
|
||||
|
||||
---
|
||||
|
||||
## B. Removal Strategies & Recommendation
|
||||
|
||||
### Strategy 1 — "Surgical force-unlock" (smallest diff)
|
||||
|
||||
Make the gate always open and neutralize the nag scheduler, leaving the paywall code physically present but inert.
|
||||
|
||||
- `LicenseManager.isProLocked` → always `false`; `isProAvailable` → always `true` (audit 03/04: gate lives at `src/pro/license/LicenseManager.swift:67-72`). Hardcode `state = .pro`.
|
||||
- `ProFeature.attemptUse()` becomes a pass-through (`src/pro/ProFeature.swift:74-83`).
|
||||
- `PreferenceDefinition.read()` returns the stored value unconditionally (`src/preferences/PreferenceDefinition.swift:32-44`); defaults already hold the Pro values (audit 03), so features unlock with no default changes.
|
||||
- Stub `ProTransitionManager.onSwitcherShown/onSwitcherDismissed/onAppLaunchComplete` to no-ops to kill nags (`src/App.swift:78,328,429`).
|
||||
|
||||
**Pros:** ~10–20 line diff, lowest compile risk, fastest, no Xcode-project surgery.
|
||||
**Cons:** Dead paywall code ships in the binary; **live remote/telemetry references remain** — license API client, `LicenseCookie` writing a tier cookie to `alt-tab.app` on every state change (`src/App.swift:451`), `RemoteLicenseClient` POSTs (`src/pro/license/RemoteLicenseClient.swift`), machine-fingerprint code (`src/pro/license/MachineFingerprint.swift`). For a *genuine free republish* this is unacceptable: it still phones the upstream paywall backend and leaks a per-machine fingerprint. Also still ships under the upstream identity unless you separately do Section D. **Not recommended as the end state.**
|
||||
|
||||
### Strategy 2 — "Full excision" (delete `src/pro/` and untangle) **← RECOMMENDED**
|
||||
|
||||
Delete the entire `src/pro/` tree, remove every integration call-site, rewrite the gated preference getters to read raw values, remove the upsell UI from `App.swift` / `Menubar.swift` / `SettingsWindow.swift`, drop the license/cookie/fingerprint network code, fix the Xcode project file and the tests.
|
||||
|
||||
**Pros:** Truly free; no dead paywall code; no license API, no tier cookie, no machine fingerprint, no Day-X nag windows. The binary is honestly "all features, no strings."
|
||||
**Cons:** Larger, riskier change touching ~35 source files plus `alt-tab-macos.xcodeproj/project.pbxproj` (~102 in-Sources entries) and several test files. Build-break risks are real and enumerated in Section C/E.
|
||||
|
||||
### Strategy 3 — "Rebase onto pre-paywall / current upstream" (cleanest of all, different shape)
|
||||
|
||||
Take **upstream `lwouis/alt-tab-macos`** at a point you want (either pre-paywall `v10.12.0` = commit `317a485b`, or current `master` *minus* the `src/pro/` commit) and cherry-pick only the genuinely-free fork features you care about.
|
||||
|
||||
**Pros:** You never have to extract the paywall from this snapshot at all — you start from a clean, paywall-free tree (pre-v11) or a tree where you simply never merge the Pro commit. Lowest *long-term* maintenance because you track upstream directly.
|
||||
**Cons:** audit 09 confirms this repo has **zero fork-owner commits** — every commit is lwouis or `semantic-release-bot`. So there are effectively **no unique fork features to carry over**; the only delta from upstream *is* the paywall. Rebasing on `v10.12.0` loses all post-v11 fixes unless cherry-picked; rebasing on current upstream-minus-Pro is essentially Strategy 2 done via git topology instead of file deletion. It also still requires the full Section D re-identity work.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Adopt Strategy 2 (full excision) as the deliverable, but use Strategy 1 as an intermediate checkpoint.** Concretely:
|
||||
|
||||
1. First land Strategy 1 (force-unlock) as a *temporary* commit so you have a known-good, fully-unlocked, compiling build to A/B against.
|
||||
2. Then perform the full excision on top of it, verifying the build stays green at each phase (Section C).
|
||||
3. Do Section D (re-identity) in the same release.
|
||||
|
||||
Strategy 3 is the right call **only** if you would rather track upstream long-term and re-apply removal as a maintained patch set — given there are no unique fork features, that is a reasonable alternative, but it does not save the re-identity work and loses the newer fixes if you go to `v10.12.0`. For a one-shot "free AltTab" republish from *this* snapshot, full excision is the most honest result and keeps the current v11.x fixes.
|
||||
|
||||
---
|
||||
|
||||
## C. Step-by-Step Execution (Strategy 2, full excision)
|
||||
|
||||
> **Golden rule:** after **every** numbered step, run `ai/build.sh` (Debug) and do not proceed until it compiles. Deleting `src/pro/` before its consumers are rewritten is a guaranteed hard break (the completeness critic lists the exact symbols). The ordering below removes *consumers first*, then the *toolkit*, then the *files*, then the *Xcode refs*, then the *tests*.
|
||||
|
||||
### Phase 0 — Safety net
|
||||
|
||||
- Branch off `master`. Optionally land the Strategy-1 force-unlock commit first (Section B) to get a fully-unlocked baseline you can diff behavior against.
|
||||
- Confirm the Debug build is green before touching anything: `ai/build.sh`.
|
||||
|
||||
### Phase 1 — Open the gates in place (no deletions yet)
|
||||
|
||||
Goal: make every feature live and stop all nags while all Pro symbols still exist, so the build stays green.
|
||||
|
||||
1. `src/pro/ProFeature.swift:74-83` — `attemptUse()` returns `true` unconditionally (remove the `isFreePassSessionActive` short-circuit at `:76` and the `attemptHardGatedFeature` call at `:79`).
|
||||
2. `src/preferences/PreferenceDefinition.swift:32-44` — `read()` returns the stored value unconditionally (drop the free-pass and isProLocked branches).
|
||||
3. `src/preferences/Preferences.swift` — verify the three gated getters (`:129` appearanceStyle, `:130` appearanceSize, `:155` shortcutStyle) and the three index-0 overrides (`:313, :319, :330`) now return Pro values; defaults already hold them (audit 03).
|
||||
4. `src/preferences/settings-window/tabs/controls/ControlsTab.swift:589-592` — remove the 1-shortcut cap; `:515` remove the `index>=1` Pro badge.
|
||||
|
||||
**Verify:** build green; manually confirm extra shortcuts, in-switcher search, App-Icons/Titles styles, and Auto size all work (audit 06 lists these four advertised features).
|
||||
|
||||
### Phase 2 — Sever the App.swift wiring
|
||||
|
||||
Edit `src/App.swift`, removing only the Pro lines (audit 01/05 enumerate them):
|
||||
|
||||
5. Remove the trial-nag hooks: `:78` (`onSwitcherDismissed`), `:328` (`onSwitcherShown`), `:428` (`onAction = ProPromptHost.shared.dispatch`), `:429` (`onAppLaunchComplete`).
|
||||
6. Remove the license callback block `:448-460` (`onBeforeProUnlock`, the `onStateChanged` fan-out, and `LicenseManager.shared.initialize()`). Keep `resetPreferencesDependentComponents` (`:51-53`) — it is used elsewhere; only drop the Pro caller. Drop the `proLockStateDidChangeNotification` post (`:458`).
|
||||
7. Remove the custom-URL activation handler wholesale: `application(_:open:)` + `handleCustomUrl` (`:465-490`) — it exists only for `alttab://activate`.
|
||||
8. Remove the menubar action selectors/methods: `:25-27` selectors, `:111-121` (`upgradeToPro`, `openAccount`; **decide** whether to keep `supportProject` — it is a donation link, not paywall, audit 01/07).
|
||||
9. Simplify first-launch: `willShowDay1WelcomeOnAppLaunch` (`:205-208`) and the deferral observer (`:210-220`) reference `Day1WelcomeLetterWindow` and `hasSeenWelcome`. Replace with an unconditional `showAndCenterSettingsWindowOnFirstLaunch`. **Risk (audit 01):** if you delete the welcome window but leave the `willClose` observer, Settings never shows on first launch — remove both together.
|
||||
|
||||
**Verify:** build green. `App.swift` now references no `ProTransitionManager`/`LicenseManager`/`ProPromptHost` symbols.
|
||||
|
||||
### Phase 3 — Strip the Menubar upsell
|
||||
|
||||
Edit `src/Menubar.swift` (audit 07):
|
||||
|
||||
10. Delete the 3 license item vars (`:8-10`), the Get-Pro / My-Account / (optionally Support) item adds (`:46-49`), the `UpgradeMenuItemView` assignment (`:47`), and the `refreshLicenseMenuItems` call (`:50`).
|
||||
11. Delete `refreshLicenseMenuItems` (`:84-107`) and `toggleUpgradeMenuItem` (`:109-118`).
|
||||
12. Delete the `UpgradeMenuItemView` class (`:235-390`) — its only consumer was `:47`. **Note (critic):** `updateContent(_ state: LicenseState)` (`:357`) hard-depends on the `LicenseState` enum; it dies with this class, which is what lets you delete `LicenseState` later.
|
||||
13. Delete `badgeDotLayer` (`:187`) and `updateBadgeDotOverlay` (`:202-227`), and remove its call inside `loadPreferredIcon` (`:196`). This severs Menubar's dependency on `ProTransitionManager.shouldShowBadgeDot`.
|
||||
14. In `menuWillOpen` (`:392-399`) drop `LicenseManager.shared.refreshState()` (`:396`) but **keep** `refreshPermissionCallout()` (`:397`) — non-pro.
|
||||
15. `showPopoverFromMenubar` (`:229-232`) becomes dead once Day-X popovers go; remove it. **Keep** `menubarIconCallback` (`:155-164`) — also used by PreferencesEvents/QAMenu.
|
||||
|
||||
**Verify:** build green. The menu now matches the upstream non-pro item set (Show / Settings / Updates / Permissions / About / Feedback / Quit).
|
||||
|
||||
### Phase 4 — Strip Settings-window license entanglement (the critic's biggest miss)
|
||||
|
||||
Edit `src/preferences/settings-window/SettingsWindow.swift` (completeness critic):
|
||||
|
||||
16. Delete the `UpgradeButton` class (`:133-214`, a `ProGradientButton` subclass reading `LicenseManager.state`/`.isLifetimeVariant`/`.customerEmail`).
|
||||
17. Delete the `upgradeButton` instance (`:303`) and its state (`:308-311` `upgradeContentView`/`isShowingUpgradeView`/`upgradeViewBottomConstraint`).
|
||||
18. Delete `setupUpgradeButton` + constraints + `upgradeButtonClicked` (`:401, :503, :507-526`).
|
||||
19. Delete `showUpgradeView`/`hideUpgradeView` (`:1066, :1122-1161`) which embed `UpgradeTab.initTab`.
|
||||
20. Delete `refreshUpgradeButton` (`:1163-1165`) — was called from `App.swift:454` (removed in Phase 2) and `UpgradeTab:214`.
|
||||
21. In `windowDidBecomeKey` (`:1243-1254`) remove `LicenseManager.refreshState` + `UpgradeTab.refreshStatus` + `playShineAnimation`; in `windowWillClose` remove `UpgradeTab.cleanup` (`:1266`).
|
||||
22. Remove `refreshEmailTooltip` / `hasSecondLine` license reads (`:196, :206-207`).
|
||||
23. Remove the settings-search "Pro" registration (`:679-680`, registers `ProBadgeView` → "Pro" string).
|
||||
|
||||
Edit `src/preferences/settings-window/SidebarList.swift` (critic):
|
||||
|
||||
24. Remove `private var proBadge: ProBadgeView?` (`:93`) and `setProBadge` (`:244-258`).
|
||||
|
||||
Edit the tabs (audit 03):
|
||||
|
||||
25. `AppearanceTab.swift` — remove the `proLockStateDidChange` observer (`:414-419`), the `proGatedAppearanceStyleIndices` args (`:462, :700-703`), the `ProBadge.attach` calls (`:463, :481, :575, :688-698`), the `wrapShortcutStyleProLockIntercept` (`:585-598`) and `wrapAppearanceSizeProLockIntercept` (`:662-679`), and `refreshProLockUi` (`:705+`).
|
||||
26. `ShortcutEditor.swift` — remove `proGatedIndices` args (`:429, :437, :449, :459`) and the locked intercepts (`:599-607, :705-711`).
|
||||
27. `ControlsTab.swift` — remove the `proLockStateDidChange` observer (`:141`) (cap + badge already removed in Phase 1).
|
||||
28. `ShortcutsWhenActiveSheet.swift` — remove the ProBadge on search / lock-search rows (`:25, :51, :54, :63-69`).
|
||||
29. `LabelAndControl.swift` — remove the `proGatedIndices` param and the `isProLocked` intercept from `makeImageRadioButtons` (`:74-116`, esp. `:78, :86-92`).
|
||||
30. `src/events/PreferencesEvents.swift:62-64` — delete the "bounce a Pro-valued write to the Upgrade tab" block (depends on `ProFeature.isStoredValuePro`).
|
||||
|
||||
**Verify:** build green. No file outside `src/pro/` now references `ProBadgeView`, `ProGradientButton`, `ProGradient`, `LicenseManager`, `LicenseState`, `ProTransitionManager`, `ProFeature`, `UpgradeTab`, or `proLockStateDidChangeNotification`. Grep to confirm:
|
||||
```
|
||||
grep -rn -e ProBadgeView -e ProGradient -e LicenseManager -e LicenseState \
|
||||
-e ProTransitionManager -e ProFeature -e UpgradeTab -e proLockStateDidChange \
|
||||
--include=*.swift src | grep -v '^src/pro/'
|
||||
```
|
||||
This grep returning empty (modulo the test files handled in Phase 7) is the gate for Phase 5.
|
||||
|
||||
### Phase 5 — Rewrite gated preference getters to be self-contained, then prepare deletion
|
||||
|
||||
31. `src/preferences/Preferences.swift` — rewrite the 3 gated getters (`:129, :130, :155`) and the 3 index-0 override branches (`:313, :319, :330`) to read raw values via `CachedUserDefaults.macroPref(...)` instead of `ProGatedPreferences.*.read()`. Remove `overrideRememberedKey` + the `ProTransitionState.setInt` cleanup in `removeOverride` (`:282-298`).
|
||||
32. `src/preferences/PreferencesMigrations.swift:20` — remove the `ProTransitionState.markFreshInstallIfUnknown` call (the only paywall coupling in migrations).
|
||||
33. (Optional, in-place-upgrade only) If you ship under the **same** bundle id, add a one-time migration that copies any `proTransition.remembered*` indices back to the base keys before you stop reading them, else users who passed trial expiry on the paywalled build silently lose their prior Pro selections (audit 03 risk). **If you ship a new bundle id (recommended, Section D), this is moot** — clean UserDefaults domain.
|
||||
|
||||
**Verify:** build green. `src/pro/` is now referenced only by itself + tests.
|
||||
|
||||
### Phase 6 — Delete `src/pro/` and fix telemetry/endpoints
|
||||
|
||||
34. Delete the entire `src/pro/` directory (verified contents):
|
||||
- `src/pro/license/` — `Clock.swift`, `Keychain.swift`, `LicenseAPI.swift`, `LicenseCookie.swift`, `LicenseManager.swift`, `LicenseManagerSpecs.md`, `LicenseManagerTests.swift`, `LicenseState.swift`, `MachineFingerprint.swift`, `RemoteLicenseClient.swift`
|
||||
- `src/pro/scheduling/` — all `Day*.swift` (Day1/4/12/15×3/21/35), `ProTransitionManager.swift`, `ProTransitionManagerTestable.swift`, `ProTransitionScheduler.swift`, `ProTransitionState.swift`, `ProTransitionTests.swift`, `ProTransitionSpecs.md`
|
||||
- `src/pro/ui/` — `ProBadgeView.swift`, `ProBadgeViewSegmentTests.swift`, `ProBadgeViewSegmentSpecs.md`, `ProGradientButton.swift`, `ProPromptHeader.swift`, `ProPromptHost.swift`, `ProPromptPopover.swift`, `ProPromptWindow.swift`, `UsageStatHeroView.swift`
|
||||
- `src/pro/ProFeature.swift`, `src/pro/ProConversionCopy.swift`, `src/pro/ProFeatureCopy.swift`
|
||||
35. Delete `src/preferences/PreferenceDefinition.swift` entirely (the gate engine; getters were rewritten in Phase 5).
|
||||
36. Delete the Upgrade tab `src/preferences/settings-window/tabs/UpgradeTab.swift` (it contains `ProHeroButton: ProGradientButton` at `:493`, the `usageHero`/`heroButton` plumbing `:5-6, :37-38, :160-161`, and the full `activateLicense`/`deactivateLicense`/`deactivateInstance` + `LicenseAPIError.seatLimitExceeded` UI `:322-405` — critic). Remove it from the Settings tab list/registration.
|
||||
37. **Endpoints/cookie/fingerprint** (`src/api/Endpoints.swift`): remove `checkoutUrl` (`:9`), `accountUrl` (`:10`), `licenseApiBaseUrl` (`:11`). Keep `appcastUrl` and `supportUrl`; keep or repoint `feedbackUrl` (`:12`) per Section D. The `LicenseCookie`, `RemoteLicenseClient`, `MachineFingerprint` files are already gone with `src/pro/license/`.
|
||||
38. **`UsageStats`** (`src/util/UsageStats.swift`, `UsageStatsTestable.swift`, `UsageStatsMessageTests.swift`): local-only, no network (audit 09). Its only *paywall* consumers were `ProConversionCopy`/`UsageStatHeroView`, now deleted. You may keep it (the neutral `App.swift` recording / AboutTab display still compile) or delete it; deleting also requires removing `UsageStatsMessageTests.swift` from the test target.
|
||||
39. **QA/Debug menu** `src/debug/QAMenu.swift:170-241` (audit 05): directly instantiates every Day-X window and pokes manager flags. Remove those manual triggers/resets or the DEBUG build won't compile.
|
||||
40. **DebugProfile** `src/secondary-windows/DebugProfile.swift:17` reads `LicenseManager.shared.state.debugProfileLabel` (the extension lived at `src/pro/license/LicenseState.swift:14`). Since DebugProfile feeds the (kept) Feedback report, **hand-edit** — drop the License line, do not delete the file (critic).
|
||||
41. **AppCenter telemetry** (audit 08/09) — remove it (most privacy-aligned for a free build; AppCenter is also EoL):
|
||||
- delete `src/vendors/AppCenterCrashes.swift` and references in `src/App.swift` (`import`/`AppCenterApplication` superclass at `App.swift:7`/start call).
|
||||
- **Principal class break (critic):** `Info.plist:36-37` `NSPrincipalClass = AppCenterApplication`, the `class App: AppCenterApplication` superclass, and `alt-tab-macos-Bridging-Header.h:1` import are coupled. Replace the superclass with `NSApplication` and set `NSPrincipalClass` to `NSApplication` (or your own subclass), and remove the bridging-header import — or the app won't launch *and* won't compile.
|
||||
- remove `Secrets.appCenterSecret` usage (`src/api/Secrets.swift`), `Info.plist` `AppCenterSecret`/forwarder keys, and the `Package.swift` AppCenter deps.
|
||||
- keep `DebugProfile.make()` if Feedback stays (it is shared); only the crash-attachment call site goes away.
|
||||
|
||||
**Verify:** build green.
|
||||
|
||||
### Phase 7 — Xcode project file + tests (the mechanical part — do not skip)
|
||||
|
||||
42. **`alt-tab-macos.xcodeproj/project.pbxproj`** (critic): every deleted file has a `PBXBuildFile` + `PBXFileReference` + `PBXGroup` child + `PBXSourcesBuildPhase` membership (~102 in-Sources pro entries; app phase ~line 1830, test phase ~1902; `PreferenceDefinition.swift` at ~1979). Note **`Clock`, `Keychain`, `LicenseAPI`, `MachineFingerprint`, `ProTransitionManagerTestable` have TWO build-file UUIDs (app + test)** — remove **both**. Any file deleted on disk but still referenced makes the project fail to parse/link. The cleanest way to do this safely is to let Xcode reconcile, but since `AGENTS.md` says don't develop in Xcode, edit `project.pbxproj` by hand and verify with `xcodebuild -list` / `ai/build.sh` after.
|
||||
43. **Tests** (critic): remove/repair test-target members that reference vanished symbols — `LicenseManagerTests`, `ProTransitionTests`, `ProTransitionManagerTestable`, `ProBadgeViewSegmentTests`, and (if you deleted UsageStats) `UsageStatsMessageTests`. In `src/_test-support/Mocks.swift` remove the `ProBadgeView` Symbols stub and the `ShortcutStylePreference` mock, and update the hardcoded bundle id (`Mocks.swift:133`, `com.lwouis.alt-tab-macos`) to the new id. `PreferencesMigrationsTests.swift:356` has a *self-contained local stub* `ProTransitionState` (won't break on deletion), but the real `PreferencesMigrations.swift:20` call you removed in Phase 5 is what mattered.
|
||||
|
||||
**Verify:** Test scheme passes:
|
||||
```
|
||||
scripts/run_tests.sh # Test scheme, Release config (audit 08)
|
||||
```
|
||||
|
||||
### Phase 8 — Final compile + behavior pass
|
||||
|
||||
44. `ai/build.sh` (Debug) green; `scripts/build_app.sh` (Release scheme) green; `scripts/run_tests.sh` green.
|
||||
45. Manual smoke test (Section E checklist): permissions flow, switcher show/hide, all four previously-Pro features, multiple shortcuts, Settings opens with no Upgrade tab / no Get-Pro item / no badge dot, no `alttab://` handler, no network call to `alt-tab.app/api`.
|
||||
|
||||
---
|
||||
|
||||
## D. Re-Identity & Publish
|
||||
|
||||
> Per `AGENTS.md`, app identity (Developer ID, TeamID, bundle id) and Keychain items are coupled to the code signature. **For the free build this invariant is effectively MOOT (audit 08/09):** once `src/pro/license/Keychain.swift` is deleted there are no stored license items to orphan, so rebranding the bundle id is safe. But the identity change itself is **mandatory** — you cannot sign with upstream's Developer ID.
|
||||
|
||||
### Identity
|
||||
|
||||
- **Bundle id:** change `com.lwouis.alt-tab-macos` → your reverse-DNS (e.g. `app.koftwentytwo.alt-tab-free`). Locations: `config/base.xcconfig:4`; unit-test bundle id in `project.pbxproj:2477/2542`; hardcoded `Mocks.swift:133`.
|
||||
- **App name / `PRODUCT_NAME`:** rename away from "AltTab" (trademark — Section A). Update `config/base.xcconfig`, README, and the About box (`App.repository`, `src/App.swift:16`).
|
||||
- **Icon:** replace the AltTab icon with your own (trademark + brand identity).
|
||||
- **TeamID / Developer ID:** `DEVELOPMENT_TEAM = "${TEAM_ID}"` currently expands to empty; signing is driven by `CODE_SIGN_IDENTITY` (`config/release.xcconfig:5`) = the upstream `Developer ID Application: Louis Pontoise (QXD7GW8FHY)`. **Replace with your own Developer ID Application cert + TeamID.** The CI cert secret `$APPLE_P12_CERTIFICATE` (`scripts/codesign/setup_ci_master.sh`) must be your P12. Notarization Apple-ID/password/team (`ci_cd.yml:9-11`, `scripts/package_and_notarize_release.sh`) must be your account.
|
||||
|
||||
### Auto-update (Sparkle) — must repoint or you can't ship updates
|
||||
|
||||
- Feed URL `Endpoints.appcastUrl` = `https://<DOMAIN>/appcast.xml`, `DOMAIN=alt-tab.app` (`config/base.xcconfig:20`). **Repoint `DOMAIN`** to a host you control, or serve the committed `appcast.xml` from your own GitHub Pages / raw URL.
|
||||
- **Generate a NEW Sparkle EdDSA keypair.** Replace `SUPublicEDKey` (`Info.plist:62`, currently `2e9SQOBoaKElchSa/4QDli/nvYkyuDNfynfzBF6vJK4=`) and the CI signing secret `$SPARKLE_ED_PRIVATE_KEY` (`update_appcast.sh:9`). Public and private **must match** or updates fail verification. You cannot reuse lwouis's key (you don't have the private half anyway).
|
||||
- **Truncate/regenerate `appcast.xml`** (~3876 lines). Existing items are signed by upstream's private key and point at `github.com/lwouis/alt-tab-macos/releases/...` (`update_appcast.sh:18`). Leaving them risks cross-feeding updates between the two products — start a fresh feed for your fork's own releases.
|
||||
- Re-vendor/re-sign `vendor/Sparkle/Helpers/Updater.app` + `Autoupdate` with **your** Developer ID (`copy_sparkle_helpers.sh` re-seals with `CODE_SIGN_IDENTITY`); a mismatch trips `codesign --verify --deep --strict`.
|
||||
|
||||
### Remove the license API + telemetry endpoints
|
||||
|
||||
- License API (`Endpoints.licenseApiBaseUrl`, `RemoteLicenseClient`) and the per-tier `LicenseCookie` are deleted in Phase 6 — confirm no remaining reference to `alt-tab.app/api/v1/license`.
|
||||
- **AppCenter** removed in Phase 6 (Step 41). Decide explicitly: remove entirely (recommended) vs repoint to a self-owned AppCenter app (note: AppCenter is EoL, so removal is the durable choice).
|
||||
- **Feedback** (`Endpoints.feedbackUrl` → `alt-tab.app/api/v1/feedback`, used `FeedbackWindow.swift:445`): its server turns the POST into a GitHub issue on **lwouis's** repo and is **not in this repo**. Either stand up your own endpoint and repoint, or remove the in-app feedback submitter. This is a *product decision*, flagged in audit 08/09.
|
||||
- **Website repository-dispatch** (`update_website.sh:5` → `lwouis/alt-tab-website`, `$WEBSITE_DISPATCH_TOKEN`, `ci_cd.yml:67-69`) and the README-stats step hitting `api.github.com/repos/lwouis/alt-tab-macos` (`update_readme_and_website.sh:10`) must be removed or repointed to your repo. Update `.github/FUNDING.yml`.
|
||||
|
||||
### Distribution channel & notarization
|
||||
|
||||
- **Recommended:** GitHub Releases of *your* fork + Sparkle feed served from your domain or GitHub Pages. This mirrors the existing pipeline with minimal change once the URLs/keys above are yours.
|
||||
- **Alternatively / additionally:** a Homebrew cask (`brew install --cask <your-fork>`), which still requires a notarized, stapled `.zip`/`.app`.
|
||||
- **Notarization is mandatory** for Gatekeeper on modern macOS regardless of channel: sign with your Developer ID → `notarytool` submit (bundled `scripts/notarytool`, `scripts/package_and_notarize_release.sh`) with **your** `$APPLE_ID`/`$APPLE_PASSWORD`/`$APPLE_TEAM_ID` → `xcrun stapler staple` → re-zip.
|
||||
- **Versioning:** releases are 100% `semantic-release` from Conventional Commits on `master` (`determine_next_version.sh` → CI-only `VERSION.txt` → `Info.plist`). `package.json` `1.0.0` is irrelevant. Your fork can keep this as-is once the secrets/URLs are yours, or reset the version line for a clean v1.
|
||||
|
||||
**Gating prerequisite (audit 08 open question):** you need your own Apple Developer account (Developer ID Application cert + notarization credentials) and a domain (or GitHub Pages) for the appcast + your own Sparkle EdDSA keypair **before** the first signed, self-updating release is possible.
|
||||
|
||||
---
|
||||
|
||||
## E. Risks, Testing, and Phased Checklist
|
||||
|
||||
### Top build-break risks (from the completeness critic — fix the consumer before deleting the type)
|
||||
|
||||
- **`LicenseState` enum** is referenced by non-pro `Menubar.swift:357` (`updateContent` param) and `DebugProfile.swift:17` (`.debugProfileLabel`). Handle in Phase 3/6 before deleting `src/pro/license/LicenseState.swift`.
|
||||
- **`LicenseManager` singleton** is read by non-obvious non-pro sites: `SettingsWindow.swift:162,164,167,196,206-207,1246` and `UpgradeTab.swift:220-405`. All removed in Phase 4/6.
|
||||
- **`ProGradientButton`** is the **superclass** of `UpgradeButton` (`SettingsWindow.swift:133`) and `ProHeroButton` (`UpgradeTab.swift:493`); `Menubar.swift:247` uses `ProGradient.makeLayer()`. Delete all subclasses/usages (Phase 3/4/6) before deleting the toolkit.
|
||||
- **`ProBadgeView`** referenced by `SidebarList.swift:93,254`, `SettingsWindow.swift:679`, Appearance/Controls/ShortcutsWhenActiveSheet. Remove all (Phase 4) before deleting it.
|
||||
- **`ProGatedPreferences`/`PreferenceDefinition.swift`** feeds `Preferences.swift:129,130,155` getters and is a **unit-test-target member** (`project.pbxproj:1979`) — a broken getter fails *app and test* compile. Rewrite getters (Phase 5) before deleting.
|
||||
- **App superclass / principal class** (`App.swift:7`, bridging header, `Info.plist:36-37`) — removing AppCenter without replacing the principal class means the app won't launch *and* won't compile (Phase 6, Step 41).
|
||||
- **`Day1WelcomeLetterWindow`** referenced in non-pro `App.swift:213`; the first-launch deferral observer (`:194-218`) must be simplified to always show Settings (Phase 2, Step 9) or compile breaks.
|
||||
- **`project.pbxproj` integrity** — ~102 in-Sources entries + fileRef/group entries per deleted file; dual app+test UUIDs on Clock/Keychain/LicenseAPI/MachineFingerprint/ProTransitionManagerTestable must **both** go (Phase 7).
|
||||
|
||||
### Behavioral / product risks
|
||||
|
||||
- **In-place upgrade data loss** (audit 03): if you keep the same bundle id, users past trial-expiry have downgraded stored prefs with the Pro index parked in `proTransition.remembered*` (in the `<bundleId>.license` UserDefaults suite). After removal nothing restores them. **Mitigation:** ship a new bundle id (clean domain) — recommended — or add the one-time restore migration in Phase 5, Step 33.
|
||||
- **Orphaned UserDefaults / Keychain** (audit 04/05): with a new bundle id there is nothing to orphan; with the same id, old `proTransition.*` / `trialStartDate` keys are harmless once no code reads them.
|
||||
- **Sparkle key mismatch** breaks updates silently for end users — verify the public/private EdDSA pair before first release.
|
||||
- **Trademark/identity** (Section A): shipping as "AltTab" with the AltTab icon is the highest *non-build* risk — rename + re-icon.
|
||||
|
||||
### Testing matrix
|
||||
|
||||
- **Unit:** `scripts/run_tests.sh` (Test scheme) green after Phase 7. The pure kernels (`SelectionResolver`, `SearchModeResolver`, `WindowFilterResolver`, `WindowOrderResolver`) never touched pro code (audit 02) and should pass unchanged — a good signal that gate removal didn't perturb core logic.
|
||||
- **Debug build:** `ai/build.sh` green after **every** phase (the discipline that catches the consumer-before-type breaks).
|
||||
- **Release build:** `scripts/build_app.sh` green before packaging.
|
||||
- **Manual smoke (post-Phase 8):**
|
||||
1. Fresh launch → Accessibility + Screen-Recording permission flow → switcher panels appear (phase-A → phase-B handoff, audit 01).
|
||||
2. Alt-Tab show/hide/cycle; selection + hover; window focus.
|
||||
3. All four previously-Pro features work: App-Icons/Titles appearance style, Auto size, in-switcher search, lock-search, and **>1 keyboard shortcut**.
|
||||
4. Settings: no Upgrade tab, no Get-Pro menu item, no trial subtitle, no orange badge dot, no Pro badges on Appearance/Controls rows.
|
||||
5. No `alttab://` URL handler registered (and removed from `Info.plist` URL scheme).
|
||||
6. Network check (e.g. Little Snitch / `nettop`): **no** request to `alt-tab.app/api/v1/license` and **no** tier cookie; appcast points at *your* host.
|
||||
7. Auto-update: a test appcast item signed with your EdDSA key updates successfully.
|
||||
|
||||
### Phased checklist
|
||||
|
||||
- [ ] Phase 0 — branch; baseline build green; (optional) land Strategy-1 force-unlock checkpoint.
|
||||
- [ ] Phase 1 — gates open in place (`ProFeature`, `PreferenceDefinition.read`, shortcut cap); build green.
|
||||
- [ ] Phase 2 — `App.swift` wiring severed (hooks, license block, custom-URL, selectors, first-launch); build green.
|
||||
- [ ] Phase 3 — `Menubar.swift` upsell stripped (items, `UpgradeMenuItemView`, badge dot, `refreshState`); build green.
|
||||
- [ ] Phase 4 — `SettingsWindow`/`SidebarList`/tabs/`PreferencesEvents` license + ProBadge plumbing removed; grep gate empty; build green.
|
||||
- [ ] Phase 5 — `Preferences.swift` getters read raw values; `PreferencesMigrations:20` removed; (optional) restore migration; build green.
|
||||
- [ ] Phase 6 — delete `src/pro/` + `PreferenceDefinition.swift` + `UpgradeTab.swift`; fix `Endpoints`, `DebugProfile`, `QAMenu`, AppCenter/principal class; build green.
|
||||
- [ ] Phase 7 — `project.pbxproj` entries removed (incl. dual app+test UUIDs); tests/Mocks repaired; `scripts/run_tests.sh` green.
|
||||
- [ ] Phase 8 — Release build green; manual smoke pass.
|
||||
- [ ] Re-identity (D) — new bundle id, name, icon, Developer ID, Sparkle EdDSA keypair + repointed feed, removed license/AppCenter, repointed/removed feedback + website dispatch + FUNDING.
|
||||
- [ ] Publish — signed, notarized, stapled `.app`/`.zip` via your GitHub Releases + your appcast (and/or Homebrew cask).
|
||||
- [ ] Compliance (A) — `LICENCE.md` intact, attribution + "based on lwouis/alt-tab-macos, GPL-3.0" in README/About, changes noted, fork remains GPL-3.0.
|
||||
|
||||
---
|
||||
|
||||
### Appendix — reference: audit sections
|
||||
|
||||
| # | File | Topic |
|
||||
|---|------|-------|
|
||||
| 01 | `docs/audit/01-core-architecture.md` | App lifecycle, App.swift/Menubar.swift spine touchpoints |
|
||||
| 02 | `docs/audit/02-switcher-ui.md` | Switcher gates (`ProFeature.attemptUse` call sites) |
|
||||
| 03 | `docs/audit/03-preferences-and-gating.md` | `PreferenceDefinition`/`ProGatedPreferences`, `isProLocked` |
|
||||
| 04 | `docs/audit/04-license-subsystem.md` | `LicenseManager`/`RemoteLicenseClient`/Keychain |
|
||||
| 05 | `docs/audit/05-trial-nag-scheduling.md` | `ProTransitionManager` Day1→Day35 scheduler |
|
||||
| 06 | `docs/audit/06-profeature-and-copy.md` | `ProFeature` cases + marketing copy |
|
||||
| 07 | `docs/audit/07-pro-ui-and-menubar.md` | `src/pro/ui/` toolkit + Menubar integration |
|
||||
| 08 | `docs/audit/08-build-release-distribution.md` | xcconfig, signing, Sparkle, CI |
|
||||
| 09 | `docs/audit/09-telemetry-and-upstream.md` | AppCenter, network surfaces, upstream relationship |
|
||||
@@ -0,0 +1,44 @@
|
||||
# SESSION-STATE — CommandTabFree (de-paywalled AltTab fork)
|
||||
|
||||
_Updated 2026-06-16. `master` carries the fork; the **full CI/CD publishing pipeline + auto-update are LIVE** — signed + notarized **v100.2.0** released (Gatekeeper-accepted), Sparkle feed served on GitHub Pages. Homebrew tap live._
|
||||
|
||||
## ✅ SIGNED + NOTARIZED RELEASE LIVE
|
||||
|
||||
The complete release pipeline ran green end-to-end (run `27643432063`, commit `2f9fac17`): cert import → **signed** `build_app.sh` (Developer ID **`James Maes (2X834TJ5MA)`**) → notarize + staple → appcast→gh-pages → semantic-release → GitHub Release. **[v100.1.0](https://github.com/KofTwentyTwo/CommandTabFree/releases/tag/v100.1.0)** is published; the downloaded asset verifies `spctl` = "accepted, source=Notarized Developer ID" and `stapler validate` passes (offline-clean, no quarantine prompt). Non-release pushes stay green via the ad-hoc `build_app.sh` path (run `27634828800`).
|
||||
|
||||
**How signing is wired:** `config/local.xcconfig` sets `CODE_SIGN_IDENTITY` + `CODE_SIGN_STYLE = Manual` + `TEAM_ID 2X834TJ5MA`; the vendored Sparkle helpers (`vendor/Sparkle/Helpers/*`) are committed **re-signed** under the same Developer ID; `setup_ci_master.sh` imports the cert with an optional `APPLE_P12_PASSWORD`. The release secrets (`APPLE_P12_CERTIFICATE`/`APPLE_P12_PASSWORD`/`APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID` + `SPARKLE_ED_PRIVATE_KEY`) are set in the `production` env. Getting here also required two CI fixes earlier this session: `d1e1b296` (regenerate base `Localizable.strings` to clear the genstrings gate) and `6070f0cb` (ad-hoc build until the cert existed).
|
||||
|
||||
**Auto-update is LIVE (shipped in v100.2.0):** `DOMAIN = koftwentytwo.github.io/CommandTabFree`, `SUEnableAutomaticChecks = true`, the in-tree `appcast.xml` skeleton removed + gitignored. GitHub Pages serves `https://koftwentytwo.github.io/CommandTabFree/appcast.xml` (gh-pages branch, published by `update_appcast.sh`); the feed carries v100.1.0 + v100.2.0 enclosures. A kof22.com custom domain can be added later (GitHub redirects the github.io URL — no broken builds). **Homebrew cask** bumped to v100.2.0 + quarantine-strip postflight dropped (`KofTwentyTwo/homebrew-tap`). **Release-gate decoupled:** the sign/notarize/release apparatus is gated on a `will-release` signal (empty `VERSION.txt` ⇒ no release), so docs/ci pushes build+test without cutting a release or needing `[skip ci]` (ci_cd.yml).
|
||||
|
||||
**Remaining (owner-gated, NOT automatable):**
|
||||
- **Upstream-sync**: provision `SYNC_BOT_TOKEN` + an external `repository_dispatch` cron; re-wire the `.fork-sync-state` cursor advance into the sync-merge flow (recording the UPSTREAM tag). `.fork-sync-state` = `v11.3.0`; the 3 sync labels exist.
|
||||
- **(Optional)** kof22.com custom domain for the appcast (one DNS CNAME → `koftwentytwo.github.io`).
|
||||
|
||||
**Repo facts:** GitHub = `KofTwentyTwo/CommandTabFree` (local dir `~/Git.Local/kof22/alt-tab-free`, SSH remote). Latest release **v100.2.0** (signed + notarized + auto-updating). ⌘⇥ icon FINAL. Owner-gated checklist: `docs/TODO.md`.
|
||||
|
||||
|
||||
## What this is
|
||||
`KofTwentyTwo/CommandTabFree` is a fork of **AltTab** (`lwouis/alt-tab-macos`, GPL-3.0) that neutralizes the v11 Pro paywall so all features are free, rebranded **CommandTabFree**. Strategy: thin, merge-stable patch tracking upstream forever (`docs/PLAN-maintained-fork.md`).
|
||||
|
||||
## ✅ DONE
|
||||
- De-paywall + anti-relock tests + frozen identity (`com.koftwentytwo.commandtabfree`) + brand sweep + ⌘⇥ icon (final) — all on **`master`** (was `depaywall-free`, fast-forward merged, HEAD `c7d2d968`, pushed to origin; source is public for GPL §6).
|
||||
- **Sparkle EdDSA keypair generated.** Public key in `Info.plist` (`SUPublicEDKey`); private key in 1Password item **"CommandTabFree-Sparkle-EdDSA"** + GitHub Actions secret `SPARKLE_ED_PRIVATE_KEY` (env `production`); keychain account `commandtabfree`. `SUEnableAutomaticChecks=false` until a real appcast host exists. This fixed the launch-time "updater failed to start" alert.
|
||||
- **Public release v100.0.3** (pre-release, **unsigned / not notarized**, universal x86_64+arm64) — ⌘⇥ menu-bar icon, redesigned About (KofTwentyTwo identity + AltTab attribution links), and a **full l10n rebrand** (product name → CommandTabFree across base/en + all ~20 translated languages; upstream attribution kept). Earlier v100.0.0/v100.0.1/v100.0.2 superseded.
|
||||
- **Homebrew tap live:** `brew install --cask koftwentytwo/tap/commandtabfree` (`KofTwentyTwo/homebrew-tap` → `Casks/commandtabfree.rb`). Cask strips the quarantine (postflight `xattr`) — was needed for the unsigned v100.0.x; now that v100.1.0 is notarized, drop that postflight and bump the cask.
|
||||
- **CI/CD wired** (GitHub Actions), **fully green on push** (run `27634828800`, commit `6070f0cb`). Passing: Guard A/B/C, commitlint, generated-files gate, version compute, `run_tests.sh`, ad-hoc `build_app.sh`. The sign/notarize/appcast/semantic-release/gh-release tail is gated on a `release-gate` step that **auto-activates** when `APPLE_*` secrets are added — secrets were added 2026-06-16 and the **full tail is now proven** (signed+notarized **v100.1.0**, run `27643432063`). Fixed this session (2026-06-16): regenerated base `Localizable.strings` to clear the genstrings gate; build ad-hoc until the owner provisions the Apple cert (was inheriting upstream's Developer ID → exit 65). Earlier this session: Guard B grep now matches the shipped `guard false else { return .pro }` patch (was checking the PLAN's never-shipped `if false`/`alt-tab-free [depaywall]` form); `update_appcast.sh` → `--ed-key-file`; commitlint sync-merge `ignores`; dropped upstream-only scripts (AppCenter/website); commit headers must be ≤72 chars. Full status: `docs/CICD-STATUS.md`.
|
||||
- **Local daily driver installed:** `/Applications/CommandTabFree.app` = **Release** build (no QA menu) signed with the `Local Self-Signed` cert (stable signature → no TCC re-grant loop). Remaining: user grants Accessibility + Screen Recording on first launch.
|
||||
|
||||
### Build recipes (verified this session)
|
||||
- **Local (stable, no QA menu):** `xcodebuild -project alt-tab-macos.xcodeproj -scheme Release -derivedDataPath DerivedData CURRENT_PROJECT_VERSION=<v> CODE_SIGN_IDENTITY="Local Self-Signed" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="" OTHER_CODE_SIGN_FLAGS="--timestamp=none" CODE_SIGNING_ALLOWED=YES` (needs `scripts/codesign/setup_local.sh` cert first; user runs it — keychain/admin prompt).
|
||||
- **Public artifact (ad-hoc):** same recipe but `CODE_SIGN_IDENTITY="-" CODE_SIGNING_REQUIRED=NO`. Zip with `ditto -c -k --sequesterRsrc --keepParent`.
|
||||
- **GOTCHA — do NOT pass `-configuration Release`.** With `-scheme Release` it splits SPM package outputs and breaks clean-build module-map generation (`build/GeneratedModuleMaps/*.modulemap not found`, then "unable to resolve module Cocoa/Sparkle/..."). Use the scheme only (matches `scripts/build_app.sh`). A failed build can corrupt `build/`; recover with `rm -rf build DerivedData` then rebuild.
|
||||
|
||||
## ⏳ NEXT (owner-gated — `docs/EXECUTION-STATUS.md` §3)
|
||||
- **Apple Developer ID** (long pole): set `CODE_SIGN_IDENTITY` in `config/local.xcconfig`, re-sign Sparkle helpers, notarize → re-release signed/notarized, then drop the cask's quarantine postflight.
|
||||
- **Real DOMAIN/appcast** (kof22.com ready): replace `fork.invalid` in `local.xcconfig`, serve `/appcast.xml`, flip `SUEnableAutomaticChecks` back on.
|
||||
- **Wire CI:** `update_appcast.sh` uses the deprecated `sign_update -s` — switch to `--ed-key-file` (or `--account commandtabfree`) for the new key; create `sync`/`conflict`/`chokepoint-refresh` labels; external cron for `upstream_sync.yml`.
|
||||
## Gotchas
|
||||
- Bundle id FROZEN (`com.koftwentytwo.commandtabfree`); keychain/UserDefaults suites derive from it.
|
||||
- Don't `git revert 9147a4a8` (mixes paywall with a 1869-file reorg).
|
||||
- Fork version line starts at **v100.0.0** (avoids collision with upstream tags merged into `master`).
|
||||
- `aws`: use `AWS_PROFILE=kingsrook_root_admin aws …` (the `--profile` flag fails on the local wrapper).
|
||||
@@ -0,0 +1,23 @@
|
||||
# TODO — CommandTabFree
|
||||
|
||||
_Updated 2026-06-16. The **publishing pipeline + auto-update are LIVE** — **v100.2.0** released (signed, notarized, Gatekeeper-accepted, auto-updating). The only substantial remaining work is upstream-sync automation. Full detail: `docs/CICD-STATUS.md`, `docs/EXECUTION-STATUS.md` §3._
|
||||
|
||||
## ✅ Done this session (2026-06-16)
|
||||
- [x] **Signed + notarized releases** — Developer ID `James Maes (2X834TJ5MA)`; cert + creds in `production` secrets; `CODE_SIGN_IDENTITY`/Manual/`TEAM_ID` in `config/local.xcconfig`; vendored Sparkle helpers re-signed. Full tail proven (runs `27643432063` v100.1.0, `27647918163` v100.2.0).
|
||||
- [x] **Auto-update** — `DOMAIN = koftwentytwo.github.io/CommandTabFree`, `SUEnableAutomaticChecks = true`, in-tree `appcast.xml` removed + gitignored. GitHub Pages serves the gh-pages feed; v100.2.0 ships auto-updating.
|
||||
- [x] **Homebrew cask** — bumped to v100.2.0 + quarantine-strip postflight dropped (`KofTwentyTwo/homebrew-tap`).
|
||||
- [x] **Release-gate decouple** — sign/notarize/release apparatus gated on a `will-release` signal (empty `VERSION.txt` ⇒ no release); non-release pushes build+test without cutting a release or needing `[skip ci]`.
|
||||
- [x] genstrings gate fixed (`d1e1b296`); ad-hoc-until-cert build (`6070f0cb`); `.fork-sync-state` reset to `v11.3.0` + release tail no longer clobbers it; 3 sync labels created.
|
||||
|
||||
## Remaining — Upstream-sync automation (owner-gated)
|
||||
- [ ] **`SYNC_BOT_TOKEN`** — GitHub App installation token (or fork PAT with contents + pull-requests write). Default `GITHUB_TOKEN` does NOT re-trigger PR checks, so guards never run on the sync PR.
|
||||
- [ ] **External cron** — hit `repository_dispatch` (type `upstream-sync`) from an external scheduler (immune to GitHub's 60-day in-repo `schedule:` auto-disable).
|
||||
- [ ] **Re-wire the `.fork-sync-state` cursor advance** into the upstream-sync merge flow so it records the synced **UPSTREAM** tag (not the fork release tag) — `upstream_sync.yml` (PLAN §4.2).
|
||||
|
||||
## Optional
|
||||
- [ ] kof22.com custom domain for the appcast (one DNS CNAME → `koftwentytwo.github.io`; GitHub redirects the github.io URL, so existing builds keep updating).
|
||||
|
||||
## Bootstrap acceptance tests (after upstream-sync is wired)
|
||||
- [ ] Sync PR through the bot shows guard checks running.
|
||||
- [ ] A deliberately-relocked branch goes RED on `guard.yml`.
|
||||
- [x] Fork build N→N+1 auto-updates via Sparkle — infra live (feed served + v100.2.0 auto-update-enabled); end-to-end N→N+1 confirmed on the next release.
|
||||
@@ -0,0 +1,403 @@
|
||||
# 01 — Core Application Architecture & Lifecycle
|
||||
|
||||
> Audit scope: the app entry point, `App.swift` (`NSApplicationDelegate`), and `src/macos/`
|
||||
> (permissions, accessibility, login item, AX scheduling). This is the spine the rest of the
|
||||
> paywall-removal audit hangs on. **Read-only audit — no source was modified.**
|
||||
|
||||
All `file:line` references are against the tree as audited (branch `master`, commit `9fadf36b`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Entry point & process bootstrap
|
||||
|
||||
The app has **no `@main` / `@NSApplicationMain`**. It uses a top-level `main.swift`
|
||||
(`src/main.swift`) as the executable entry, per the AGENTS.md "pure Swift, no Interface Builder"
|
||||
constraint.
|
||||
|
||||
Boot order in `src/main.swift`:
|
||||
|
||||
1. `src/main.swift:4-6` — CLI fast-path. If launched with a recognized CLI command
|
||||
(`CliClient.detectCommand()`), it forwards the command to the already-running instance via a
|
||||
Mach message port and the process exits early (never becomes the GUI app).
|
||||
2. `src/main.swift:11-15` — installs POSIX signal handlers for `SIGTERM` / `SIGTRAP` that call
|
||||
`emergencyExit(...)`.
|
||||
3. `src/main.swift:19-21` — installs `NSSetUncaughtExceptionHandler` (Obj-C exceptions) → also
|
||||
`emergencyExit(...)`.
|
||||
4. `src/main.swift:23` — `App.shared.run()` enters the AppKit run loop.
|
||||
|
||||
`emergencyExit` (`src/main.swift:33-39`) re-enables the native ⌘-Tab
|
||||
(`setNativeCommandTabEnabled(true)`), prints a stack trace, drains in-flight screen captures
|
||||
(`makeSureAllCapturesAreFinished()`, `src/main.swift:41-51`, which sets `App.isTerminating = true`
|
||||
and spins up to 5 s waiting on `ActiveWindowCaptures.value()`), then `exit(0)`.
|
||||
|
||||
**Paywall touchpoint in entry path:** none. `main.swift` is clean.
|
||||
|
||||
---
|
||||
|
||||
## 2. The `App` class
|
||||
|
||||
`App` (`src/App.swift:7`) subclasses `AppCenterApplication` (a Microsoft AppCenter `NSApplication`
|
||||
subclass, used for crash reporting) and sets itself as its own delegate
|
||||
(`src/App.swift:41-44`, `delegate = self`). It conforms to `NSApplicationDelegate` in the extension
|
||||
at `src/App.swift:434-507`.
|
||||
|
||||
It is a hybrid singleton: instance methods satisfy the `NSApplicationDelegate` protocol, but the
|
||||
bulk of the app's coordination logic lives in `static` methods/properties on `App` (e.g.
|
||||
`App.showUi`, `App.hideUi`, `App.refreshUi`, `App.continueAppLaunchAfterPermissionsAreGranted`).
|
||||
`App.shared` (`src/App.swift:24`) downcasts `NSApplication.shared`.
|
||||
|
||||
Notable static state:
|
||||
- `App.isTerminating` (`src/App.swift:28`) — read by the capture-drain loop.
|
||||
- `App.activity` (`src/App.swift:9-10`) — a `ProcessInfo.beginActivity` token preventing App Nap.
|
||||
- `App.sparkleDelegate` / `App.updaterController` (`src/App.swift:35-36`) — Sparkle auto-update.
|
||||
- Selectors `supportProjectAction` / `upgradeToProAction` / `openAccountAction`
|
||||
(`src/App.swift:25-27`) — wired into the menubar (see §7).
|
||||
|
||||
---
|
||||
|
||||
## 3. Startup sequence (the two-phase launch)
|
||||
|
||||
Launch is **deliberately two-phase**, gated on macOS permissions. This split is structurally
|
||||
important for the removal plan because the paywall is initialized in **phase A** and the rest of the
|
||||
app only boots in **phase B** after permissions pass.
|
||||
|
||||
### Phase A — `applicationDidFinishLaunching` (`src/App.swift:435-463`)
|
||||
|
||||
Runs immediately on launch, before any permission is confirmed:
|
||||
|
||||
1. `:436` `AppCenterCrash()` — crash reporting.
|
||||
2. `:437` `disableRelaunchOnLogin()`.
|
||||
3. `:438-439` `Logger.initialize()` + version log.
|
||||
4. `:444` (release only) `MoveToApplicationsFolder.promptIfNeeded()`.
|
||||
5. `:446` `AXUIElement.setGlobalTimeout()` — sets the global 1 s AX messaging timeout.
|
||||
6. `:447` `Preferences.initialize()` — loads `UserDefaults`, runs migrations.
|
||||
7. **`:448-460` License/Pro wiring (PAYWALL).** Wires three `LicenseManager.shared` callbacks and
|
||||
calls `LicenseManager.shared.initialize()`. Detailed in §6.
|
||||
8. `:461` `BackgroundWork.preStart()` — creates the *minimal* early queues needed for permission
|
||||
polling (`permissionsCheckOnTimerQueue`, `permissionsSystemCallsQueue`, `screenshotsQueue`;
|
||||
`src/util/BackgroundWork.swift:23-30`).
|
||||
9. `:462` `SystemPermissions.ensurePermissionsAreGranted()` — starts the permission timer (see §5).
|
||||
|
||||
Phase A does **not** create the switcher panel, menubar, or any event taps. If permissions are
|
||||
missing, the app sits in the permission-window loop and never proceeds to phase B.
|
||||
|
||||
### Phase B — `App.continueAppLaunchAfterPermissionsAreGranted` (`src/App.swift:383-431`)
|
||||
|
||||
Called **once**, from `SystemPermissions.checkPermissionsPreStartup()`
|
||||
(`src/macos/SystemPermissions.swift:77`), the moment both Accessibility and Screen-Recording
|
||||
permissions are confirmed. This is the real subsystem-wiring sequence:
|
||||
|
||||
| Line | Call | Subsystem |
|
||||
|------|------|-----------|
|
||||
| `:385` | `BackgroundWork.start()` | full set of background threads/queues (§4) |
|
||||
| `:386` | `NSScreen.updatePreferred()` | display state |
|
||||
| `:387` | `Appearance.update()` | switcher theming |
|
||||
| `:388-389` | `TilesPanel.updateMaxPossible…Size()` | layout precompute |
|
||||
| `:390` | `Menubar.initialize()` | menubar status item (**touches license**, §7) |
|
||||
| `:391` | `MainMenu.create()` | app main menu |
|
||||
| `:392-393` | `TilesPanel()` / `PreviewPanel()` | switcher windows |
|
||||
| `:394-395` | `Spaces.refresh()` / `Screens.refresh()` | spaces & displays state |
|
||||
| `:396-405` | `*.observe()` for Spaces, Screens, SystemAppearance, SystemScrollerStyle, InputSource, Cursor, Trackpad, Cli; plus `Applications.initialDiscovery()`; `KeyboardEvents.addEventHandlers()` | event observers / taps (§8) |
|
||||
| `:406-413` | `SparkleDelegate()` + `SPUStandardUpdaterController`, updater started 30 s later | auto-update |
|
||||
| `:414` | `PreferencesEvents.initialize()` | applies side-effects of every preference |
|
||||
| `:415` | `BenchmarkRunner.startIfNeeded()` | dev benchmark |
|
||||
| `:416-420` | first-launch Settings window (`showSettingsWindowOnFirstLaunchIfNeeded`) | UI (**license-aware**, §6) |
|
||||
| `:421-426` | `#if DEBUG` QA menu / debug graph | dev-only |
|
||||
| `:427` | `UsageStats.prune()` | usage telemetry |
|
||||
| **`:428-429`** | **`ProTransitionManager.shared.onAction = …` + `onAppLaunchComplete()`** | **PAYWALL** (§6) |
|
||||
|
||||
`Applications.initialDiscovery()` (`src/App.swift:401`) is also where the NSWorkspace
|
||||
running-applications observer gets installed: it calls `RunningApplicationsEvents.observe()`
|
||||
internally (`src/switcher/state/Applications.swift:20`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Background threading model (`BackgroundWork`)
|
||||
|
||||
`src/util/BackgroundWork.swift`. The app is heavily multithreaded because most macOS hooks
|
||||
(CGEvent taps, AX observers, Mach ports) require their own run loops, and AX calls can block for the
|
||||
1 s timeout.
|
||||
|
||||
- **Dedicated run-loop threads** (`BackgroundThreadWithRunLoop`, `:106-137`):
|
||||
`accessibilityEventsThread`, `keyboardAndMouseAndTrackpadEventsThread`, `missionControlThread`,
|
||||
`cliEventsThread`. Each runs a `CFRunLoopRun()` with a dummy source to stay alive
|
||||
(`:131-136`). Created in `start()` (`:43-49`).
|
||||
- **Operation queues** (`LabeledOperationQueue`, `:140-160`): `repeatingKeyQueue`,
|
||||
`screenshotsQueue`, `accessibilityCommandsQueue`, `focusOrderQueue`, `crashReportsQueue`,
|
||||
`permissionsCheckOnTimerQueue`, `permissionsSystemCallsQueue`.
|
||||
- `preStart()` (`:23-30`) creates only the three queues needed before permissions; `start()`
|
||||
(`:32-50`) creates the rest in phase B.
|
||||
- Thread budget is policed: `addPotentialThreadCount` asserts the total stays ≤ 45 against the
|
||||
~64-thread process limit (`:59-63`).
|
||||
|
||||
The separate `AXCallScheduler` (`src/macos/AXCallScheduler.swift`) owns three more bounded pools
|
||||
for *outgoing* AX queries (`axQueryFirstTryQueue` 10, `axQueryScanQueue` 6, `axQueryRetryQueue` 8;
|
||||
`:43-45`). It is a per-key serializing executor with retry/backoff for unresponsive apps
|
||||
(`schedule(...)`, `:51-71`; `attemptBlock`, `:113-169`). It does **no** throttling/coalescing —
|
||||
that is the caller's job. **No paywall coupling.**
|
||||
|
||||
**Paywall touchpoint in threading:** none. `BackgroundWork` and `AXCallScheduler` are clean.
|
||||
|
||||
---
|
||||
|
||||
## 5. How permissions are requested (`src/macos/SystemPermissions.swift`)
|
||||
|
||||
`SystemPermissions` drives a `DispatchSourceTimer` on `permissionsCheckOnTimerQueue`.
|
||||
|
||||
- `ensurePermissionsAreGranted()` (`:22-27`) creates the timer with handler
|
||||
`checkPermissionsOnTimer` and fires it immediately.
|
||||
- **Pre-startup** (`checkPermissionsPreStartup`, `:70-84`): if both
|
||||
`AccessibilityPermission.status` and `ScreenRecordingPermission.status` are not-`.notGranted`,
|
||||
it flips `preStartupPermissionsPassed = true`, closes the permission window, switches to the
|
||||
infrequent timer, starts the distributed-notification revoke listener, and calls
|
||||
**`App.continueAppLaunchAfterPermissionsAreGranted()`** (`:77`). Otherwise it shows the
|
||||
permissions window (`:81`).
|
||||
- **Post-startup** (`checkPermissionsPostStartup`, `:86-91`): if Accessibility is revoked while
|
||||
running, it restarts the app (`App.restart()`).
|
||||
- **Revoke detection** (`startListeningForDistributedRevoke`, `:29-43`): observes the undocumented
|
||||
`com.apple.accessibility.api` distributed notification; on revoke → `App.restart()`. A sparse
|
||||
60 s backstop timer covers cases where the notification doesn't fire (`setInfrequentTimer`,
|
||||
`:96-103`).
|
||||
- **Accessibility detection** (`AccessibilityPermission`, `:116-128`): `AXIsProcessTrustedWithOptions`
|
||||
with `kAXTrustedCheckOptionPrompt: false`.
|
||||
- **Screen-recording detection** (`ScreenRecordingPermission`, `:130-217`): via
|
||||
`SCShareableContent.getExcludingDesktopWindows` (macOS 12.3+) or `CGDisplayStream` probing,
|
||||
each wrapped in a 6 s timeout (`runWithTimeout`, `:202-217`). Honors
|
||||
`Preferences.screenRecordingPermissionSkipped` (`:141`).
|
||||
|
||||
The permission UI window (`PermissionsWindow`) lives outside this scope
|
||||
(`src/secondary-windows/permission-window/`).
|
||||
|
||||
**Paywall touchpoint in permissions:** none. `SystemPermissions.swift` is clean.
|
||||
|
||||
---
|
||||
|
||||
## 6. PAYWALL — every place the spine touches License / Pro
|
||||
|
||||
This section is the deliverable's core. Below is **every** reference in the spine (`App.swift`,
|
||||
`Menubar.swift`, `main.swift`, `src/macos/`) to `LicenseManager`, `ProTransitionManager`,
|
||||
`ProPromptHost`, checkout, and `UpgradeTab`.
|
||||
|
||||
### 6.1 `App.swift` — license wiring & callbacks
|
||||
|
||||
| `file:line` | Code | Purpose |
|
||||
|-------------|------|---------|
|
||||
| `src/App.swift:26` | `static var upgradeToProAction: Selector { #selector(App.upgradeToPro) }` | selector exposed to menubar "Get Pro" |
|
||||
| `src/App.swift:27` | `static var openAccountAction: Selector { #selector(App.openAccount) }` | selector for menubar "My Account" |
|
||||
| `src/App.swift:78` | `ProTransitionManager.shared.onSwitcherDismissed()` | inside `hideUi()` — ends free-pass session, queues post-dismiss upsell windows |
|
||||
| `src/App.swift:115-117` | `@objc static func upgradeToPro() { ProTransitionManager.openCheckout() }` | opens checkout URL |
|
||||
| `src/App.swift:119-121` | `@objc static func openAccount() { UpgradeTab.openAccountPage() }` | opens account page |
|
||||
| `src/App.swift:206-207` | `if case .pro = LicenseManager.shared.state { return false }` / `return !ProTransitionManager.shared.hasSeenWelcome` | `willShowDay1WelcomeOnAppLaunch()` — defers first-launch Settings if the Day-1 welcome nag will show |
|
||||
| `src/App.swift:328` | `ProTransitionManager.shared.onSwitcherShown()` | inside `showUiOrCycleSelection()` — decides Day-4 tour or post-expiration free-pass |
|
||||
| `src/App.swift:428` | `ProTransitionManager.shared.onAction = { ProPromptHost.shared.dispatch($0) }` | binds the prompt-action emitter to the Day-X UI host |
|
||||
| `src/App.swift:429` | `ProTransitionManager.shared.onAppLaunchComplete()` | starts the trial-nag scheduler |
|
||||
| `src/App.swift:448` | `LicenseManager.shared.onBeforeProUnlock = { ProTransitionManager.shared.onProUnlocked() }` | restore Pro prefs on activation |
|
||||
| `src/App.swift:449-459` | `LicenseManager.shared.onStateChanged = { state in … }` | the big state-change fan-out (see below) |
|
||||
| `src/App.swift:460` | `LicenseManager.shared.initialize()` | computes initial license/trial state; **first thing that touches Keychain/UserDefaults trial bookkeeping** |
|
||||
| `src/App.swift:480` | `UpgradeTab.showAutoActivating(licenseKey)` | custom-URL `alttab://activate` handler |
|
||||
| `src/App.swift:481-489` | `LicenseManager.shared.activate(licenseKey) { … }` (+ `UpgradeTab.showAutoActivation{Success,Failed}`) | license activation via custom URL |
|
||||
|
||||
The `onStateChanged` closure (`src/App.swift:449-459`) does six things on every license-state
|
||||
transition:
|
||||
1. `Menubar.refreshLicenseMenuItems()` (`:450`)
|
||||
2. `syncLicenseCookie(state:)` (`:451`) — defined in `src/pro/license/LicenseCookie.swift:5`,
|
||||
writes a Sparkle HTTP cookie so the appcast can serve license-aware updates.
|
||||
3. `ProTransitionManager.shared.onLicenseStateChanged()` (`:452`)
|
||||
4. `UpgradeTab.refreshStatus()` (`:453`)
|
||||
5. `SettingsWindow.shared?.refreshUpgradeButton()` (`:454`)
|
||||
6. `App.resetPreferencesDependentComponents()` if `TilesPanel.shared != nil` (`:455`), then posts
|
||||
`ProTransitionManager.proLockStateDidChangeNotification` (`:458`).
|
||||
|
||||
### 6.2 `App.swift` custom-URL scheme — license activation
|
||||
|
||||
`application(_:open:)` (`src/App.swift:465-471`) routes `alttab://` URLs to `handleCustomUrl`
|
||||
(`:473-490`), which handles **only** `host == "activate"` with a `license_key` query item, calling
|
||||
`LicenseManager.shared.activate(...)`. **The entire custom-URL handler exists solely for license
|
||||
activation** — there is no other URL scheme use in the spine.
|
||||
|
||||
### 6.3 `Menubar.swift` — license-driven menu items & badge
|
||||
|
||||
`Menubar` is the other spine file with deep paywall coupling (it's created in phase B at
|
||||
`src/App.swift:390`):
|
||||
|
||||
| `file:line` | Purpose |
|
||||
|-------------|---------|
|
||||
| `src/Menubar.swift:46-47` | builds the "Get Pro" item with a custom `UpgradeMenuItemView` |
|
||||
| `src/Menubar.swift:48` | "My Account" item (`App.openAccountAction`) |
|
||||
| `src/Menubar.swift:49` | "Support this project" item |
|
||||
| `src/Menubar.swift:50` | calls `refreshLicenseMenuItems()` during build |
|
||||
| `src/Menubar.swift:84-107` | `refreshLicenseMenuItems()` — switches visibility of Get-Pro/Account/Support items on `LicenseManager.shared.state` |
|
||||
| `src/Menubar.swift:109-118` | `toggleUpgradeMenuItem()` — inserts/removes the Get-Pro item |
|
||||
| `src/Menubar.swift:156-158` | comment referencing `LicenseManager.initialize()` ordering vs `statusItem` nil |
|
||||
| `src/Menubar.swift:202-227` | `updateBadgeDotOverlay()` — gated on `ProTransitionManager.shared.shouldShowBadgeDot` (orange nag dot on the menubar icon) |
|
||||
| `src/Menubar.swift:235-390` | `UpgradeMenuItemView` — the entire gradient/shine "Get Pro" menu cell, uses `ProGradient.makeLayer()` and `App.upgradeToPro()`; `updateContent(_ state: LicenseState)` renders trial-days-remaining |
|
||||
| `src/Menubar.swift:392-399` | `MenubarMenuDelegate.menuWillOpen` → `LicenseManager.shared.refreshState()` |
|
||||
|
||||
### 6.4 `src/macos/` — license touchpoints
|
||||
|
||||
**None.** `SystemPermissions.swift`, `BackgroundWork.swift`, `AXCallScheduler.swift`,
|
||||
`LoginItem.swift` contain no License/Pro references (verified by grep). The `src/macos/` layer is
|
||||
paywall-free.
|
||||
|
||||
### 6.5 The contract the spine depends on (for the removal plan)
|
||||
|
||||
`App.swift` depends on this public surface of the Pro module:
|
||||
|
||||
- `LicenseManager.shared` (singleton, `src/pro/license/LicenseManager.swift:7-15`), with:
|
||||
`.state: LicenseState` (`:51`), `.initialize()` (`:91`), `.refreshState()` (`:99`),
|
||||
`.activate(_:completion:)` (`:104`), `.onStateChanged` (`:38`), `.onBeforeProUnlock` (`:49`),
|
||||
`.isProLocked` (`:67`), `.isProAvailable` (`:62`), `.daysSinceTrialStart` (`:79`),
|
||||
`.customerEmail` (`:55`).
|
||||
- `LicenseState` enum (`src/pro/license/LicenseState.swift:1-5`): `.trial(daysRemaining:)`,
|
||||
`.pro`, `.proExpired`, `.trialExpired`.
|
||||
- `ProTransitionManager.shared` (`src/pro/scheduling/ProTransitionManager.swift:105`), with the
|
||||
lifecycle hooks `onSwitcherShown/Dismissed`, `onAppLaunchComplete`, `onLicenseStateChanged`,
|
||||
`onProUnlocked`, `onAction`, `hasSeenWelcome`, `shouldShowBadgeDot`, the static
|
||||
`openCheckout()` (`:307`) and `proLockStateDidChangeNotification` (`:110`).
|
||||
- `ProPromptHost.shared.dispatch(_:)` (`src/pro/ui/ProPromptHost.swift:9-11`).
|
||||
- `UpgradeTab` static methods (`src/preferences/settings-window/tabs/UpgradeTab.swift`).
|
||||
- `syncLicenseCookie(state:)` (`src/pro/license/LicenseCookie.swift:5`).
|
||||
|
||||
The **runtime feature gate** itself is `ProFeature.attemptUse()`
|
||||
(`src/pro/ProFeature.swift:74-83`): returns `true` if `isProAvailable`, else if a free-pass
|
||||
session is active, else routes hard-gated features through
|
||||
`ProTransitionManager.shared.attemptHardGatedFeature(...)`. Call sites for the gate live outside
|
||||
this scope (TilesView, ShortcutAction, SearchModeResolver) — see §9.
|
||||
|
||||
---
|
||||
|
||||
## 7. App-menu / window plumbing (license-adjacent)
|
||||
|
||||
`App` exposes several `@objc` window/show helpers wired into the menubar and main menu:
|
||||
`showSettingsWindow` (`:137`), `showAboutWindow` (`:151`), `showFeedbackPanel` (`:123`),
|
||||
`showDebugWindow` (`:132`), `showPermissionsWindow` (`:235`), `checkForUpdatesNow` (`:103`),
|
||||
`checkPermissions` (`:107`), `supportProject` (`:111`). Of these, only `supportProject`,
|
||||
`upgradeToPro`, `openAccount` are paywall-adjacent.
|
||||
|
||||
First-launch Settings logic (`showSettingsWindowOnFirstLaunchIfNeeded`, `:189-233`) is
|
||||
**entangled with the Day-1 welcome nag**: it checks `willShowDay1WelcomeOnAppLaunch()`
|
||||
(`:205-208`, reads `LicenseManager.shared.state` and `ProTransitionManager.shared.hasSeenWelcome`)
|
||||
and, if the welcome window will appear, defers showing Settings until a `Day1WelcomeLetterWindow`
|
||||
`willCloseNotification` fires (`:210-220`).
|
||||
|
||||
---
|
||||
|
||||
## 8. How AltTab hooks into macOS (event taps, AX observers, NSWorkspace)
|
||||
|
||||
The hooks are installed in phase B and live mostly under `src/events/`. Summary of the kernel of
|
||||
macOS integration the rest of the audit will reference:
|
||||
|
||||
- **CGEvent tap (keyboard)** — `KeyboardEvents.addCgEventTap()`
|
||||
(`src/events/KeyboardEvents.swift:132-152`): a `.cghidEventTap` / `.headInsertEventTap` /
|
||||
`.defaultTap` tap for `.flagsChanged` + `.keyDown`, added to
|
||||
`BackgroundWork.keyboardAndMouseAndTrackpadEventsThread`'s run loop. If `tapCreate` returns nil
|
||||
(permission lost) → `App.restart()` (`:150`). Also a local `NSEvent` monitor
|
||||
(`:123-130`). The tap's `.keyDown` branch only absorbs Esc, and only when a shortcut binds it
|
||||
and the switcher is active (issue #5585 workaround, `:33-44`).
|
||||
- **Carbon hotkeys** — `RegisterEventHotKey` for the global switcher shortcuts
|
||||
(`registerHotKeyIfNeeded`, `:104-120`; handlers installed in `addGlobalHandlerIfNeeded`,
|
||||
`:154-173`). `toggleGlobalShortcuts` (`:64-75`) registers/unregisters them when an app exception
|
||||
disables AltTab.
|
||||
- **Accessibility (AX) observers** — `AccessibilityEvents.axObserverCallback`
|
||||
(`src/events/AccessibilityEvents.swift:6-21`). Subscriptions bake `(pid, wid)` into the
|
||||
`refcon` (`subscriptionRefcon`, `:25-28`). Focus/activation use an IPC-free fast lane
|
||||
(`updateFocusOrderFastLane`, `:38-56`) on `BackgroundWork.focusOrderQueue`; everything else is
|
||||
scheduled through `AXCallScheduler.shared` and dispatched to `handleEventApp` / `handleEventWindow`.
|
||||
These feed the window inventory (`Windows`, `Applications`) and drive
|
||||
`App.refreshOpenUiAfterExternalEvent` / `App.checkIfShortcutsShouldBeDisabled`.
|
||||
- **NSWorkspace** — `RunningApplicationsEvents.observe()`
|
||||
(`src/events/RunningApplicationsEvents.swift:6-10`): KVO on
|
||||
`NSWorkspace.shared.runningApplications` (not the launch/terminate notifications, which miss
|
||||
non-GUI apps), feeding `Applications.addRunningApplications` / `removeRunningApplications`.
|
||||
- **Other observers** wired at `src/App.swift:396-405`: Spaces, Screens, system appearance,
|
||||
scroller style, input source, cursor, trackpad, CLI (Mach message port).
|
||||
- **Login item** — `src/macos/LoginItem.swift` writes/removes a launchd `LaunchAgents` plist
|
||||
keyed by `App.bundleIdentifier`; reconciled from `Preferences.startAtLogin` via
|
||||
`PreferencesEvents`.
|
||||
|
||||
None of the macOS hook installation is paywall-coupled. The only license interaction at runtime
|
||||
is via `ProFeature.attemptUse()` invoked from feature call sites (§9), not from the tap/observer
|
||||
plumbing itself.
|
||||
|
||||
---
|
||||
|
||||
## 9. Runtime feature gates reachable from the spine (out-of-scope files, noted for the plan)
|
||||
|
||||
The spine wires the managers; the actual *gating* happens when features run. For continuity with
|
||||
later audit sections, the `ProFeature.attemptUse()` (`src/pro/ProFeature.swift:74`) call sites are:
|
||||
|
||||
- `src/switcher/main-window/TilesView.swift:79` (`lockSearchInSwitcher`), `:92` (`searchInSwitcher`)
|
||||
- `src/switcher/ShortcutAction.swift:56` (`extraShortcut(index:)`)
|
||||
- preference read interception: `src/preferences/PreferenceDefinition.swift:34-35`
|
||||
(`isProLocked` + `isFreePassSessionActive` swap stored Pro values for Free)
|
||||
|
||||
And the broader (non-spine) paywall coupling surface — settings UI badges, upgrade tab, and the
|
||||
preference setter that bounces to the Upgrade tab — is concentrated in `src/preferences/...`,
|
||||
`src/Menubar.swift`, `src/events/PreferencesEvents.swift:62-63`, and `src/debug/QAMenu.swift`
|
||||
(DEBUG-only). These are covered by later audit sections; they are listed here only so the spine's
|
||||
fan-out is traceable.
|
||||
|
||||
---
|
||||
|
||||
## 10. Removal-plan notes (spine only)
|
||||
|
||||
**Goal:** every `ProFeature.attemptUse()` should behave as if `isProAvailable == true`, and all
|
||||
trial-nag / upsell windows and license activation should be gone, without breaking the two-phase
|
||||
launch.
|
||||
|
||||
1. **`App.continueAppLaunchAfterPermissionsAreGranted` must keep its non-Pro work.** Only
|
||||
`src/App.swift:428-429` (the `onAction` binding + `onAppLaunchComplete`) is Pro. Removing those
|
||||
two lines stops the trial-nag scheduler. Everything else in this method (queues, panels,
|
||||
observers, Sparkle, first-launch Settings) must stay.
|
||||
2. **`applicationDidFinishLaunching` license block (`src/App.swift:448-460`)** is the cleanest cut
|
||||
point. If the Pro module is deleted wholesale, all 13 lines (`:448-459` callback wiring +
|
||||
`:460` `initialize()`) go. **Risk:** `LicenseManager.shared.initialize()` is currently the
|
||||
place that seeds `trialStartDate` (`src/pro/license/LicenseManager.swift:193-194`); removing it
|
||||
is fine for a free build (nothing reads trial state once gating is unconditional), but any
|
||||
leftover reader of `LicenseManager.shared.state` will crash if the singleton is deleted.
|
||||
3. **`onStateChanged` fan-out (`src/App.swift:449-459`)** has one non-Pro side effect worth noting:
|
||||
`App.resetPreferencesDependentComponents()` (`:455`) and the
|
||||
`proLockStateDidChangeNotification` post (`:458`). Both become dead once Pro is removed, but
|
||||
`resetPreferencesDependentComponents()` itself (`src/App.swift:51-53`) is a legitimate function
|
||||
used elsewhere — keep the function, drop this caller.
|
||||
4. **`hideUi` / `showUiOrCycleSelection`** each have exactly one Pro line
|
||||
(`src/App.swift:78` and `:328`). Remove the `ProTransitionManager.shared.onSwitcher*` calls;
|
||||
the surrounding session logic is independent.
|
||||
5. **`upgradeToPro` / `openAccount` / `supportProject`** (`src/App.swift:111-121`) and their
|
||||
selectors (`:25-27`) are only referenced by the menubar. Removing the corresponding
|
||||
`Menubar` items (`src/Menubar.swift:46-50`, the entire `UpgradeMenuItemView`
|
||||
`:235-390`, `refreshLicenseMenuItems` `:84-118`, badge dot `:202-227`, and the
|
||||
`MenubarMenuDelegate.menuWillOpen` license refresh `:392-399`) lets these selectors be deleted.
|
||||
`supportProject` (`Endpoints.supportUrl`) is a donation link, not a paywall — keep or drop per
|
||||
the owner's call.
|
||||
6. **Custom-URL handler (`src/App.swift:465-490`).** The entire `application(_:open:)` +
|
||||
`handleCustomUrl` exists only for license activation. It can be removed entirely (and the
|
||||
`alttab://` URL scheme dropped from `Info.plist` — out of this scope but flagged).
|
||||
7. **First-launch welcome entanglement (`src/App.swift:189-233`).** `willShowDay1WelcomeOnAppLaunch`
|
||||
(`:205-208`) and the deferral observer (`:210-220`) reference Pro. With the welcome nag gone,
|
||||
simplify `showSettingsWindowOnFirstLaunchIfNeeded` to always call
|
||||
`showAndCenterSettingsWindowOnFirstLaunch()` directly. **Risk:** if you delete
|
||||
`Day1WelcomeLetterWindow` but leave the `willCloseNotification` observer, the observer never
|
||||
fires and Settings never shows on first launch — remove the observer path together with the nag.
|
||||
8. **Keychain / signing invariant (from AGENTS.md).** A free build that *keeps* `LicenseManager`
|
||||
alive (e.g. to avoid touching many call sites) must preserve `App.bundleIdentifier`, TeamID, and
|
||||
Developer ID, because `LicenseManager.keychainService` is `"\(App.bundleIdentifier).license"`
|
||||
(`src/pro/license/LicenseManager.swift:4`). A clean free build that deletes the Pro module
|
||||
entirely removes all Keychain usage and sidesteps this — preferred. Either way, do **not**
|
||||
rotate bundle ID without the migration AGENTS.md describes.
|
||||
9. **`src/macos/` is safe.** No file under `src/macos/` references the paywall; the permission
|
||||
loop, AX scheduler, and login item need no changes. The phase-A→phase-B handoff
|
||||
(`SystemPermissions.swift:77` → `App.continueAppLaunchAfterPermissionsAreGranted`) is the
|
||||
critical path and is paywall-free.
|
||||
|
||||
---
|
||||
|
||||
### Appendix — files read for this section
|
||||
|
||||
`src/main.swift`, `src/App.swift`, `src/Menubar.swift`, `src/macos/SystemPermissions.swift`,
|
||||
`src/macos/AXCallScheduler.swift`, `src/macos/LoginItem.swift`, `src/util/BackgroundWork.swift`,
|
||||
`src/events/KeyboardEvents.swift`, `src/events/AccessibilityEvents.swift`,
|
||||
`src/events/RunningApplicationsEvents.swift`, `src/switcher/SwitcherSession.swift`,
|
||||
`src/pro/scheduling/ProTransitionManager.swift`, `src/pro/license/LicenseManager.swift`,
|
||||
`src/pro/license/LicenseState.swift`, `src/pro/ProFeature.swift`,
|
||||
`src/pro/ui/ProPromptHost.swift` (surface only), `src/pro/license/LicenseCookie.swift` (ref only).
|
||||
@@ -0,0 +1,523 @@
|
||||
# Audit 02 — Switcher UI (the product itself)
|
||||
|
||||
Scope: `src/switcher/` and `src/events/`. This section covers how windows are
|
||||
enumerated and modeled, how the switcher panel is rendered/updated, how
|
||||
cycling/selection/focus works, the keyboard/shortcut event flow, and how all of
|
||||
this consults preferences. Pro-gated behavior that surfaces in this subsystem is
|
||||
called out explicitly with `file:line` references and summarized at the end.
|
||||
|
||||
All paths are relative to the repo root `/Users/james.maes/Git.Local/kof22/alt-tab-free`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Window enumeration and modeling
|
||||
|
||||
### 1.1 The data model
|
||||
|
||||
The product is built around two model layers:
|
||||
|
||||
- **Live AppKit-coupled objects** — `Window` (`src/switcher/state/Window.swift`)
|
||||
and `Application` (`src/switcher/state/Application.swift`). These hold
|
||||
`AXUIElement` handles, AX observers, thumbnails, icons, etc.
|
||||
- **Pure value records** — `WindowState` (`src/switcher/state/WindowState.swift`)
|
||||
and `ApplicationState`. These are `Equatable` structs containing only the
|
||||
"facts" (`isMinimized`, `isFullscreen`, `spaceIds`, `title`, `lastFocusOrder`,
|
||||
…) and are passed into the unit-testable decision "kernels"
|
||||
(`WindowFilterResolver`, `WindowOrderResolver`, `SelectionResolver`,
|
||||
`ExceptionMatcher`, `SearchModeResolver`).
|
||||
|
||||
`Window` is `@dynamicMemberLookup` over `WindowState`
|
||||
(`src/switcher/state/Window.swift:3`, subscript at `:43-46`), so `window.title`,
|
||||
`window.isMinimized`, etc. forward to `window.state`. This is purely a
|
||||
convenience and has no paywall coupling.
|
||||
|
||||
Per-window fields of interest (`Window.swift:19-38`): `state`, `cgWindowId`,
|
||||
`thumbnail: CALayerContents?`, `icon` (delegates to `application.icon`),
|
||||
`shouldShowTheUser`, `tabbedSiblingWids`, `position`/`size`, `screenId`,
|
||||
`axUiElement`, `application`, `axObserver`, `rowIndex`, search-result caches
|
||||
(`swAppResults`, `swTitleResults`, `swBestSimilarity`, `lastSearchQuery`).
|
||||
|
||||
### 1.2 How windows are discovered
|
||||
|
||||
Discovery is **event-driven**, not poll-driven, and is rooted in `src/events/`:
|
||||
|
||||
1. **Accessibility (AX) notifications** drive everything. Each `Window` subscribes
|
||||
to a fixed set of AX notifications on creation
|
||||
(`Window.swift:5-12`: destroyed/title-changed/miniaturized/deminiaturized/
|
||||
resized/moved) via `observeEvents()` (`Window.swift:134-150`). The callback is
|
||||
`AccessibilityEvents.axObserverCallback`
|
||||
(`src/events/AccessibilityEvents.swift:6-21`).
|
||||
2. The callback decodes the owning `(pid, wid)` from a packed `refcon`
|
||||
(`AccessibilityEvents.swift:25-33`) and routes through `AxEventRouting`
|
||||
(`src/events/AxEventRouting.swift`) to either app-level (`handleEventApp`,
|
||||
`AccessibilityEvents.swift:95-107`) or window-level (`handleEventWindow`,
|
||||
`:141-175`) handling.
|
||||
3. `handleEventWindow` reads AX attributes (title, subrole, role, size, position,
|
||||
fullscreen, minimized, children for tab detection) and calls
|
||||
`Windows.findOrCreate(...)` (`AccessibilityEvents.swift:151`).
|
||||
4. `Windows.findOrCreate` (`src/switcher/state/Windows.swift:390-400`) either
|
||||
updates an existing window (matched by `cgWindowId` via `byWindowId` or by
|
||||
`isEqualRobust`) or, gated by `WindowDiscriminator.isActualWindow(...)`, creates
|
||||
a new `Window` and calls `appendWindow`.
|
||||
|
||||
`WindowDiscriminator` (`src/switcher/state/WindowDiscriminator.swift`) is the
|
||||
"is this a real, user-switchable window?" filter: it rejects `wid == 0`, missing
|
||||
size, and windows smaller than 100x50 (`:8-19`), then accepts based on subrole
|
||||
(`kAXStandardWindowSubrole`/`kAXDialogSubrole`) plus a large set of
|
||||
app-specific special cases (Adobe, Steam, Firefox, VLC, JetBrains, Keynote,
|
||||
Books, Crossover/Wine, scrcpy, AutoCAD, etc., `:20-44` and helpers). No paywall
|
||||
logic here.
|
||||
|
||||
`appendWindow` (`Windows.swift:402-411`) appends to the canonical
|
||||
`Windows.list`, registers it in `byWindowId`, assigns an initial
|
||||
`lastFocusOrder`, and grows the recycled `TileView` pool if needed
|
||||
(`TilesView.recycledViews`).
|
||||
|
||||
### 1.3 Spaces / screens / tab groups
|
||||
|
||||
- `Window.updateSpacesAndScreen` (`Window.swift:301-307`) populates `spaceIds`,
|
||||
`spaceIndexes`, `isOnAllSpaces`, and `screenId` from CGS, with a workaround for
|
||||
inactive tabs (`:309-320`).
|
||||
- `recomputeIsInvisible()` (`Window.swift:124-126`) flags "ghost" windows
|
||||
(no Space, not tabbed/minimized/hidden).
|
||||
- Tab groups are tracked via `TabGroup` (`src/switcher/state/TabGroup.swift`) and
|
||||
`tabbedSiblingWids`.
|
||||
|
||||
### 1.4 Window lifecycle / removal
|
||||
|
||||
`Windows.removeWindows` (`Windows.swift:413-483`) is the teardown path. Of note
|
||||
(all memory-hygiene, no paywall): it releases pooled `TileView` IOSurfaces
|
||||
(`:419-425`), clears `PreviewPanel` if it was showing a removed window
|
||||
(`:427-431`), detaches the per-window AX observer runloop source via
|
||||
`releaseAxObserver()` (`Window.swift:101-108`, `Windows.swift:473`), strips
|
||||
`AXCallScheduler`/throttler entries, and re-shifts `lastFocusOrder`. It ends with
|
||||
`App.refreshOpenUiAfterExternalEvent([], windowRemoved: true)`.
|
||||
|
||||
### 1.5 Per-show refresh
|
||||
|
||||
`Windows.updatesBeforeShowing()` (`Windows.swift:63-82`) runs each time the
|
||||
switcher is summoned: refreshes Spaces, snapshots per-shortcut filter preferences
|
||||
once (`WindowFilters.snapshot()`, `Windows.swift:507-519`), recomputes
|
||||
`shouldShowTheUser` per window via `WindowFilterResolver.shouldShow(...)`
|
||||
(`Windows.swift:105-125`), applies "only main windows" grouping
|
||||
(`refreshWhichWindowsToShowTheUser`, `:89-103`, gated on
|
||||
`Preferences.onlyShowMainWindows()`), and sorts. It returns `false` (and the show
|
||||
aborts) if Mission Control is showing all/front windows.
|
||||
|
||||
---
|
||||
|
||||
## 2. The switcher panel: rendering and updating
|
||||
|
||||
### 2.1 Panel objects
|
||||
|
||||
- **`TilesPanel`** (`src/switcher/main-window/TilesPanel.swift`) — the main
|
||||
`NSPanel`. `.nonactivatingPanel`, floating, `level = .popUpMenu`, joins all
|
||||
Spaces, clear background, vibrant dark/light appearance
|
||||
(`TilesPanel.swift:11-32`, appearance at `:34-37`). Its content view is
|
||||
`TilesView.contentView`. Singleton `TilesPanel.shared`. Created in
|
||||
`App.swift:392` (`_ = TilesPanel()`).
|
||||
- **`PreviewPanel`** (`src/switcher/PreviewPanel.swift`) — a separate `NSPanel`
|
||||
that shows a full-resolution preview of the *selected* window, ordered just
|
||||
below `TilesPanel` (`PreviewPanel.swift:46-53`). Created in `App.swift:393`.
|
||||
**This is the "preview selected window" feature** (see §6).
|
||||
|
||||
### 2.2 `TilesView` — the layout/render engine
|
||||
|
||||
`TilesView` (`src/switcher/main-window/TilesView.swift`) is a static class that
|
||||
owns the scroll view, search field, "No Window" label, the recycled tile pool,
|
||||
and the layout math. Key flow:
|
||||
|
||||
- `initialize()` (`:29-38`) sets up the search field, the no-window label, the
|
||||
background effect view, and pre-allocates **20** `TileView` objects into
|
||||
`recycledViews`.
|
||||
- **Cell recycling**: `TileView`s are reused across shows. `recycledViews[i]` is
|
||||
bound to `Windows.list[i]` during layout. Tiles past the visible/list count get
|
||||
their image references released and `window_ = nil`
|
||||
(`:514-519`, `:455`).
|
||||
- `updateItemsAndLayout(_:)` (`:386-412`) is the entry point called by
|
||||
`TilesPanel.updateContents` (`TilesPanel.swift:39-49`). It computes max width,
|
||||
optionally resolves "auto" size, lays out tiles into rows
|
||||
(`layoutTileViews`, `:471-529`), positions parent views
|
||||
(`layoutParentViews`, `:549-601`), centers rows, and re-highlights the
|
||||
selection.
|
||||
- `TileView` (`src/switcher/main-window/TileView.swift`) renders a single cell:
|
||||
thumbnail layer, app-icon layer, title label, status icons, dock label badge,
|
||||
windowless-app indicator. The chosen content depends on the **appearance
|
||||
style** (`thumbnails` / `appIcons` / `titles`) read via
|
||||
`Preferences.effectiveAppearanceStyle(...)` (e.g. `TileView.swift:52, 64, 82`).
|
||||
- Companion views: `TileOverView` (hover/window-controls overlay + hit-testing),
|
||||
`TileUnderLayer` (highlight rectangle), `StatusIconsView`, `TileTitleView`,
|
||||
`TileFontIconView`, `TilesPanelBackgroundView` (all under
|
||||
`src/switcher/main-window/`).
|
||||
|
||||
### 2.3 Show / hide / refresh orchestration (in `src/App.swift`)
|
||||
|
||||
The switcher's lifecycle methods live in `App.swift`, not in `src/switcher/`, but
|
||||
they are the orchestration glue:
|
||||
|
||||
- `App.showUiOrCycleSelection(shortcutIndex, forceDoNothingOnRelease)`
|
||||
(`App.swift:307-351`) — creates the `SwitcherSession` if absent, records usage
|
||||
(`UsageStats.recordTrigger`, see §6), and on the first summon (or a
|
||||
different shortcut index) rebuilds: updates preferred screen, sorts by level on
|
||||
the very first summon, hides the panel (`alpha = 0`) to mask the rebuild,
|
||||
**notifies the trial/upsell scheduler** via
|
||||
`ProTransitionManager.shared.onSwitcherShown()` (`App.swift:328`, see §6),
|
||||
starts the search session, runs `updatesBeforeShowing`, picks the initial
|
||||
selection, then displays after `Preferences.windowDisplayDelay`. If the same
|
||||
shortcut is held, it cycles instead (`:347-350`).
|
||||
- `App.buildUiAndShowPanel()` (`App.swift:353-369`) — updates Appearance, swaps
|
||||
the background effect view, refreshes UI, calls `TilesPanel.shared.show()`,
|
||||
previews the selected window, kicks off thumbnail capture.
|
||||
- `App.refreshUi(preserveScrollPosition)` (`App.swift:293-305`) — the in-session
|
||||
redraw: `Windows.updateSelectedWindow()` → `TilesPanel.updateContents(...)` →
|
||||
VoiceOver → `WindowThumbnails.previewSelectedIfNeeded()` →
|
||||
`Applications.refreshBadgesAsync()`. Heavily guarded by
|
||||
`SwitcherSession.isActive` between each step (race-safety).
|
||||
- `App.refreshOpenUiAfterExternalEvent(...)` (`App.swift:284-291`) — throttled
|
||||
redraw triggered by AX events (window created/moved/focused/destroyed).
|
||||
- `App.hideUi(keepPreview)` (`App.swift:63-79`) — tears down the session:
|
||||
`SwitcherSession.current = nil`, `UsageStats.resetSession()`, end search,
|
||||
toggle context-menu/cursor/trackpad event handling off, order the panel out,
|
||||
and **notify the scheduler** via
|
||||
`ProTransitionManager.shared.onSwitcherDismissed()` (`App.swift:78`, see §6).
|
||||
|
||||
### 2.4 `SwitcherSession` — per-invocation state
|
||||
|
||||
`SwitcherSession` (`src/switcher/SwitcherSession.swift`) holds everything scoped
|
||||
to one show: `shortcutIndex`, `isFirstSummon`, `forceDoNothingOnRelease`,
|
||||
`selectedIndex`, `hoveredIndex`, `selectedTarget` (window id for stable
|
||||
re-selection), `searchQuery`. `SwitcherSession.current != nil` is the canonical
|
||||
"panel is conceptually shown" flag (`isActive`, `:11`).
|
||||
`SwitcherSession.activeShortcutIndex` (`:14`) is read by essentially every
|
||||
per-shortcut effective-preference lookup in the UI.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cycling, selection, hover, and focus
|
||||
|
||||
### 3.1 Selection state
|
||||
|
||||
Selection is stored on `SwitcherSession` (`selectedIndex`, `hoveredIndex`,
|
||||
`selectedTarget`). The canonical list `Windows.list` is index-aligned with
|
||||
`TilesView.recycledViews`.
|
||||
|
||||
### 3.2 Selection decisions (pure kernel)
|
||||
|
||||
`SelectionResolver` (`src/switcher/state/SelectionResolver.swift`) is a pure,
|
||||
unit-tested kernel. `Windows` projects `list` into `[SelectionWindow]`
|
||||
(`Windows.selectionSnapshot`, `Windows.swift:185-193`), builds `SelectionInputs`
|
||||
(`makeSelectionInputs`, `:195-204`), and:
|
||||
|
||||
- On show: `setInitialSelectedAndHoveredWindowIndex()` (`Windows.swift:163-172`)
|
||||
→ `SelectionResolver.initialPickIndex(...)`.
|
||||
- On every refresh: `updateSelectedWindow()` (`Windows.swift:174-182`) →
|
||||
`SelectionResolver.decide(...)` → `applySelectionDecision(...)` (`:206-223`).
|
||||
|
||||
`makeSelectionInputs` reads `Preferences.windowOrder[shortcutIndex]` to set
|
||||
`useLastFocusedRule` (`Windows.swift:200-201`).
|
||||
|
||||
### 3.3 Cycling
|
||||
|
||||
- `App.cycleSelection(direction, allowWrap)` (`App.swift:248-256`) dispatches:
|
||||
up/down → `TilesView.navigateUpOrDown(...)`; left/right →
|
||||
`Windows.cycleSelectedWindowIndex(step, allowWrap)`.
|
||||
- `Windows.cycleSelectedWindowIndex` (`Windows.swift:272-284`) and
|
||||
`selectedWindowIndexAfterCycling` (`:286-297`) skip non-displayed windows and
|
||||
handle wrap-around suppression on key-repeat.
|
||||
- `TilesView.navigateUpOrDown` (`TilesView.swift:367-384`) and `nextRow`
|
||||
(`:341-365`) handle row-based vertical navigation, respecting RTL layout.
|
||||
- `updateSelectedAndHoveredWindowIndex(newIndex, fromMouse)`
|
||||
(`Windows.swift:238-270`) is the single mutation point. It distinguishes hover
|
||||
(mouse) from focus (keyboard or mouse-with-`mouseHoverEnabled`), updates the
|
||||
highlight (`TilesView.highlight`), scrolls the focused tile into view, triggers
|
||||
the window preview (`WindowThumbnails.previewSelectedIfNeeded()`), and notifies
|
||||
VoiceOver. Mouse hover honors `Preferences.mouseHoverEnabled` (`:255`).
|
||||
|
||||
### 3.4 Highlight rendering
|
||||
|
||||
`TilesView.highlight(index)` (`TilesView.swift:324-339`) draws the per-tile
|
||||
highlight and updates `TileUnderLayer` with the focused and (distinct) hovered
|
||||
tile frames.
|
||||
|
||||
### 3.5 Focus / activation
|
||||
|
||||
When the user commits (key-up in focus-on-release, or the focus shortcut):
|
||||
|
||||
- `App.focusTarget()` (`App.swift:96-101`) → `App.focusSelectedWindow(...)`
|
||||
(`:263-275`) → `Window.focus()` (`Window.swift:245-275`).
|
||||
- `Window.focus()` handles three cases: AltTab's own windows, windowless apps
|
||||
(launch/activate), and normal windows (private SkyLight
|
||||
`_SLPSSetFrontProcessWithOptions` + `makeKeyWindow` byte-poking ported from
|
||||
Hammerspoon, `Window.swift:277-288`, then `axUiElement.focusWindow()`).
|
||||
- Optional cursor-follows-focus via `App.moveCursorToSelectedWindow`
|
||||
(`App.swift:277-282`), gated on `Preferences.cursorFollowFocus`.
|
||||
|
||||
Window-management actions on the selected window are wired in `ShortcutActions`
|
||||
(`src/switcher/ShortcutAction.swift:28-32`): `close()`, `minDemin()`,
|
||||
`toggleFullscreen()`, `application.quit()`, `application.hideOrShow()` (all in
|
||||
`Window.swift`/`Application.swift`). None of these are paywalled.
|
||||
|
||||
---
|
||||
|
||||
## 4. Keyboard / shortcut event flow
|
||||
|
||||
### 4.1 Registration
|
||||
|
||||
`KeyboardEvents` (`src/events/KeyboardEvents.swift`) installs:
|
||||
|
||||
- **Carbon global hotkeys** (`RegisterEventHotKey`) for the `nextWindowShortcut*`
|
||||
and `holdShortcut*` slots (`:104-120`, IDs from
|
||||
`KeyboardEventsTestable.globalShortcutsIds`,
|
||||
`src/events/KeyboardEventsTestable.swift:4-9`).
|
||||
- A **local NSEvent monitor** for keyDown/keyUp (`:123-130`).
|
||||
- A **CGEvent tap** on `.cghidEventTap` for `.flagsChanged` and `.keyDown`
|
||||
(`:132-152`). The `.keyDown` path only absorbs **Esc** (and only when a
|
||||
shortcut binds Esc and the switcher is active) to beat the macOS 26 Game
|
||||
Overlay hook on `⌘⎋` (`:33-44`, `anyShortcutUsesEscape` at `:20`).
|
||||
|
||||
Registration is driven from `ControlsTab`
|
||||
(`src/preferences/.../ControlsTab.swift:781` calls
|
||||
`KeyboardEvents.addGlobalShortcut`). `addEventHandlers()` is called once at
|
||||
startup (`App.swift:402`).
|
||||
|
||||
### 4.2 Matching and dispatch
|
||||
|
||||
All paths funnel into `handleKeyboardEvent(...)`
|
||||
(`src/events/KeyboardEventsTestable.swift:13-24`):
|
||||
|
||||
1. If the search field is being edited and the panel is key, the keyDown is first
|
||||
offered to `TilesView.handleSearchEditingKeyDown` (`:14-19`).
|
||||
2. `triggerMatchingShortcuts(...)` (`:49-66`) iterates `ControlsTab.shortcuts`,
|
||||
and for each `ATShortcut` calls `matches(...)` then `shouldTrigger()`, and if
|
||||
both pass, `executeAction(isARepeat)`.
|
||||
|
||||
`ATShortcut` (`src/switcher/ATShortcut.swift`) encapsulates one bound shortcut:
|
||||
`matches(...)` (`:21-41`) does global-id and modifier/keycode matching;
|
||||
`modifiersMatch(...)` (`:43-61`) handles the hold-modifier semantics and the
|
||||
"base key without hold modifiers when panel open" case;
|
||||
`shouldTrigger()` (`:63-80`) gates by scope (global/local), trigger phase
|
||||
(down/up), session shortcut index, and — for the key-up path —
|
||||
`Preferences.effectiveShortcutStyle(...) == .focusOnRelease`;
|
||||
`redundantSafetyMeasures()` (`:90-110`) is a safety net for dropped/out-of-order
|
||||
key-up events.
|
||||
|
||||
### 4.3 Actions
|
||||
|
||||
`ShortcutActions.execute(id)` (`src/switcher/ShortcutAction.swift:49-70`) maps an
|
||||
id to behavior:
|
||||
|
||||
- A static table `ShortcutActions.all` (`:9-41`) maps named ids to closures
|
||||
(`focusWindowShortcut`, `previousWindowShortcut`, arrow/vim cycling,
|
||||
`cancelShortcut`, `closeWindowShortcut`, `minDeminWindowShortcut`,
|
||||
`toggleFullscreenWindowShortcut`, `quitAppShortcut`, `hideShowAppShortcut`,
|
||||
`searchShortcut`, `lockSearchShortcut`).
|
||||
- Prefix-matched ids: `holdShortcut*` → `App.focusTarget()`;
|
||||
`nextWindowShortcut*` → `App.showUiOrCycleSelection(index, false)`.
|
||||
- **A Pro gate is applied at the top** for `holdShortcut`/`nextWindowShortcut`
|
||||
slots with index ≥ 1 (`ShortcutAction.swift:50-58`, see §6).
|
||||
|
||||
### 4.4 Key repeat
|
||||
|
||||
`KeyRepeatTimer` (`src/switcher/KeyRepeatTimer.swift`) implements artificial
|
||||
key-repeat for next/previous-window cycling when the bound shortcut has no
|
||||
keycode (modifier-only). It reads the system `KeyRepeat`/`InitialKeyRepeat`
|
||||
defaults (`:41-42`) and polls hardware modifier state to detect release
|
||||
(`holdModifierIsReleased`, `:62-70`). No paywall coupling.
|
||||
|
||||
### 4.5 Other event monitors (`src/events/`)
|
||||
|
||||
- `CursorEvents` (`src/events/CursorEvents.swift`) — `.cgSessionEventTap` for
|
||||
mouse down/up/moved; handles click-to-focus, click-outside-to-dismiss
|
||||
(`:88`), middle-click-to-close (`:120-129`), and hover with a "deadzone" to
|
||||
ignore tiny trackpad jitter (`:178-189`). Toggled on/off with the session.
|
||||
- `ScrollwheelEvents` (`src/events/ScrollwheelEvents.swift`) — blocks continuous
|
||||
(trackpad) scroll while letting discrete (mouse) scroll through.
|
||||
- `TrackpadEvents` (`src/events/TrackpadEvents.swift`) — gesture-based cycling.
|
||||
- `ContextMenuEvents` (`:ContextMenuEvents.swift`) — right-click menu on tiles.
|
||||
- Plus environment monitors that trigger refreshes: `SpacesEvents`,
|
||||
`ScreensEvents`, `DockEvents`, `SleepWakeEvents`, `InputSourceEvents`,
|
||||
`SystemAppearanceEvents`, `SystemScrollerStyleEvents`,
|
||||
`RunningApplicationsEvents`, `WindowCaptureEvents` (thumbnail capture),
|
||||
`PreferencesEvents`, `UserDefaultsEvents`, `CliEvents`, `AxEventRouting`.
|
||||
|
||||
**`PreferencesEvents`** contains one paywall touchpoint
|
||||
(`src/events/PreferencesEvents.swift:62`, see §6).
|
||||
|
||||
---
|
||||
|
||||
## 5. Search
|
||||
|
||||
Search is a first-class part of the switcher UI and is **Pro-gated** (see §6).
|
||||
|
||||
- `Search` (`src/switcher/Search.swift`) computes per-window match/relevance
|
||||
using the `SearchTestable` fuzzy matcher, caching results on the `Window`
|
||||
(`swBestSimilarity`, `lastSearchQuery`).
|
||||
- `Windows.updateSearchQuery(...)` (`Windows.swift:14-35`) updates the session
|
||||
query and the "select best match / restore default" flags, then re-sorts.
|
||||
- `Windows.shouldDisplay(window)` (`Windows.swift:10-12`) is
|
||||
`shouldShowTheUser && Search.matches(window, query)`. This single predicate is
|
||||
consulted everywhere (layout, cycling, selection snapshot, viewport).
|
||||
- `SearchModeResolver` (`src/switcher/state/SearchModeResolver.swift`) is the pure
|
||||
search-mode state machine (`off`/`editing`/`locked`). Its `enableEditing` and
|
||||
`lock` decisions take a `canSearch` / `canLockSearch` `Bool`; the caller
|
||||
(`TilesView`) computes that bool from `ProFeature.*.attemptUse()` (`:68-86`).
|
||||
- `TilesView` translates those decisions into AppKit side effects
|
||||
(`enableSearchEditing`, `lockSearchMode`, `disableSearchMode`,
|
||||
`TilesView.swift:59-128`).
|
||||
- Sorting respects per-shortcut order/group preferences via `WindowOrderResolver`
|
||||
(`src/switcher/state/WindowOrderResolver.swift`, `OrderSortType` =
|
||||
`recentlyFocused`/`recentlyCreated`/`alphabetical`/`space`) and search
|
||||
relevance (`Windows.sort`, `Windows.swift:322-343`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Pro / paywall touchpoints surfacing in this subsystem
|
||||
|
||||
The switcher subsystem itself is mostly free of paywall logic — the gating lives
|
||||
in `src/pro/` (`ProFeature`, `ProTransitionManager`, `LicenseManager`,
|
||||
`ProGatedPreferences`) — but it is *invoked* from a small number of well-defined
|
||||
sites in scope. The pattern is consistent: pure kernels never gate; the
|
||||
imperative wrapper calls `ProFeature.<case>.attemptUse()` (or reads an
|
||||
"effective" preference that silently downgrades when Pro is locked) at the real
|
||||
use moment.
|
||||
|
||||
`ProFeature.attemptUse()` (`src/pro/ProFeature.swift:74-83`) returns `true` when
|
||||
Pro/trial is available or a "free pass session" is active; otherwise it consults
|
||||
the Day1→Day35 nag ladder for hard-gated features. For a fully-free build, every
|
||||
one of these should resolve to "always allowed."
|
||||
|
||||
### Hard gates invoked from the switcher
|
||||
|
||||
1. **Extra shortcut slots (index ≥ 1)** — `ShortcutAction.swift:50-58`
|
||||
(`ShortcutActions.execute`): pressing any `holdShortcut`/`nextWindowShortcut`
|
||||
slot past the first calls `ProFeature.extraShortcut(index:).attemptUse()` and
|
||||
`return`s (does nothing) if denied. Removing this gate makes all configured
|
||||
shortcut variants always work.
|
||||
|
||||
2. **Search in the switcher** — `TilesView.swift:92`
|
||||
(`enableSearchEditing`): `ProFeature.searchInSwitcher.attemptUse()` is passed
|
||||
as `canSearch` to `SearchModeResolver.enableEditing`. If denied, the kernel
|
||||
returns `.proGateBlocked(.search)` and editing never starts.
|
||||
|
||||
3. **Lock search results** — `TilesView.swift:79` (`lockSearchMode`):
|
||||
`ProFeature.lockSearchInSwitcher.attemptUse()` passed as `canLockSearch` to
|
||||
`SearchModeResolver.lock`.
|
||||
|
||||
### Degradable preferences read via "effective" getters
|
||||
|
||||
These do not call `attemptUse()` directly; instead the switcher reads
|
||||
`Preferences.effective*` getters, and the *stored* value is silently downgraded
|
||||
when Pro is locked (via `ProGatedPreferences`). The switcher consumes them at:
|
||||
|
||||
4. **Appearance style** (`appIcons`/`titles` are Pro;
|
||||
`ProFeature.appIconsAndTitlesStyle`) — read via
|
||||
`Preferences.effectiveAppearanceStyle(...)`. Surfaces in the switcher at, e.g.,
|
||||
`TilesView.swift:388, 563, 614`, `TilesPanel.swift:101, 128`,
|
||||
`TileView.swift:52, 64, 82`. The Pro-aware read path for index 0 is
|
||||
`ProGatedPreferences.appearanceStyleOverride0.read()`
|
||||
(`Preferences.swift:311-315`).
|
||||
|
||||
5. **Auto size** (`ProFeature.autoSize`) — read via
|
||||
`Preferences.effectiveAppearanceSize(...)` at `TilesView.swift:388, 391`
|
||||
(and `TilesPanel`/`Appearance`). Pro-aware read at
|
||||
`Preferences.swift:317-321`.
|
||||
|
||||
6. **Search-on-release shortcut style** (`ProFeature.searchOnReleaseShortcut`) —
|
||||
read via `Preferences.effectiveShortcutStyle(...)` at `ATShortcut.swift:70,
|
||||
96`, `App.swift:329`, `ShortcutAction.swift:22`. Pro-aware read at
|
||||
`Preferences.swift:328-332`. (`focusOnRelease` itself is free; the
|
||||
`searchOnRelease` value is the Pro one.)
|
||||
|
||||
### Preview selected window
|
||||
|
||||
7. **`PreviewPanel`** (`src/switcher/PreviewPanel.swift`) and
|
||||
`WindowThumbnails.previewSelectedIfNeeded()`
|
||||
(`src/switcher/state/WindowThumbnails.swift:6-19`) implement the "preview the
|
||||
selected window full-size" feature, driven by
|
||||
`Preferences.effectivePreviewSelectedWindow(...)`
|
||||
(`Preferences.swift:334-337`). **As written, this preference is NOT in
|
||||
`ProFeature` / `ProGatedPreferences`** — `effectivePreviewSelectedWindow`
|
||||
reads the stored bool directly with no Pro-aware override. So in the current
|
||||
code preview-selected-window is effectively a free feature gated only by the
|
||||
Screen-Recording permission. Worth confirming against `src/pro/` during
|
||||
removal, but it does not appear to need changes for a free build.
|
||||
|
||||
### Trial/upsell scheduler hooks
|
||||
|
||||
8. The switcher show/hide lifecycle notifies the trial-nag scheduler:
|
||||
`ProTransitionManager.shared.onSwitcherShown()` (`App.swift:328`) and
|
||||
`onSwitcherDismissed()` (`App.swift:78`). These are the hooks the Day1→Day35
|
||||
nag system uses to decide when to interrupt the user with upsell UI. For a free
|
||||
build these calls should be removed (or made no-ops).
|
||||
|
||||
### Usage analytics
|
||||
|
||||
9. `UsageStats.recordTrigger(...)` (`App.swift:315`) and
|
||||
`UsageStats.recordSearchIfFirst()` (`TilesView.swift:183`), reset via
|
||||
`UsageStats.resetSession()` (`App.swift:67`). `UsageStats`
|
||||
(`src/util/UsageStats.swift`) records trigger/search counts (including
|
||||
`triggersExtraShortcuts`, `triggersAppIcons`, etc.) into a private
|
||||
`UserDefaults` suite. This feeds the upsell messaging ("you used X N times").
|
||||
Not strictly a gate, but part of the paywall apparatus; safe to strip or stub
|
||||
for a free build.
|
||||
|
||||
> Note: `ProFeature.searchOnReleaseShortcut` is mapped to the "extra shortcuts"
|
||||
> marketing copy and grouped under keyboard shortcuts
|
||||
> (`ProFeature.swift:52`), which is why the trial copy lumps them together.
|
||||
|
||||
---
|
||||
|
||||
## 7. Preferences interaction summary
|
||||
|
||||
The switcher reads preferences in two modes:
|
||||
|
||||
- **Per-shortcut "effective" reads**, keyed on
|
||||
`SwitcherSession.activeShortcutIndex`. These are the ones that can be Pro-gated:
|
||||
`effectiveAppearanceStyle`, `effectiveAppearanceSize`, `effectiveShortcutStyle`,
|
||||
`effectivePreviewSelectedWindow`, `effectiveAppearanceTheme`
|
||||
(`Preferences.swift:311-337`). Window-filter preferences are snapshotted once per
|
||||
show in `WindowFilters.snapshot()` (`Windows.swift:507-519`) for performance.
|
||||
- **Plain reads** scattered throughout: `Preferences.onlyShowMainWindows()`,
|
||||
`windowOrder`, `mouseHoverEnabled`, `cursorFollowFocus`, `windowDisplayDelay`,
|
||||
`fadeOutAnimation`, `previewFadeInAnimation`, `captureWindowsInBackground`, the
|
||||
`exceptions` list (consumed by `ExceptionMatcher`), etc.
|
||||
|
||||
`Appearance` (`src/switcher/Appearance.swift`) centralizes all derived layout
|
||||
constants (paddings, font, theme, max-width-on-screen) and is recomputed via
|
||||
`Appearance.update()` on each show.
|
||||
|
||||
---
|
||||
|
||||
## 8. Removal notes for a clean free build
|
||||
|
||||
To free everything in this subsystem without breaking the build, the cleanest
|
||||
approach is to make the gates at the call sites always pass rather than ripping
|
||||
`src/pro/` out from under the switcher first:
|
||||
|
||||
- **Make `ProFeature.attemptUse()` always return `true`.** This single change
|
||||
neutralizes the three hard gates invoked from scope
|
||||
(`ShortcutAction.swift:56`, `TilesView.swift:79, 92`) with no signature changes.
|
||||
- **Make the `Preferences.effective*` getters read the raw stored value** (i.e.
|
||||
bypass `ProGatedPreferences.*Override0.read()` downgrades at
|
||||
`Preferences.swift:313, 319, 330`) so `appIcons`/`titles`/`auto`/`searchOnRelease`
|
||||
are honored regardless of license state. Equivalently, make
|
||||
`ProGatedPreferences.read()` return the stored Pro value unconditionally.
|
||||
- **Stub `ProTransitionManager.shared.onSwitcherShown()` /
|
||||
`onSwitcherDismissed()`** (`App.swift:78, 328`) to no-ops, or delete the calls,
|
||||
to kill the trial-nag interrupts triggered by the switcher lifecycle.
|
||||
- **`UsageStats` calls** (`App.swift:67, 315`; `TilesView.swift:183`) can be left
|
||||
(harmless) or stubbed; they only feed upsell copy.
|
||||
- **`PreviewPanel` / preview-selected-window needs no change** for the free build
|
||||
per current code (not actually Pro-gated; see §6.7) — but verify against
|
||||
`src/pro/` in the licensing-focused audit section.
|
||||
|
||||
Risks / things to verify in the dedicated `src/pro/` audit:
|
||||
- `SearchModeResolver` and `SelectionResolver` are pure and have unit tests; do
|
||||
**not** move gating into them — keep gates at the `TilesView`/`ShortcutAction`
|
||||
call sites so the kernels' tests stay valid.
|
||||
- Removing `ProTransitionManager`/`LicenseManager` types outright will break these
|
||||
call sites at compile time; either keep thin stubs or update all call sites in
|
||||
the same change. The keyboard/selection/render paths have no other Pro coupling,
|
||||
so once the four/five sites above are neutralized the switcher is fully free.
|
||||
- `ProFeatureCopy` / marketing strings are referenced only by `src/pro/` UI, not by
|
||||
the switcher, so they are out of scope here.
|
||||
@@ -0,0 +1,368 @@
|
||||
# 03 — Preferences System & Feature-Gating Mechanism
|
||||
|
||||
Audit scope: the preferences system (`src/preferences/`) and the Pro feature-gating mechanism
|
||||
that sits on top of it. This is the heart of how the paywall degrades and hides features. This
|
||||
document is the authoritative reference for the removal plan; every gated preference is enumerated
|
||||
with its exact key, Pro values, and free equivalent.
|
||||
|
||||
---
|
||||
|
||||
## 1. How the preferences system works (no-paywall baseline)
|
||||
|
||||
### 1.1 Storage and defaults
|
||||
|
||||
- `Preferences.defaultValues` (`src/preferences/Preferences.swift:6-78`) is the single dictionary of
|
||||
every default, registered into `UserDefaults.standard` via `registerDefaults()`
|
||||
(`Preferences.swift:179-181`). All values are stored as **strings** (e.g. `"true"`, `"100"`, or a
|
||||
stringified enum index like `AppearanceStylePreference.thumbnails.indexAsString`).
|
||||
- Macro (enum-backed) preferences store the **enum case index** as a string. The index is derived by
|
||||
the `CaseIterable where Self: Equatable` extension `index` / `indexAsString`
|
||||
(`src/macos/api-wrappers/HelperExtensions.swift:330-337`).
|
||||
- Reads go through `CachedUserDefaults` (`Preferences.swift:432-523`), a thread-safe cache keyed by
|
||||
preference name. `macroPref(key, allCases)` reads the stored int and maps it to `allCases[index]`,
|
||||
resetting to the default if the value can't be parsed (`getThenConvertOrReset`, lines 503-518).
|
||||
- Writes go through `Preferences.set` / `setShortcut` / `remove` (`Preferences.swift:191-228`). Each
|
||||
write invalidates the cache and, unless `notify: false` is passed, fires
|
||||
`PreferencesEvents.preferenceChanged(key)` (`Preferences.swift:216-218`). **This notify hook is the
|
||||
single chokepoint the paywall hijacks to bounce setters to the Upgrade tab — see §3.2.**
|
||||
|
||||
### 1.2 Per-shortcut model and overrides
|
||||
|
||||
- Most behavioral prefs are per-shortcut, indexed by `indexToName(baseName, index)`
|
||||
(`Preferences.swift:421-423`): index 0 has no suffix (`appearanceSizeOverride`), index 1 gets
|
||||
suffix `2` (`appearanceSizeOverride2`), etc. (`nameToIndex` is the inverse, lines 425-429.)
|
||||
- `maxShortcutCount = 9`, `minShortcutCount = 1` (`Preferences.swift:160-161`). `shortcutCount`
|
||||
clamps the stored count into that range (lines 162-164).
|
||||
- Appearance **overrides** let any shortcut deviate from the global appearance. The 5 override base
|
||||
names are `appearanceStyleOverride`, `appearanceSizeOverride`, `appearanceThemeOverride`,
|
||||
`shortcutStyleOverride`, `previewFocusedWindowOverride` (`Preferences.swift:258-261`), each mapped
|
||||
back to its global key by `overrideToGlobalKey` (lines 264-270).
|
||||
- `hasOverride(baseName, index)` (lines 275-277) consults `Preferences.all` (the cached
|
||||
`persistentDomain` snapshot, lines 238-244), which **excludes registered defaults**. So an
|
||||
untouched override reports `false` even though its key has a registered default. The override
|
||||
defaults are deliberately set to the **free-tier value** (`Preferences.swift:67-75`) so that
|
||||
`snapshotAndDowngrade` is a no-op for unset overrides.
|
||||
- `effectiveAppearanceStyle/Size/Theme/ShortcutStyle/PreviewSelectedWindow(index)` (lines 311-337)
|
||||
resolve the value a given shortcut actually uses: the override if set, else the global. **For
|
||||
index 0, the three Pro-gated overrides route through `ProGatedPreferences.*Override0.read()`**
|
||||
(lines 313, 319, 330) — meaning index-0 overrides are degraded exactly like the globals.
|
||||
|
||||
### 1.3 Migrations
|
||||
|
||||
`PreferencesMigrations` (`src/preferences/PreferencesMigrations.swift`) runs once per version bump on
|
||||
launch via `Preferences.initialize()` (`Preferences.swift:168-172`). It is largely paywall-agnostic
|
||||
historical data migration. **The only paywall coupling** is one line in `migratePreferences()`:
|
||||
|
||||
- `PreferencesMigrations.swift:20` — `ProTransitionState.markFreshInstallIfUnknown(existingVersion == nil)`.
|
||||
This is the *only* place that can still observe a nil `preferencesVersion`, so it persists the
|
||||
fresh-install signal the trial scheduler later reads. For a free build this single call must be
|
||||
removed along with `ProTransitionState`.
|
||||
|
||||
No other migration references Pro/license/trial state.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Pro-gating mechanism (the heart of the paywall)
|
||||
|
||||
The gating mechanism has two complementary halves:
|
||||
|
||||
1. **Degradable preferences** — stored values that get *silently downgraded* to a free equivalent
|
||||
when Pro locks, snapshotted for later restore, and *intercepted at write-time* in Settings.
|
||||
2. **Hard-gated runtime actions** — features with no stored preference, gated at use-time (extra
|
||||
shortcuts, in-switcher search, lock-search).
|
||||
|
||||
### 2.1 `isProLocked` — the master gate
|
||||
|
||||
`LicenseManager.shared.isProLocked` (`src/pro/license/LicenseManager.swift:67-72`) is the single
|
||||
boolean every gate consults:
|
||||
|
||||
```swift
|
||||
var isProLocked: Bool {
|
||||
switch state {
|
||||
case .pro, .trial: return false
|
||||
case .proExpired, .trialExpired: return true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
So a user is "locked" once the 14-day trial (`trialDuration = 14`, line 17) expires or a Pro license
|
||||
becomes invalid. `isProAvailable` is the inverse-ish (`pro` or `trial`).
|
||||
|
||||
For a fully-free build, the cleanest semantic equivalent is **`isProLocked == false` always** (and
|
||||
`isProAvailable == true` always). Every gate below then becomes a no-op pass-through.
|
||||
|
||||
### 2.2 The `PreferenceDefinition` / `PreferenceGate` engine
|
||||
|
||||
`src/preferences/PreferenceDefinition.swift` is the single source of truth for degradable prefs.
|
||||
|
||||
- `PreferenceGate<T>` (lines 7-11) holds three things:
|
||||
- `freeEquivalent: T` — the value returned/written when locked.
|
||||
- `rememberedKey: String` — the `ProTransitionState` key under which the original Pro index is
|
||||
snapshotted for restore + "ghost UI".
|
||||
- `isProValue: (T) -> Bool` — predicate identifying which stored values count as "Pro".
|
||||
- `PreferenceDefinition<T>` (lines 18-74) wraps `key`, `default`, and an optional `gate`, and exposes
|
||||
the read/downgrade/restore operations:
|
||||
- **`read()`** (lines 32-44): the hot-path getter. If there's no gate or `!isProLocked`, returns
|
||||
the stored value verbatim. When **locked**, three branches:
|
||||
1. If a **free-pass session** is active and a remembered Pro index exists → returns the
|
||||
remembered Pro value (so the switcher renders the user's Pro choice "one last time").
|
||||
2. Else if the stored value is still a Pro value (transient, before
|
||||
`onProLockEngaged()` has downgraded it) → returns `freeEquivalent`.
|
||||
3. Else → returns stored (already downgraded).
|
||||
- **`snapshotAndDowngrade()`** (lines 51-57): if stored is a Pro value, overwrites it with
|
||||
`freeEquivalent` via `Preferences.set` (which **fires the notify hook**), and returns the
|
||||
original index for the caller to remember. No-op if no gate or already free.
|
||||
- **`restore(from:)`** (lines 62-65): writes the remembered index back with `notify: false`.
|
||||
- **`isStoredValuePro()`** (lines 69-73): true iff stored is currently a Pro value, regardless of
|
||||
lock state — used by the notify hook (§3.2).
|
||||
- `AnyProGatedPreference` (lines 79-85) is the type-erased view (Int/Bool returns only) that the
|
||||
lock/unlock passes iterate over. `PreferenceDefinition.erased` (lines 87-96) produces it.
|
||||
|
||||
### 2.3 `ProGatedPreferences` — the registry of gated prefs
|
||||
|
||||
`ProGatedPreferences` (`PreferenceDefinition.swift:102-170`) declares **six** gated
|
||||
`PreferenceDefinition`s (3 globals + 3 index-0 overrides). `ProGatedPreferences.all` (lines 157-164)
|
||||
is the list iterated on lock/unlock. `forPreferenceKey(_:)` (lines 167-169) is the lookup the notify
|
||||
hook uses. **This enum is the precise enumeration source for the table in §4.**
|
||||
|
||||
### 2.4 `ProFeature` — capability registry
|
||||
|
||||
`src/pro/ProFeature.swift` enumerates every gated capability (degradable + hard-gated):
|
||||
|
||||
- Cases (lines 9-16): `.appIconsAndTitlesStyle`, `.autoSize`, `.searchOnReleaseShortcut`
|
||||
(degradable) and `.extraShortcut(index:)`, `.searchInSwitcher`, `.lockSearchInSwitcher`
|
||||
(hard-gated).
|
||||
- `gateKind` (lines 27-33): `.degradable`, `.hardGated`, or `.degradableAndHardGated`.
|
||||
- `gatedPreference` (lines 37-44): maps the 3 degradable features to their
|
||||
`ProGatedPreferences.*.erased` definition.
|
||||
- `attemptUse()` (lines 74-83): the runtime gate. Returns `true` if `isProAvailable` or a free-pass
|
||||
session is active; otherwise hard-gated features defer to
|
||||
`ProTransitionManager.attemptHardGatedFeature`, and degradable features always return `true`
|
||||
(they're gated at write-time, not use-time).
|
||||
- `isStoredValuePro(preferenceKey:)` (lines 87-89): delegates to the registry; used by the notify hook.
|
||||
|
||||
### 2.5 Lock / unlock passes — downgrade & restore
|
||||
|
||||
`ProTransitionState` (`src/pro/scheduling/ProTransitionState.swift`) owns the snapshot/restore logic
|
||||
and the `remembered*` indices (persisted in the `<bundleId>.license` UserDefaults suite under keys
|
||||
prefixed `proTransition.`):
|
||||
|
||||
- **`onProLockEngaged()`** (lines 73-79): iterates `ProGatedPreferences.all`, calls
|
||||
`snapshotAndDowngrade()` on each, and stores the returned Pro index under the pref's
|
||||
`rememberedKey`. Triggered when the license locks via `ProTransitionManager.onLicenseStateChanged`
|
||||
→ `onProLockEngaged` (`ProTransitionManager.swift:179-181, 259-262`), and also from the Day 15
|
||||
Full-Upgrade / Proactive paths (lines 248-249, 256). Idempotent (re-entry no-ops because stored is
|
||||
already free).
|
||||
- **`onProUnlocked()`** (lines 85-92): iterates the registry, restores each remembered index via
|
||||
`restoreFromIndex` (with `notify: false`), then clears the remembered key. Triggered on activation
|
||||
via `LicenseManager.onBeforeProUnlock` (wired in `App.swift`) and `ProTransitionManager.onProUnlocked`.
|
||||
- `rememberedAppearanceStyle/Size/ShortcutStyle` + the three `*Override` variants (lines 36-62) are
|
||||
typed accessors over those `proTransition.remembered*` keys.
|
||||
|
||||
### 2.6 Free-pass session & ghost UI
|
||||
|
||||
- `ProTransitionManager.isFreePassSessionActive` (`ProTransitionManager.swift:135-142`) is a
|
||||
session-only flag, true between a free-pass grant and switcher dismissal. While active,
|
||||
`PreferenceDefinition.read()` returns the **remembered Pro value** (branch 1 above), so the
|
||||
switcher shows the user's Pro selection one last time before `[C]` upsell fires. Flipping the flag
|
||||
triggers `App.resetPreferencesDependentComponents()` so `TilesView` re-renders.
|
||||
- The remembered indices also drive the "ghost outline" in Settings (the Pro selection is shown
|
||||
badged/dimmed even though the live value is the free equivalent).
|
||||
|
||||
---
|
||||
|
||||
## 3. How gating reaches the UI
|
||||
|
||||
### 3.1 Settings controls — write-time interception + badges
|
||||
|
||||
`LabelAndControl.makeImageRadioButtons` (`src/preferences/settings-window/LabelAndControl.swift:74-116`)
|
||||
takes a `proGatedIndices: Set<Int>` parameter. On click, **if `isProLocked` and the clicked index is
|
||||
gated** (lines 86-92): it snaps the radio group back to the stored value and calls
|
||||
`UpgradeTab.navigateToUpgradeTab()` instead of writing. This is the generic interception primitive.
|
||||
|
||||
`AppearanceTab` (`src/preferences/settings-window/tabs/appearance/AppearanceTab.swift`) wires the three
|
||||
global gated controls:
|
||||
|
||||
- **Appearance Style** radios (lines 458-463): `proGatedIndices: proGatedAppearanceStyleIndices()`
|
||||
which is "everything but `.thumbnails`" (lines 700-703 → indices `{1, 2}` = appIcons, titles).
|
||||
`addProBadgesToStyleButtons` (lines 688-698) attaches a `ProBadgeView` to every non-thumbnails
|
||||
card.
|
||||
- **Appearance Size** segmented (lines 474-490): `wrapAppearanceSizeProLockIntercept` (lines 662-679)
|
||||
intercepts the `.auto` segment (last index) when locked — snaps back to stored, calls
|
||||
`navigateToUpgradeTab()`. `addProBadgeToAutoSegment` (lines 681-686) badges the Auto segment.
|
||||
- **After-keys-released / shortcutStyle** segmented (lines 566-583):
|
||||
`wrapShortcutStyleProLockIntercept` (lines 585-598) intercepts the `.searchOnRelease` segment.
|
||||
`addProBadgeToShortcutStyleSegment` (lines 606-611) badges the Search segment.
|
||||
- `refreshProLockUi()` (lines 705+) re-syncs these three controls when
|
||||
`ProTransitionManager.proLockStateDidChangeNotification` fires (observer registered lines 414-419),
|
||||
so the live Settings window updates on lock/unlock without a reopen.
|
||||
|
||||
The per-shortcut override panes mirror this exactly in `ShortcutEditor.swift`
|
||||
(`src/preferences/settings-window/tabs/controls/ShortcutEditor.swift`):
|
||||
|
||||
- `ShortcutOverrideSegmented` / `ShortcutOverrideRadios` carry their own `proGatedIndices` (lines
|
||||
540, 648). The override `AppearancePane` (lines 425-465) gates: style → `{appIcons, titles}` (line
|
||||
429), size → `{.auto}` (line 437), theme → `[]` (line 449, **not gated**), shortcutStyle →
|
||||
`{.searchOnRelease}` (line 459). Preview toggle is **not** gated.
|
||||
- The write-time intercept lives in `handleClick` for both controls (lines 596-621 for segmented,
|
||||
702-717 for radios): if a gated index is clicked while locked, snap back + `navigateToUpgradeTab()`.
|
||||
|
||||
### 3.2 The notify-hook bounce (write-side enforcement)
|
||||
|
||||
`PreferencesEvents.preferenceChanged(_:)` (`src/events/PreferencesEvents.swift:53-75`) contains the
|
||||
critical line **62-64**:
|
||||
|
||||
```swift
|
||||
if LicenseManager.shared.isProLocked && ProFeature.isStoredValuePro(preferenceKey: key) {
|
||||
UpgradeTab.navigateToUpgradeTab()
|
||||
}
|
||||
```
|
||||
|
||||
Any write that lands a Pro value into a gated key while locked bounces the user to Upgrade. This is
|
||||
why `onProUnlocked` restores with `notify: false` (otherwise restoring a Pro value during a
|
||||
still-locked window would yank the user to Upgrade). For the free build, removing this branch is
|
||||
mandatory.
|
||||
|
||||
### 3.3 Extra-shortcut sidebar gating (`ControlsTab`)
|
||||
|
||||
- `ControlsTab` badges every shortcut row at index ≥ 1 as Pro (`row.setProBadge(index >= 1)`,
|
||||
`src/preferences/settings-window/tabs/controls/ControlsTab.swift:515`).
|
||||
- `addShortcutSlot()` (lines 586-598): if `currentCount >= 1 && isProLocked`, it refuses to add the
|
||||
slot and calls `navigateToUpgradeTab()` (lines 589-592). **So free users are hard-capped at exactly
|
||||
1 shortcut.**
|
||||
|
||||
### 3.4 Runtime hard-gates (switcher)
|
||||
|
||||
- Extra shortcut trigger: `ShortcutAction.swift:56` — `if !ProFeature.extraShortcut(index: index).attemptUse() { return }`.
|
||||
- In-switcher search: `TilesView.swift:92` gates editing on `ProFeature.searchInSwitcher.attemptUse()`.
|
||||
- Lock-search: `TilesView.swift:79` gates on `ProFeature.lockSearchInSwitcher.attemptUse()`.
|
||||
- `searchInSwitcher` / `lockSearchInSwitcher` map to copy "Search"; `searchOnReleaseShortcut` is the
|
||||
Settings-side degradable twin of the same capability.
|
||||
|
||||
---
|
||||
|
||||
## 4. ENUMERATION — every Pro-gated preference (exact)
|
||||
|
||||
There are **6 degradable preference definitions** (3 globals + 3 index-0 overrides) plus runtime
|
||||
hard-gates with no backing preference. Indices below are the `CaseIterable` order (and thus the
|
||||
stored string value).
|
||||
|
||||
### 4.1 Degradable preferences (downgraded on lock, snapshot/restore)
|
||||
|
||||
Source: `ProGatedPreferences` (`PreferenceDefinition.swift:103-153`).
|
||||
|
||||
| # | Preference key | Type / enum | `isProValue` (Pro values) | Pro index value(s) | Free equivalent (value written on lock) | Free index | rememberedKey | Default |
|
||||
|---|----------------|-------------|---------------------------|--------------------|------------------------------------------|-----------|---------------|---------|
|
||||
| 1 | `appearanceStyle` | `AppearanceStylePreference` | `$0 != .thumbnails` | `.appIcons` (1), `.titles` (2) | `.thumbnails` | 0 | `rememberedAppearanceStyle` | `.thumbnails` |
|
||||
| 2 | `appearanceSize` | `AppearanceSizePreference` | `$0 == .auto` | `.auto` (3) | `.medium` | 1 | `rememberedAppearanceSize` | `.auto` |
|
||||
| 3 | `shortcutStyle` | `ShortcutStylePreference` | `$0 == .searchOnRelease` | `.searchOnRelease` (2) | `.doNothingOnRelease` | 1 | `rememberedShortcutStyle` | `.focusOnRelease` |
|
||||
| 4 | `appearanceStyleOverride` (shortcut 0 only) | `AppearanceStylePreference` | `$0 != .thumbnails` | `.appIcons` (1), `.titles` (2) | `.thumbnails` | 0 | `rememberedAppearanceStyleOverride` | `.thumbnails` |
|
||||
| 5 | `appearanceSizeOverride` (shortcut 0 only) | `AppearanceSizePreference` | `$0 == .auto` | `.auto` (3) | `.medium` | 1 | `rememberedAppearanceSizeOverride` | `.medium` |
|
||||
| 6 | `shortcutStyleOverride` (shortcut 0 only) | `ShortcutStylePreference` | `$0 == .searchOnRelease` | `.searchOnRelease` (2) | `.doNothingOnRelease` | 1 | `rememberedShortcutStyleOverride` | `.doNothingOnRelease` |
|
||||
|
||||
Notes:
|
||||
- Enum orders: `AppearanceStylePreference = [thumbnails(0), appIcons(1), titles(2)]`
|
||||
(`MacroPreferences.swift:284-287`); `AppearanceSizePreference = [small(0), medium(1), large(2),
|
||||
auto(3)]` (lines 308-312); `ShortcutStylePreference = [focusOnRelease(0), doNothingOnRelease(1),
|
||||
searchOnRelease(2)]` (lines 100-103).
|
||||
- **Note the asymmetry for `appearanceSize`/`shortcutStyle`**: the *registered default* is the Pro
|
||||
value (`.auto` / `.focusOnRelease` for the global), but the *free equivalent written on lock* is
|
||||
`.medium` / `.doNothingOnRelease`. (For `appearanceStyle` the default `.thumbnails` is already
|
||||
free.) Override-key defaults are intentionally the **free** value so unset overrides never get
|
||||
snapshotted (`Preferences.swift:67-75`, `PreferenceDefinition.swift:130-153`).
|
||||
- Index-0 overrides (4-6) are the only override indices snapshotted, because indices ≥ 1 are
|
||||
unreachable while locked (extra shortcuts are hard-gated). `Preferences.removeOverride` also clears
|
||||
the matching remembered key for these three (`Preferences.swift:282-298`).
|
||||
- The corresponding index-1..8 override keys (`appearanceStyleOverride2..9`, etc.) and the two
|
||||
**non-gated** overrides (`appearanceThemeOverride`, `previewFocusedWindowOverride`) are NOT gated.
|
||||
|
||||
### 4.2 Hard-gated runtime features (no backing preference)
|
||||
|
||||
Source: `ProFeature` (`ProFeature.swift:13-16`), enforcement sites in §3.3–3.4.
|
||||
|
||||
| Feature | Gate kind | Enforcement | Free-tier effect when locked |
|
||||
|---------|-----------|-------------|------------------------------|
|
||||
| Extra shortcuts (`shortcutCount` > 1, slots index ≥ 1) | hardGated | `ControlsTab.addShortcutSlot` (ControlsTab.swift:589-592) + `ShortcutAction.swift:56` | Capped at 1 shortcut; extra-shortcut chord no-ops |
|
||||
| In-switcher search (`searchInSwitcher`) | hardGated | `TilesView.swift:92` via `attemptUse()` | Typing-to-search disabled in switcher |
|
||||
| Lock-search (`lockSearchInSwitcher`) | hardGated | `TilesView.swift:79` via `attemptUse()` | Lock-search disabled |
|
||||
|
||||
`searchOnReleaseShortcut` is `degradableAndHardGated` (`ProFeature.swift:30`): it is the
|
||||
Settings-side degradable form (#3/#6 above) **and** its first post-expiration switcher summon can
|
||||
trigger the `[C]` upsell.
|
||||
|
||||
---
|
||||
|
||||
## 5. What must change for a clean free build
|
||||
|
||||
The intent is: **all features unlocked, all paywall/trial/license/upsell removed**, without breaking
|
||||
the build. The preferences subsystem must keep working; only the gating layered on top is removed.
|
||||
|
||||
### 5.1 Minimal, low-risk approach (recommended first pass)
|
||||
|
||||
Neuter the master gate so every check becomes a pass-through, leaving the preferences plumbing intact:
|
||||
|
||||
- Make `LicenseManager.shared.isProLocked` always return `false` and `isProAvailable` always `true`.
|
||||
Every Settings intercept, the notify-hook bounce, the sidebar shortcut cap, and `attemptUse()`
|
||||
immediately become no-ops, and `PreferenceDefinition.read()` returns stored values verbatim.
|
||||
- This is the smallest change that unlocks everything. Downside: dead paywall code (badges,
|
||||
`ProTransitionState`, Day-X UI, `UpgradeTab`) remains compiled in.
|
||||
|
||||
### 5.2 Full removal (the actual goal — requires care)
|
||||
|
||||
Touchpoints that must be removed/rewritten in this scope:
|
||||
|
||||
1. **`PreferenceDefinition.swift` (entire file)** — `PreferenceGate`, `PreferenceDefinition`,
|
||||
`AnyProGatedPreference`, `ProGatedPreferences`. The getters in `Preferences.swift` that call
|
||||
`ProGatedPreferences.*.read()` (lines 129, 130, 155) and the index-0 override branches in
|
||||
`effectiveAppearanceStyle/Size/ShortcutStyle` (lines 313, 319, 330) must be rewritten to read
|
||||
the raw value directly, e.g. `CachedUserDefaults.macroPref("appearanceStyle", AppearanceStylePreference.allCases)`.
|
||||
2. **`PreferencesEvents.swift:62-64`** — delete the `isProLocked && isStoredValuePro` bounce block
|
||||
(and the `ProFeature` import dependency it implies).
|
||||
3. **`Preferences.swift`** — remove `overrideRememberedKey` (lines 291-298) and the
|
||||
`ProTransitionState.setInt(...)` cleanup inside `removeOverride` (lines 284-286).
|
||||
4. **`PreferencesMigrations.swift:20`** — remove the
|
||||
`ProTransitionState.markFreshInstallIfUnknown(...)` call.
|
||||
5. **`LabelAndControl.swift:74-116`** — drop the `proGatedIndices` parameter and the
|
||||
`isProLocked && proGatedIndices.contains(index)` intercept (lines 86-92).
|
||||
6. **`AppearanceTab.swift`** — remove `proGatedAppearanceStyleIndices`, all `ProBadgeView` attach
|
||||
calls, both `wrap*ProLockIntercept` methods, `refreshProLockUi` and its
|
||||
`proLockStateDidChangeNotification` observer, and the `autoSegment/shortcutStyleSegment` overlay
|
||||
refs.
|
||||
7. **`ShortcutEditor.swift`** — drop `proGatedIndices`/`attachBadge`/`refreshBadge` plumbing and the
|
||||
locked-intercept branches in both `ShortcutOverrideSegmented.handleClick` (lines 599-607) and
|
||||
`ShortcutOverrideRadios.handleClick` (lines 705-711).
|
||||
8. **`ControlsTab.swift`** — remove `row.setProBadge(index >= 1)` (line 515) and the
|
||||
`isProLocked` cap in `addShortcutSlot` (lines 589-592), plus the
|
||||
`proLockStateDidChangeNotification` observer (line 141).
|
||||
9. **Switcher hard-gates** (out of strict scope but coupled) — `ShortcutAction.swift:56`,
|
||||
`TilesView.swift:79,92` call `ProFeature.*.attemptUse()`; these calls must be removed or replaced
|
||||
with `true`.
|
||||
10. The defaults in `Preferences.defaultValues` need no value change to unlock features (defaults
|
||||
already include the Pro values, e.g. `appearanceSize = .auto`), but the **override defaults**
|
||||
that were deliberately set to the free value (`appearanceSizeOverride = .medium`,
|
||||
`shortcutStyleOverride = .doNothingOnRelease`, `Preferences.swift:72,74`) can stay as-is — they
|
||||
only mattered for the snapshot no-op trick, which is being removed.
|
||||
|
||||
### 5.3 Risks / gotchas
|
||||
|
||||
- **`index`/`indexAsString`/`macroPref` cache plumbing is paywall-independent** and must be
|
||||
preserved — it is core preferences infrastructure, not paywall code.
|
||||
- **Already-locked users have downgraded stored values.** A user who ran the paywalled build past
|
||||
trial expiry has `appearanceStyle`/`appearanceSize`/`shortcutStyle` already overwritten to free
|
||||
equivalents in UserDefaults, with the original Pro index sitting in the `proTransition.remembered*`
|
||||
keys (in the `<bundleId>.license` suite). After removal, `read()` no longer restores those — the
|
||||
user keeps the free value and silently loses their old Pro selection. A free build with a **new
|
||||
bundle ID** (per AGENTS.md, identity is coupled to the signature) gets a clean UserDefaults domain,
|
||||
so this only affects in-place upgrades of the same bundle ID. Consider a one-time migration that
|
||||
copies any `proTransition.remembered*` index back into its base key before deleting the
|
||||
`proTransition.*` state — otherwise it's a silent (minor) data loss.
|
||||
- **`screenRecordingDependentFeatures`** (`Preferences.swift:346-355`) iterates
|
||||
`effectiveAppearanceStyle`/`effectivePreviewSelectedWindow` across all shortcuts; once those stop
|
||||
routing through `read()`, behavior is unchanged but verify the permission callout still resolves
|
||||
correctly when more than 1 shortcut is now reachable.
|
||||
- Removing `ProGatedPreferences` will break `ProFeature.gatedPreference` and
|
||||
`ProTransitionState.remembered*` accessors (compile errors) — these live in `src/pro/` and should
|
||||
be deleted as a unit alongside the rest of the paywall (covered by the pro-subsystem audit).
|
||||
- Test files reference these symbols: `PreferencesMigrationsTests.swift`,
|
||||
`LicenseManagerTests.swift`, `ProTransitionTests.swift`, `OnActionExtensionTests.swift`, and the
|
||||
`_test-support/Mocks.swift` `ShortcutStylePreference` mock — they will need updating/removal.
|
||||
@@ -0,0 +1,282 @@
|
||||
# Audit 04 — License / Activation Subsystem
|
||||
|
||||
> Scope: `src/pro/license/` — `LicenseManager.swift`, `LicenseState.swift`, `LicenseAPI.swift`, `RemoteLicenseClient.swift`, `LicenseCookie.swift`, `Keychain.swift`, `MachineFingerprint.swift`, `Clock.swift` (plus `LicenseManagerSpecs.md` & `LicenseManagerTests.swift`). Cross-references `src/api/Endpoints.swift`, `Info.plist`, `config/base.xcconfig`, and the app-side consumers.
|
||||
>
|
||||
> This is the **single source of truth** for whether the user has Pro. Every Pro gate and upsell prompt in the app reads from `LicenseManager.shared`. Removing the paywall cleanly = making this subsystem report "always Pro/free-unlocked" (or stubbing it out) without breaking the public surface the rest of the app links against.
|
||||
|
||||
---
|
||||
|
||||
## 1. The `LicenseState` model
|
||||
|
||||
Defined in `src/pro/license/LicenseState.swift:1-21`. It is a flat 4-case `enum LicenseState: Equatable` — **there is no separate "lifetime" enum case**:
|
||||
|
||||
```swift
|
||||
enum LicenseState: Equatable {
|
||||
case trial(daysRemaining: Int) // active trial, day count baked in
|
||||
case pro // valid paid license (any variant)
|
||||
case proExpired // version-limited Pro past its version cutoff
|
||||
case trialExpired // trial over, OR a defensively-invalidated license
|
||||
}
|
||||
```
|
||||
|
||||
Members:
|
||||
- `isProAvailable: Bool` (`LicenseState.swift:7-12`) — `true` for `.trial` and `.pro`; `false` for `.proExpired` and `.trialExpired`. This is the "can the user use Pro features right now" gate.
|
||||
- `debugProfileLabel: String` (`LicenseState.swift:14-20`) — `"Trial"`, `"Pro"`, or `"Free"` (both expired cases map to `"Free"`). Used only by `src/secondary-windows/DebugProfile.swift:17`.
|
||||
|
||||
### "Lifetime" vs "Pro" vs "version-limited" — these are *variants*, not states
|
||||
|
||||
The free/trial/pro/lifetime distinction the audit brief asks about is split across two mechanisms:
|
||||
|
||||
1. **State** (`.trial` / `.pro` / `.proExpired` / `.trialExpired`) — the runtime entitlement.
|
||||
2. **Variant slug** (a string from the backend, persisted in Keychain account `variantId`) — sub-classifies a `.pro` license:
|
||||
- **Lifetime**: `LicenseManager.lifetimeVariants: Set<String> = ["pro_lifetime"]` (`LicenseManager.swift:25`). A lifetime variant has *no version cutoff, ever*.
|
||||
- **Version-limited Pro**: `LicenseManager.versionLimitedVariants: [String: String] = [:]` (`LicenseManager.swift:28`) — maps a variant slug to a max supported version string (e.g. `"variant_slug": "X.Y.Z"`). **Currently empty**, so in this build every `.pro` variant behaves as unlimited and `.proExpired` is unreachable in practice (confirmed by `testVersionLimitedPastCutoffIsProExpired` in the specs, `LicenseManagerSpecs.md:55-56`).
|
||||
- **Regular Pro**: any variant slug that is neither lifetime nor version-limited.
|
||||
|
||||
`isLifetimeVariant` (see §6) reads the persisted `variantId` and tests membership in `lifetimeVariants`. There is **no** `.lifetime` enum case — lifetime is just `.pro` + a particular variant slug.
|
||||
|
||||
### State transitions (from `LicenseManagerSpecs.md:18-23`)
|
||||
|
||||
```
|
||||
.trial(daysRemaining) → .pro (user activates a key)
|
||||
.trial(daysRemaining) → .trialExpired (14 days elapse)
|
||||
.pro → .trialExpired (revalidation fails / license invalidated)
|
||||
.pro → .proExpired (version-limited variant past its cutoff — dead code while the dict is empty)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. The activation flow — `activate(_ licenseKey:)`
|
||||
|
||||
`LicenseManager.activate(_:completion:)` — `LicenseManager.swift:104-140`.
|
||||
|
||||
1. Calls `api.activate(licenseKey)` (network).
|
||||
2. On `.success(response: ActivateResult)`, hops to the **main queue** and builds an all-or-nothing batch of Keychain writes:
|
||||
- `licenseKey` → account `"licenseKey"` (`keychainKeyAccount`).
|
||||
- `response.instanceId` → account `"instanceId"` (`keychainInstanceAccount`).
|
||||
- `response.variantId` (if non-nil) → account `"variantId"` (`keychainVariantAccount`).
|
||||
3. **Rollback invariant** (`LicenseManager.swift:117-126`): each write is attempted in order; if any returns a status other than `errSecSuccess`, *every previously-attempted account is removed* and the call fails with `LicenseAPIError.keychainWriteFailed(account:status:)`. Validation timestamps and email are **not** written in this path, so the user can never end up half-activated. The spec (`LicenseManagerSpecs.md:29`) calls this "the most important invariant in the file."
|
||||
4. On a fully successful write batch:
|
||||
- `defaults["lastValidation"] = clock.now.timeIntervalSince1970` (`LicenseManager.swift:127`).
|
||||
- `defaults["lastValidationResult"] = true` (`:128`).
|
||||
- If `response.customerEmail` is non-nil/non-empty → `defaults[customerEmailKey] = email` (`:129-131`).
|
||||
- Calls `onBeforeProUnlock()` (`:132`) **before** flipping state.
|
||||
- Sets `state = .pro` (`:133`), which triggers `didSet → onStateChanged?(state)`.
|
||||
- `completion(.success(()))`.
|
||||
5. On `.failure(error)` from the API, state is untouched and the error is forwarded (`:135-137`).
|
||||
|
||||
The completion runs on the main thread in all branches (the whole result handler is wrapped in `DispatchQueue.main.async`).
|
||||
|
||||
App-side activation entrypoints:
|
||||
- **URL scheme** `alt-tab://activate?license_key=...` → `App.handleCustomUrl` → `LicenseManager.shared.activate(...)` (`src/App.swift:473-485`).
|
||||
- **Settings → Upgrade tab** manual key entry (`src/preferences/settings-window/tabs/UpgradeTab.swift:325`).
|
||||
|
||||
### Sibling network operations
|
||||
|
||||
- `deactivate(completion:)` — `LicenseManager.swift:142-166`. Requires both `licenseKey` and `instanceId` in Keychain (else `LicenseAPIError.invalidKey`); calls `api.deactivate`, and on success **removes all three Keychain accounts**, clears `lastValidation`, `lastValidationResult`, and `customerEmail` from defaults, then recomputes via `computeTrialState()` (→ `.trial(n)` if still inside the 14-day window, else `.trialExpired`). Consumed by `UpgradeTab.swift:405`.
|
||||
- `deactivateInstance(licenseKey:instanceId:completion:)` — `LicenseManager.swift:170-174`. Remote-deactivates *another machine's* seat without touching any local state. Used to reclaim a seat after a `seatLimitExceeded` error (`UpgradeTab.swift:389`).
|
||||
- `revalidateWithServer()` — `LicenseManager.swift:209-232` (see §4).
|
||||
|
||||
---
|
||||
|
||||
## 3. Remote server validation — the actual API
|
||||
|
||||
### Base URL resolution
|
||||
|
||||
`Endpoints` (`src/api/Endpoints.swift:3-13`) reads two Info.plist keys, themselves fed from `config/base.xcconfig`:
|
||||
|
||||
| Constant | Source | Resolved value (this build) |
|
||||
|---|---|---|
|
||||
| `Endpoints.domain` | Info.plist `Domain` ← xcconfig `DOMAIN` | `alt-tab.app` (`config/base.xcconfig:20`) |
|
||||
| `Endpoints.apiDomain` | Info.plist `ApiDomain` ← xcconfig `API_DOMAIN` | `alt-tab.app/api` (`config/base.xcconfig:21`) |
|
||||
| `Endpoints.website` | `"https://\(domain)"` | `https://alt-tab.app` |
|
||||
| `Endpoints.licenseApiBaseUrl` | `"https://\(apiDomain)/v1/license"` | **`https://alt-tab.app/api/v1/license`** |
|
||||
|
||||
`Info.plist:75-78` declares `Domain → $(DOMAIN)` and `ApiDomain → $(API_DOMAIN)`. (Note `feedbackUrl` at `Endpoints.swift:12` is `https://alt-tab.app/api/v1/feedback` — a separate, non-license endpoint owned by the feedback window.)
|
||||
|
||||
`LicenseManager.shared` injects `RemoteLicenseClient(baseUrl: Endpoints.licenseApiBaseUrl, keychain: keychain)` (`LicenseManager.swift:12`).
|
||||
|
||||
### Endpoints, methods, request/response shapes
|
||||
|
||||
`RemoteLicenseClient` (`src/pro/license/RemoteLicenseClient.swift`) is a `struct` conforming to the `LicenseAPI` protocol. All three calls are **POST**, `Content-Type: application/json`, `Accept: application/json`, body via `JSONSerialization`, executed on `URLSession.shared` (`RemoteLicenseClient.swift:103-128`). The file header comment (`:1-5`) describes it as "our own licensing backend at `alt-tab.app/v1/license/*`", provider-agnostic; the `LicenseManagerSpecs.md:13` calls the backend "LemonSqueezy-backed."
|
||||
|
||||
**POST `{baseUrl}/activate`** (`RemoteLicenseClient.swift:15-60`)
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"license_key": "<key>",
|
||||
"fingerprint": "<MachineFingerprint.get>",
|
||||
"trial_started_at": <int epoch seconds> // only if LicenseManager.shared.trialStartDate != nil
|
||||
}
|
||||
```
|
||||
`trial_started_at` (`:23-25`) lets the backend report trial→paid conversion latency (backend takes MIN across machines; omitted after deactivation clears the local trial start).
|
||||
Response DTO `ActivateResponse` (`:132-139`): `{ activated: Bool, instance_id: String?, variant_id: String?, customer_email: String?, error: String?, instances: [InstanceDTO]? }`.
|
||||
Mapping (`:28-58`):
|
||||
- `activated == true` but missing `instance_id` → `invalidResponse(debugInfo: "missing instance_id")`.
|
||||
- `activated == true` → `ActivateResult(instanceId, variantId, customerEmail)`.
|
||||
- `activated == false` + `error == "invalid_key"` → `LicenseAPIError.invalidKey`.
|
||||
- `error == "seat_limit_exceeded"` → `LicenseAPIError.seatLimitExceeded(instances:)` (maps `instances` DTOs).
|
||||
- any other non-nil `error` → `activationRejected(reason)`; nil error → `activationRejected("unknown")`.
|
||||
|
||||
**POST `{baseUrl}/validate`** (`RemoteLicenseClient.swift:62-78`)
|
||||
Request body: `{ "license_key": "<key>", "instance_id": "<id>" }`.
|
||||
Response DTO `ValidateResponse` (`:141-144`): `{ valid: Bool, variant_id: String? }` → `ValidateResult(valid, variantId)`.
|
||||
|
||||
**POST `{baseUrl}/deactivate`** (`RemoteLicenseClient.swift:80-101`)
|
||||
Request body: `{ "license_key": "<key>", "instance_id": "<id>" }`.
|
||||
Response DTO `DeactivateResponse` (`:146-149`): `{ deactivated: Bool, error: String? }`. `deactivated == false` → `LicenseAPIError.deactivationRejected`.
|
||||
|
||||
`InstanceDTO` (`:151-159`): `{ id: String, machineName: String?, lastSeenAt: Int }` → maps to the public `ActiveInstance` struct.
|
||||
|
||||
### `LicenseAPI` protocol & error model
|
||||
|
||||
`src/pro/license/LicenseAPI.swift`:
|
||||
- `protocol LicenseAPI` (`:4-8`) — the three async methods. This is the seam tests mock (`MockLicenseAPI`, `LicenseManagerTests.swift:504-527`).
|
||||
- Result structs: `ActivateResult` (`:10-14`), `ValidateResult` (`:16-19`), `ActiveInstance` (`:21-25`).
|
||||
- `enum LicenseAPIError: LocalizedError` (`:27-58`) — `invalidKey`, `activationRejected(String)`, `seatLimitExceeded(instances:)`, `deactivationRejected`, `noData`, `invalidResponse(debugInfo:)`, `apiError(String)`, `keychainWriteFailed(account:status:)`. Provides localized `errorDescription` and an internal `debugInfo`.
|
||||
|
||||
### Decode-failure handling
|
||||
|
||||
`post<T>` (`RemoteLicenseClient.swift:103-128`): network error → forwarded; nil data → `noData`; decode failure logs `statusCode`+body and, if the raw JSON has a string `error` field, surfaces `apiError(message)`, else `invalidResponse(debugInfo:)`. **Note:** no HTTP status-code gating — a 4xx/5xx with a decodable body is treated as a normal response.
|
||||
|
||||
---
|
||||
|
||||
## 4. Async revalidation (background re-check)
|
||||
|
||||
- `scheduleAsyncRevalidationIfNeeded()` (`LicenseManager.swift:202-207`): on `initialize()`, computes `elapsed = now - defaults["lastValidation"]`; only calls `revalidateWithServer()` if `elapsed >= revalidationInterval`.
|
||||
- `revalidationInterval = 30 * 24 * 60 * 60` (30 days) — `LicenseManager.swift:18` (private static).
|
||||
- `revalidateWithServer()` (`:209-232`): requires `licenseKey` + `instanceId` in Keychain (else returns). On `api.validate` success: refreshes `lastValidation` timestamp, stores `response.valid` into `lastValidationResult`, persists a returned `variantId` to Keychain, and sets `state = computeState()` if valid or `.trialExpired` if invalid. Network failure is swallowed (`:227-228`) — state and timestamp preserved, retried next launch. Also called directly from the QA menu "Revalidate" button (`src/debug/QAMenu.swift:221`).
|
||||
|
||||
---
|
||||
|
||||
## 5. State computation, machine fingerprint, persistence
|
||||
|
||||
### `computeState()` / `computeTrialState()`
|
||||
|
||||
- `computeState()` (`LicenseManager.swift:176-190`): if a `licenseKey` exists in Keychain — read `lastValidationResult`; if missing/false → **defensively** `.trialExpired` (`:179`); if a `variantId` exists and is in `versionLimitedVariants` and `currentAppVersion()` numerically exceeds the cutoff → `.proExpired` (`:180-186`); otherwise `.pro`. If no license key → `computeTrialState()`.
|
||||
- `computeTrialState()` (`:192-200`): persists `trialStartDate` on first ever call (never reset on relaunch); `daysSince = floor((now - start)/86400)`; if `>= trialDuration` → `.trialExpired`, else `.trial(daysRemaining: trialDuration - daysSince)`.
|
||||
- `trialDuration = 14` (`:17`), 0-indexed: day 0 → `.trial(14)`, day 13 → `.trial(1)`, day 14 → `.trialExpired` (`LicenseManagerSpecs.md:27`).
|
||||
- `currentAppVersion: () -> String` (`:42-44`) defaults to `CFBundleShortVersionString`; overridable for tests.
|
||||
|
||||
### Machine fingerprint
|
||||
|
||||
`MachineFingerprint.get(keychain:)` (`src/pro/license/MachineFingerprint.swift:9-20`): primary source is the IOKit `IOPlatformUUID` of `IOPlatformExpertDevice` (`:11-14`). Fallback: a previously-stored value at Keychain account `"machineFingerprint"` (`:5,:16`); if neither exists, generates a random `UUID().uuidString`, persists it to that Keychain account, and returns it. Only consumer is the `activate` request body (`RemoteLicenseClient.swift:18`). `validate`/`deactivate` use the stored `instanceId` instead.
|
||||
|
||||
### Persistence map (Keychain + UserDefaults + cookie)
|
||||
|
||||
**Keychain** — `SystemKeychain` (`src/pro/license/Keychain.swift:10-89`), generic-password items (`kSecClassGenericPassword`), accessibility `kSecAttrAccessibleWhenUnlocked` on write (`Keychain.swift:45`). `setValue` deletes-then-adds (`:39-48`). All items share one service:
|
||||
- **Service:** `LicenseManager.keychainService = "\(App.bundleIdentifier).license"` (`LicenseManager.swift:4`) → resolves to **`com.lwouis.alt-tab-macos.license`** (`App.bundleIdentifier` = `Bundle.main.bundleIdentifier!`, `src/App.swift:11`; bundle id `com.lwouis.alt-tab-macos`, `config/base.xcconfig:4`).
|
||||
- Accounts under that service:
|
||||
| Account constant | Account string | Written by |
|
||||
|---|---|---|
|
||||
| `keychainKeyAccount` | `"licenseKey"` | activate (`LicenseManager.swift:19`) |
|
||||
| `keychainInstanceAccount` | `"instanceId"` | activate (`:20`) |
|
||||
| `keychainVariantAccount` | `"variantId"` | activate / revalidate (`:21`) |
|
||||
| (MachineFingerprint) | `"machineFingerprint"` | fingerprint fallback (`MachineFingerprint.swift:5`) |
|
||||
- `removeAll()` (`Keychain.swift:74-88`, DEBUG only) wipes everything under the service — used by the QA "Mock fresh install" action.
|
||||
|
||||
> **Code-signature coupling (from AGENTS.md):** Keychain access is tied to the app's Developer ID / TeamID / bundle id. If the free build changes the bundle id or signing identity, these items become inaccessible — but for a free build that no longer reads them, that is harmless.
|
||||
|
||||
**UserDefaults** — suite `LicenseManager.defaultsSuiteName = "\(App.bundleIdentifier).license"` (`LicenseManager.swift:5`) → `com.lwouis.alt-tab-macos.license`. Non-secret bookkeeping keys:
|
||||
| Key | Type | Set by | Read by |
|
||||
|---|---|---|---|
|
||||
| `"trialStartDate"` | Double (epoch) | `computeTrialState` first launch (`:194`) | `trialStartDate`, `daysSinceTrialStart`, trial math |
|
||||
| `"lastValidation"` | Double (epoch) | activate / revalidate (`:127`,`:217`) | `scheduleAsyncRevalidationIfNeeded` |
|
||||
| `"lastValidationResult"` | Bool | activate / revalidate (`:128`,`:218`) | `computeState` defensive check |
|
||||
| `customerEmailKey` = `"customerEmail"` | String | activate (`:130`) | `customerEmail` getter |
|
||||
|
||||
**HTTP cookie** — `syncLicenseCookie(state:)` (`src/pro/license/LicenseCookie.swift:5-22`) sets a `license` cookie on `.alt-tab.app` (host from `Endpoints.website`), value `"pro"` / `"proExpired"` / `""`, `secure`, `distantFuture` expiry, in `HTTPCookieStorage.shared`. Purpose: Sparkle's appcast request can be tailored per tier. Called from the `onStateChanged` hook (`src/App.swift:451`).
|
||||
|
||||
---
|
||||
|
||||
## 6. PUBLIC SURFACE — what the rest of the app depends on
|
||||
|
||||
`LicenseManager.shared` (`LicenseManager.swift:7-15`) is the singleton; the wider app touches **only** the members below. Each entry lists definition site + every external call site, since this is what a free-build rewrite must keep compiling.
|
||||
|
||||
### `state: LicenseState` — `private(set)` stored, `didSet → onStateChanged?(state)` (`LicenseManager.swift:51-53`)
|
||||
The core read. External readers:
|
||||
- `src/App.swift:206` — `if case .pro = ... { return false }` (gates some behavior).
|
||||
- `src/Menubar.swift:86` — menubar Pro/trial display.
|
||||
- `src/preferences/settings-window/SettingsWindow.swift:162,196,206` — account UI.
|
||||
- `src/preferences/settings-window/tabs/UpgradeTab.swift:220` — upgrade tab rendering.
|
||||
- `src/pro/scheduling/ProTransitionManager.swift:172,299` — trial-nag scheduling.
|
||||
- `src/secondary-windows/DebugProfile.swift:17` — `.debugProfileLabel`.
|
||||
|
||||
### `isProLocked: Bool` — computed (`LicenseManager.swift:67-72`)
|
||||
`true` for `.proExpired`/`.trialExpired`, `false` for `.pro`/`.trial`. **The most widely consumed gate.** Drives degradation of Pro prefs and ghosting of Pro UI:
|
||||
- `src/pro/ProFeature.swift:65` (`var isLocked`).
|
||||
- `src/events/PreferencesEvents.swift:62`.
|
||||
- `src/preferences/PreferenceDefinition.swift:34`.
|
||||
- `src/preferences/settings-window/LabelAndControl.swift:86`.
|
||||
- `src/preferences/settings-window/tabs/appearance/AppearanceTab.swift:589,667`.
|
||||
- `src/preferences/settings-window/tabs/controls/ControlsTab.swift:589`.
|
||||
- `src/preferences/settings-window/tabs/controls/ShortcutEditor.swift:599,705`.
|
||||
- `src/pro/scheduling/ProTransitionManager.swift:179`.
|
||||
|
||||
### `isProAvailable: Bool` — computed, delegates to `state.isProAvailable` (`LicenseManager.swift:62`)
|
||||
- `src/pro/ProFeature.swift:63,75`.
|
||||
|
||||
### `customerEmail: String?` — computed from `defaults[customerEmailKey]` (`LicenseManager.swift:55`)
|
||||
- `src/preferences/settings-window/SettingsWindow.swift:167,196,207`.
|
||||
- `src/preferences/settings-window/tabs/UpgradeTab.swift:227`.
|
||||
|
||||
### `isLifetimeVariant: Bool` — computed from Keychain `variantId` ∈ `lifetimeVariants` (`LicenseManager.swift:57-60`)
|
||||
- `src/preferences/settings-window/SettingsWindow.swift:164`.
|
||||
- `src/preferences/settings-window/tabs/UpgradeTab.swift:228`.
|
||||
|
||||
### `onStateChanged: ((LicenseState) -> Void)?` (`LicenseManager.swift:38`)
|
||||
Fired from `state.didSet` on initialize and on every transition. Wired once in `src/App.swift:449-459` to: refresh menubar license items, sync the Sparkle cookie, notify `ProTransitionManager`, refresh `UpgradeTab`/upgrade button, reset preference-dependent components, and post `proLockStateDidChangeNotification`.
|
||||
|
||||
### `onBeforeProUnlock: () -> Void` (`LicenseManager.swift:49`, default no-op)
|
||||
Fired **before** `state` flips to `.pro` (in `activate` `:132` and `mockProUser` `:276`) so observers can snapshot pre-Pro selections. Wired in `src/App.swift:448` to `ProTransitionManager.shared.onProUnlocked()`.
|
||||
|
||||
### `initialize()` (`LicenseManager.swift:91-94`)
|
||||
Sets `state = computeState()` then schedules async revalidation. Called once at startup: `src/App.swift:460`.
|
||||
|
||||
### `refreshState()` (`LicenseManager.swift:99-102`)
|
||||
Recomputes `state` and reassigns only if changed (so the baked-in trial day count tracks the clock). Called before UI surfaces read the day count:
|
||||
- `src/Menubar.swift:396`.
|
||||
- `src/preferences/settings-window/SettingsWindow.swift:1246`.
|
||||
|
||||
### `activate(_:completion:)` (`LicenseManager.swift:104-140`) — see §2
|
||||
- `src/App.swift:481`, `src/preferences/settings-window/tabs/UpgradeTab.swift:325`.
|
||||
|
||||
### Other public members consumed externally
|
||||
- `deactivate(completion:)` — `UpgradeTab.swift:405`.
|
||||
- `deactivateInstance(licenseKey:instanceId:completion:)` — `UpgradeTab.swift:389`.
|
||||
- `revalidateWithServer()` — `QAMenu.swift:221` (DEBUG).
|
||||
- `daysSinceTrialStart: Int` (`:79-82`) — `ProTransitionManager.swift:300`.
|
||||
- `trialStartDate: Date?` (`:74-77`) — read internally by `RemoteLicenseClient.activate` (`:23`).
|
||||
- `LicenseManager.trialDuration` (static, `=14`) — referenced for trial messaging.
|
||||
- DEBUG-only QA helpers `mockTrialUser`/`mockTrialExpired`/`mockTrialDay`/`mockProUser` (`:234-279`) — `src/debug/QAMenu.swift:151,239` etc.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests & specs (skim)
|
||||
|
||||
- `LicenseManagerSpecs.md` — prose spec; the load-bearing rules are in §"Behavior & edge cases" (`:25-34`): 14-day 0-indexed trial, no grace period, all-or-nothing Keychain writes with rollback, defensive expiry, 30-day throttled revalidation, synchronous compute on initialize, callback ordering.
|
||||
- `LicenseManagerTests.swift` — 1:1 with the spec's scenario list. Tests inject `MockClock` (`:475`), `MockKeychain` (`:482`), `MockLicenseAPI` (`:504-527`) and an isolated `UserDefaults(suiteName:)` per test (`:13-23`). They never set `onStateChanged`/`onBeforeProUnlock` side effects unless asserting on them. `mockProUser`-related test is `#if DEBUG`-guarded.
|
||||
|
||||
---
|
||||
|
||||
## 8. Removal notes for a fully-free build
|
||||
|
||||
**Goal:** every feature unlocked, no trial nag, no network, no Keychain license reads — without breaking the ~30 external call sites in §6.
|
||||
|
||||
Recommended strategy (lowest blast radius): **keep the `LicenseManager.shared` public surface, gut the internals.**
|
||||
- Make `state` permanently `.pro` (or add an always-`true` short-circuit). Then `isProLocked` is always `false` and `isProAvailable` always `true` automatically — most consumers (§6) need no edits.
|
||||
- Stub `activate`/`deactivate`/`deactivateInstance`/`revalidateWithServer` to no-op-success (or delete and remove the 2 activate call sites + Upgrade tab management). The `LicenseAPI`/`RemoteLicenseClient`/`MachineFingerprint`/`LicenseCookie`/`Keychain` files can then be deleted entirely, since nothing else references them.
|
||||
- `customerEmail` → return nil; `isLifetimeVariant` → return false (or true). Their consumers are all in `SettingsWindow`/`UpgradeTab` account UI, which is being removed with the paywall anyway.
|
||||
- `onStateChanged`/`onBeforeProUnlock`/`initialize()`/`refreshState()` can become no-ops but should remain as symbols until the `App.swift` wiring (`:448-460`), `Menubar`, and `SettingsWindow` callers are also cleaned up — otherwise the build breaks.
|
||||
|
||||
**What is safe to delete outright** (no external readers beyond the license subsystem itself + the paywall UI being removed): `Clock.swift`, `MachineFingerprint.swift`, `LicenseCookie.swift` (also drop the `onStateChanged` call to `syncLicenseCookie` in `App.swift:451`), `RemoteLicenseClient.swift`, `Keychain.swift`, `LicenseAPI.swift`, `LicenseManagerTests.swift`, `LicenseManagerSpecs.md`.
|
||||
|
||||
**Coupled removals to coordinate (outside this audit's scope but flagged):**
|
||||
- `src/api/Endpoints.swift:11` `licenseApiBaseUrl` becomes dead; `Info.plist`/`config/base.xcconfig` `ApiDomain` only feeds license + feedback URLs (feedback at `Endpoints.swift:12` is independent — keep `apiDomain` if feedback stays).
|
||||
- The Sparkle appcast tier cookie (`LicenseCookie.swift`) means the free build's updater no longer differentiates tiers — verify the appcast still serves the free build without the `license` cookie.
|
||||
- `App.swift:473-485` URL-scheme `activate` handler and the whole `UpgradeTab` should be removed with the paywall UI.
|
||||
|
||||
**Risks:**
|
||||
- The `private(set) var state` `didSet` fires `onStateChanged` — if you hardcode `state = .pro` at init time, ensure the `onStateChanged` hook (and `ProTransitionManager`) tolerate being invoked, or unset the hook in the free build.
|
||||
- Existing installs have stale Keychain items under `com.lwouis.alt-tab-macos.license` and UserDefaults under the same suite; a free build can ignore them, but leaving them is cosmetically untidy.
|
||||
- Bundle id is still `com.lwouis.alt-tab-macos` (upstream's). Republishing under a new identity will change the Keychain service name anyway (moot once license reads are gone), but is a signing/identity decision per AGENTS.md.
|
||||
@@ -0,0 +1,244 @@
|
||||
# 05 — Trial / "Pro Transition" Nag-Escalation System
|
||||
|
||||
Scope: `src/pro/scheduling/` (the timed/escalating upsell machinery). This is the layer that
|
||||
schedules and fires the Day1→Day35 nag prompts, computes the "hard gate" that blocks Pro features
|
||||
after trial expiry, and persists which prompts have already been shown. It sits on top of the
|
||||
license layer (`src/pro/license/`) and the feature-gating layer (`src/pro/ProFeature.swift`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture at a glance
|
||||
|
||||
The system is split into four cooperating pieces plus eight UI classes:
|
||||
|
||||
| Piece | File | Role |
|
||||
|---|---|---|
|
||||
| Coordinator | `ProTransitionManager.swift` | Singleton. Wires license state + persisted state + scheduler + UI emitter. Holds the public hooks App.swift calls. Owns the session-only free-pass flag. |
|
||||
| Scheduler | `ProTransitionScheduler.swift` | Owns the persisted `nextScheduledDate`, the in-flight `DispatchWorkItem`, and `computeNextFireDate()`. Fires `onFire` (→ `evaluateAndShow()`). |
|
||||
| Persisted state | `ProTransitionState.swift` | All `hasSeen*` flags + `remembered*` Pro indices in the `<bundleId>.license` UserDefaults suite, keys prefixed `proTransition.`. Snapshots into the pure decision struct. |
|
||||
| Pure decision logic | `ProTransitionManagerTestable.swift` | No singletons / no `Date()` / no UI. `State` struct + `evaluate*` functions. The actual state machine. 100% unit-tested by `ProTransitionTests.swift`. |
|
||||
| UI dispatch | `src/pro/ui/ProPromptHost.swift` | Receives `ProPromptAction` enum cases and maps them to concrete Day-X window/popover classes. |
|
||||
|
||||
The coordinator never references AppKit Day-X classes directly. It emits a `ProPromptAction`
|
||||
(`ProTransitionManager.swift:87-98`) through `onAction`; `ProPromptHost.dispatch(_:)`
|
||||
(`ProPromptHost.swift:11-38`) owns the concrete `.show()` calls.
|
||||
|
||||
### Time / day model
|
||||
- `daysSinceTrialStart` is **0-indexed**: Day 1 = 0, Day 12 = 11, Day 15 = 14, Day 21 = 20,
|
||||
Day 35 = 34, Day 49 = 48 (`ProTransitionManagerTestable.swift:11`, computed in
|
||||
`LicenseManager.daysSinceTrialStart`, `LicenseManager.swift:79-82`:
|
||||
`Int(clock.now.timeIntervalSince(start) / 86400)`).
|
||||
- `trialStartDate` is stored in the license UserDefaults suite (`LicenseManager.swift:74-77`).
|
||||
- Time-of-day windows: prompts only fire between **10:00–11:30** or **15:30–17:00**
|
||||
(`ProTransitionManagerTestable.isInTimeWindow`, `ProTransitionManagerTestable.swift:148-152`:
|
||||
600–690 min or 930–1020 min). The window check is snapshotted in
|
||||
`ProTransitionState.snapshot(...)` (`ProTransitionState.swift:97-118`).
|
||||
|
||||
---
|
||||
|
||||
## 2. What triggers the system (entry points)
|
||||
|
||||
There are exactly **three** kinds of trigger, all routed through `ProTransitionManager`:
|
||||
|
||||
### A. App launch / timed scheduler fire
|
||||
- `App.swift:429` calls `ProTransitionManager.shared.onAppLaunchComplete()` at end of launch
|
||||
(after `onAction` is wired at `App.swift:428`).
|
||||
- `ProTransitionManager.onAppLaunchComplete()` (`ProTransitionManager.swift:167-169`) delegates to
|
||||
`scheduler.onAppLaunchComplete()` (`ProTransitionScheduler.swift:24-30`): if a persisted
|
||||
`nextScheduledDate` is in the past, fire immediately; then `scheduleNext()`.
|
||||
- The scheduler arms a single `DispatchWorkItem` (`ProTransitionScheduler.swift:40-55`) that calls
|
||||
`onFire` → `evaluateAndShow()` (`ProTransitionManager.swift:121, 271-293`) → consults
|
||||
`evaluateTimedAction(...)` and emits the matching prompt action, then re-arms `scheduleNext()`.
|
||||
|
||||
### B. Switcher shown / dismissed
|
||||
- `App.showUiOrCycleSelection()` calls `ProTransitionManager.shared.onSwitcherShown()` at the start
|
||||
of a **fresh** summon, not on a cycle (`App.swift:328`, inside the `isFirstSummon` branch).
|
||||
- `App.hideUi()` calls `ProTransitionManager.shared.onSwitcherDismissed()` (`App.swift:78`).
|
||||
- `onSwitcherShown()` (`ProTransitionManager.swift:205-223`) consults `evaluateSwitcherOpen(...)`
|
||||
and queues a `pendingDismissAction` (Day 4 tour, or post-expiration free-pass + [C]).
|
||||
- `onSwitcherDismissed()` (`ProTransitionManager.swift:187-201`) ends any active free-pass session
|
||||
and, 1 second later (so the focused window is back in front), runs the queued action.
|
||||
|
||||
### C. Hard-gated feature attempt (use-time)
|
||||
- `ProFeature.attemptUse()` (`ProFeature.swift:74-83`) is the gate. For the hard-gated features
|
||||
(`.extraShortcut`, `.searchInSwitcher`, `.lockSearchInSwitcher`) it calls
|
||||
`ProTransitionManager.shared.attemptHardGatedFeature(self)` (`ProFeature.swift:79`).
|
||||
- `attemptHardGatedFeature(_:)` (`ProTransitionManager.swift:226-242`) consults `evaluateHardGate`
|
||||
and returns whether the action may proceed; side effects are the free-pass / [C] / [E] ladder.
|
||||
|
||||
---
|
||||
|
||||
## 3. Public hooks called from App.swift (the integration surface)
|
||||
|
||||
These are the exact call sites a clean removal must sever (all in `src/App.swift` unless noted):
|
||||
|
||||
| Call site | Hook | Purpose |
|
||||
|---|---|---|
|
||||
| `App.swift:78` | `onSwitcherDismissed()` | Ends free-pass session, fires deferred prompt. |
|
||||
| `App.swift:116` | `ProTransitionManager.openCheckout()` | Opens LemonSqueezy checkout URL. |
|
||||
| `App.swift:207` | `hasSeenWelcome` (read) | Gates first-launch behavior (`!hasSeenWelcome`). |
|
||||
| `App.swift:328` | `onSwitcherShown()` | Queues Day 4 tour / post-expiry free-pass. |
|
||||
| `App.swift:428` | `onAction = { ProPromptHost.shared.dispatch($0) }` | Wires the emitter to the UI host. |
|
||||
| `App.swift:429` | `onAppLaunchComplete()` | Kicks off the scheduler. |
|
||||
| `App.swift:448` | `onProUnlocked()` (via `LicenseManager.onBeforeProUnlock`) | Restores remembered Pro prefs on purchase. |
|
||||
| `App.swift:452` | `onLicenseStateChanged()` | Cancels scheduler + dismisses windows on Pro; engages lock on expiry. |
|
||||
| `App.swift:458` | `proLockStateDidChangeNotification` (post) | Refreshes Settings ghost UI. |
|
||||
| `Menubar.swift:205` | `shouldShowBadgeDot` (read) | Days 13–14 menubar badge dot. |
|
||||
| `PreferenceDefinition.swift:35` | `isFreePassSessionActive` (read) | Swaps stored→remembered Pro value during free-pass. |
|
||||
| `ProFeature.swift:76,79` | `isFreePassSessionActive`, `attemptHardGatedFeature(_:)` | Hard-gate ladder. |
|
||||
| `QAMenu.swift:179-234` | many | DEBUG-only manual triggers / resets. |
|
||||
| `AppearanceTab.swift:416`, `ControlsTab.swift:141` | `proLockStateDidChangeNotification` (observe) | Live Settings ghost-UI refresh. |
|
||||
|
||||
---
|
||||
|
||||
## 4. The full trial timeline (day-by-day)
|
||||
|
||||
All prompt selection lives in `ProTransitionManagerTestable.evaluateTimedAction(_:)`
|
||||
(`ProTransitionManagerTestable.swift:63-98`), `evaluateSwitcherOpen(_:)` (lines 114-127), and
|
||||
`evaluateHardGate(_:)` (lines 101-106). Letters [A]–[H] match the spec / `ProPromptAction` enum.
|
||||
|
||||
| Day (1-idx) | `daysSinceTrialStart` | Prompt | Trigger | What fires | Gate flag | Soft / Hard | Retry / miss behavior |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **Day 1** | 0 | **[A] Welcome Letter** `Day1WelcomeLetterWindow` | Timed: launch / scheduler. `computeNextFireDate` returns `Date()` immediately while `!hasSeenWelcome` (`ProTransitionScheduler.swift:64-66`). `evaluateTimedAction` returns `.showWelcome` and blocks all other actions until seen (`Testable:67-69`). | NSWindow 560×520, Free-vs-Pro comparison table, "Start my 14-day trial" button. Copy varies fresh-install vs upgrade via `ProTransitionState.isFreshInstall` (`Day1WelcomeLetterWindow.swift:14-26`). | `hasSeenWelcome` | Soft | Always shows. |
|
||||
| **Day 4** | 3 | **[H] Day 4 Tour** `Day4TourPopover` | **Switcher open** only. `evaluateSwitcherOpen` returns `.showDay4Tour` iff `isTrialActive && daysSinceTrialStart==3 && !hasSeenDay4Tour` (`Testable:116-119`). Deferred to 1s post-dismiss (`Manager:208-210, 193-199`). | NSPopover under menubar icon, 280×110, lists App Icons/Titles, extra shortcuts, Search; "Try them in Settings". | `hasSeenDay4Tour` | Soft | No retry — only fires if switcher is opened on Day 4 exactly. |
|
||||
| Days 5–11 | 4–10 | — | — | Silent trial. | — | — | — |
|
||||
| **Day 12** | 11 | **[B] Heads-Up** `Day12HeadsUpPopover` | Timed, only on the single day `daysSinceTrialStart==11`, inside a time window (`Testable:71-73`). Scheduler drops the candidate once `< day13Start` (`Scheduler:71-76`). | NSPopover under menubar icon, 300×100, "Your Pro trial ends in 2 days", "Not now" + "Get Pro". Also refreshes badge (`ProPromptHost.swift:17-19`). | `hasSeenDay12` | Soft | If missed in 10:00 window, try 15:30 window; **skip entirely** on Day 13+ (no retry). |
|
||||
| Days 13–14 | 12–13 | Badge dot | `shouldShowBadgeDot` = `!isPro && isTrialActive && 12 <= days <= 13` (`Testable:131-133`). Read by `Menubar.swift:205`. | Orange/red dot on menubar icon. | — | Soft | Removed on Day 15 (days≥14). |
|
||||
| **Day 15 (expiry)** | 14 | License flips to `.trialExpired`; **HARD GATE arms** | On the license state change, `onLicenseStateChanged()` (`Manager:171-182`) calls `onProLockEngaged()` because `isProLocked` is now true. | Degradable Pro prefs (style, size, shortcut style) immediately downgrade to Free equivalents and the Pro value is snapshotted into `remembered*` (`ProTransitionState.onProLockEngaged`, `ProTransitionState.swift:73-79`). | — | **HARD** | — |
|
||||
| **Day 15+** | ≥14 | **[D] Proactive** `Day15ProactiveWindow` | Timed, only if **no hard-gate fired yet**: `days>=14 && !hasSeenProactiveDay15 && !hasSeenFullUpgrade && isInTimeWindow` (`Testable:77`). | NSWindow 380×280, "Your 14-day Pro trial just ended", two-stat usage hero, "Get Pro" / "Maybe later". `showProactiveDay15Window()` also calls `onProLockEngaged()` (`Manager:253-257`). | `hasSeenProactiveDay15` | Soft (window) | Retry next active day until shown or [C] fires. Scheduler stops offering it once `hasSeenFullUpgrade` (`Scheduler:79`). |
|
||||
| **Day 15+** | ≥14 | **[C] Full Upgrade** `Day15FullUpgradeWindow` | (a) First **switcher open** post-expiry → free-pass + [C] (`evaluateSwitcherOpen` → `.triggerFreePass`, `Testable:123-124`); or (b) first **hard-gate attempt** via the free-pass ladder (`evaluateHardGate`, `Testable:101-106`). | NSWindow 440×340, header resolved by `HardGateReason.resolved` priority (`Manager:40-73`), usage hero, "Get Pro" / "Continue with Free". `showFullUpgradeWindow` sets `hasSeenFullUpgrade=true` then `onProLockEngaged()` (`Manager:244-251`). | `hasSeenFullUpgrade`, `freePassUsed`, `hasTriggeredPostExpirationSwitcher` | **HARD** (this is the lock-in moment) | N/A — user-initiated. Once-ever. |
|
||||
| **after [C]** | ≥14 | **[E] Hard-Gate Popover** `Day15HardGatePopover` | Every hard-gate attempt **after** [C] shown: `evaluateHardGate` returns `.showHardGatePopover` when `freePassUsed && hasSeenFullUpgrade` (`Testable:101-106`). | NSPopover under menubar icon 280×100, header from same `resolved` priority, "Not now" / "Get Pro". | — | **HARD** (feature is blocked, returns `false`) | Fires on **every** post-[C] hard-gate attempt forever (continues after opt-out). |
|
||||
| **Day 21–34** | 20–33 | **[F] Day 21 Reminder** `Day21ReminderPopover` | Timed: `days>=20 && days<34 && !hasSeenDay21 && isInTimeWindow` (`Testable:88`). Checked **after** Day 35 so a long-inactive user skips straight to [G]. | NSPopover 300×140, "AltTab Pro is still available", `ProConversionCopy.day21Body()`, "Not now" / "Get Pro". | `hasSeenDay21` | Soft | Retry daily; skipped if Day 35 arrives first (`hasSeenDay35` guard, `Scheduler:86`). |
|
||||
| **Day 35–48** | 34–47 | **[G] Day 35 Final** `Day35FinalWindow` | Timed: `days>=34 && days<48 && !hasSeenDay35 && isInTimeWindow` (`Testable:83`). Checked **before** [F] (`Testable:82`). | NSWindow 380×280, "Still interested in Pro?", usage hero, "Get Pro" / **"No thanks — don't ask again"** (→ `userOptedOut=true`, `Day35FinalWindow.swift:37-41`). | `hasSeenDay35`, `userOptedOut` | Soft | Retry daily; **give up at Day 49** (`days<48` upper bound; scheduler caps at `day49`, `Scheduler:92-99`). ⨉ sets `hasSeenDay35` but does NOT opt out. |
|
||||
| **Day 49+** | ≥48 | — | Scheduling complete (`isSchedulingComplete`, `Testable:140`). | Steady state: [E] on every hard-gate attempt; degradable features silently fall back; purchase only via Preferences. | — | **HARD** persists | — |
|
||||
|
||||
After all proactive prompts: `evaluateTimedAction` falls through to `.refreshBadgeDot` for `days>=14`
|
||||
(`Testable:93-95`), which just re-paints the (now-removed) badge.
|
||||
|
||||
---
|
||||
|
||||
## 5. Soft nags vs. the HARD GATE — exactly what gets locked
|
||||
|
||||
**Soft nags** = [A], [H], [B], [D], [F], [G] and the badge dot. These are pure informational
|
||||
windows/popovers. They never block functionality. Each is gated once-ever by a `hasSeen*` flag.
|
||||
Dismissing them does nothing except set the flag (except [G]'s opt-out link, which sets
|
||||
`userOptedOut`).
|
||||
|
||||
**The HARD GATE** is the feature-blocking mechanism that arms at trial expiry (Day 15). Two layers:
|
||||
|
||||
1. **Degradable prefs downgrade immediately** (`onProLockEngaged`, `ProTransitionState.swift:73-79`).
|
||||
On the `isProLocked` transition, every entry in `ProGatedPreferences.all` is snapshotted and
|
||||
downgraded: appearance style → `.thumbnails`, size → `.medium`, shortcut style →
|
||||
`.doNothingOnRelease`. The original Pro index is saved to `remembered*` (drives Settings "ghost"
|
||||
UI + the one-last-session free-pass read). `PreferenceDefinition.read()` (`PreferenceDefinition.swift:32-44`)
|
||||
returns the Free equivalent once locked — unless a free-pass session is active, in which case it
|
||||
returns the remembered Pro value (`PreferenceDefinition.swift:35-39`).
|
||||
|
||||
2. **Hard-gated features are blocked at use-time** via the **free-pass ladder**
|
||||
(`evaluateHardGate`, `Testable:101-106`):
|
||||
- `isPro || isTrialActive` → `.allow` (run normally).
|
||||
- `!freePassUsed` → `.freePass`: allow the action **once**, mark `freePassUsed=true`, open a
|
||||
free-pass session, and queue [C] for after dismissal (`Manager:230-234`).
|
||||
- `!hasSeenFullUpgrade` → `.showFullUpgrade`: block the feature (return `false`), show [C]
|
||||
inline (`Manager:235-237`).
|
||||
- else → `.showHardGatePopover`: block the feature, show [E] (`Manager:238-240`). Fires forever.
|
||||
|
||||
Hard-gated features (`ProFeature.attemptUse`, `ProFeature.swift:77-82`): `.extraShortcut`
|
||||
(shortcut slot ≥ 2), `.searchInSwitcher`, `.lockSearchInSwitcher`. The degradable-only set
|
||||
(`.appIconsAndTitlesStyle`, `.autoSize`, `.searchOnReleaseShortcut`) always returns `true` here
|
||||
because it is gated at preference-write time, not use-time.
|
||||
|
||||
**The defining difference:** soft nags return control to the user immediately. After [C] has fired,
|
||||
the hard gate **permanently blocks** the three hard-gated features (returns `false`, action does
|
||||
not execute) and shows [E] each time — even after opt-out, even past Day 49. Only purchase clears it.
|
||||
|
||||
---
|
||||
|
||||
## 6. Free-pass sessions
|
||||
|
||||
A free-pass session is a **session-only** window (`isFreePassSessionActive`,
|
||||
`ProTransitionManager.swift:135-142`) during which the user gets their Pro experience back for one
|
||||
last switcher summon. It is opened in two places:
|
||||
|
||||
- `onSwitcherShown()` → `.triggerFreePass` (first post-expiry summon): sets `freePassUsed=true`,
|
||||
`hasTriggeredPostExpirationSwitcher=true`, `isFreePassSessionActive=true`, builds a
|
||||
`HardGateReason.proPreferences(...)` from `remembered*`, and queues [C] (`Manager:211-219`).
|
||||
- `attemptHardGatedFeature()` → `.freePass`: sets `freePassUsed=true`, `isFreePassSessionActive=true`,
|
||||
queues [C] for the attempted feature (`Manager:230-234`).
|
||||
|
||||
While active:
|
||||
- `PreferenceDefinition.read()` returns the **remembered Pro value** instead of the downgraded
|
||||
stored value (`PreferenceDefinition.swift:35-39`), so the switcher renders Pro for that one session.
|
||||
- `ProFeature.attemptUse()` returns `true` for *all* features without re-consuming the pass
|
||||
(`ProFeature.swift:76`), so search/lock-search/extra-shortcut chords work inside the session.
|
||||
- Setting the flag triggers `App.resetPreferencesDependentComponents()` (when `TilesPanel` exists)
|
||||
so `TilesView` re-renders against the new `read()` (`Manager:136-142`).
|
||||
|
||||
The session ends in `onSwitcherDismissed()` (`Manager:189`), which sets
|
||||
`isFreePassSessionActive=false`; the deferred [C] window then appears 1s later (`Manager:193-199`).
|
||||
|
||||
---
|
||||
|
||||
## 7. How the schedule is computed and persisted
|
||||
|
||||
`ProTransitionScheduler.computeNextFireDate()` (`ProTransitionScheduler.swift:58-103`):
|
||||
- Bails (returns `nil`) if Pro, or `userOptedOut && hasSeenDay35`, or no `trialStartDate`.
|
||||
- If `!hasSeenWelcome` → returns `Date()` (fire [A] now).
|
||||
- Otherwise builds a candidate list from the unshown prompts (Day 12, Day 15 proactive, Day 21,
|
||||
Day 35), each via `nextTimeWindow(onOrAfterDay:trialStart:)` (`Scheduler:106-123`) which finds the
|
||||
next 10:00 or 15:30 slot on/after the target trial day (scanning up to 60 days). Returns the
|
||||
**earliest** candidate.
|
||||
- Day 12 candidate is dropped once past `day13Start` (`Scheduler:71-76`); Day 35 candidate must be
|
||||
before `day49` (`Scheduler:92-99`).
|
||||
|
||||
`scheduleNext()` (`Scheduler:40-55`) cancels the prior `DispatchWorkItem`, persists the chosen date
|
||||
to UserDefaults key `proTransition.nextScheduledDate` (`Scheduler:14, 46`), and arms a new
|
||||
`DispatchWorkItem` via `asyncAfter`. On fire it calls `onFire()` then re-schedules.
|
||||
|
||||
**Persistence:** all flags + the next-fire date live in the `<bundleId>.license` UserDefaults suite,
|
||||
keys prefixed `proTransition.` (`ProTransitionState.swift:9, 136-154`; `Scheduler:14`). Note the
|
||||
schedule and the nag state are co-located in the **license** suite (shared with `trialStartDate`),
|
||||
not standard defaults. The pure-decision `isSchedulingComplete` (`Testable:136-145`) mirrors the
|
||||
scheduler's bail conditions for testing.
|
||||
|
||||
`cancel()` (`Scheduler:33-37`) removes `nextScheduledDate` and the work item — called on purchase
|
||||
via `onLicenseStateChanged()` (`Manager:172-176`), which also emits `.dismissAllProWindows` +
|
||||
`.refreshBadge`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Removal notes (for the free build)
|
||||
|
||||
Removing the nag system cleanly is largely about severing the App.swift hooks in §3 and deleting the
|
||||
`src/pro/scheduling/` directory. Key dependencies and risks:
|
||||
|
||||
- **`src/pro/scheduling/` is self-contained UI/logic** but depends on: `ProFeature` /
|
||||
`ProGatedPreferences` (feature gating), `LicenseManager` (state, `trialStartDate`,
|
||||
`daysSinceTrialStart`, `clock`), `UsageStats`, `ProConversionCopy` / `ProFeatureCopy`,
|
||||
`ProPromptWindow`/`ProPromptPopover`/`ProPromptHeader`/`UsageStatHeroView`/`NotAdvisedButton`
|
||||
(shared Pro UI in `src/pro/ui/`), and `Endpoints.checkoutUrl`. Those are part of the broader
|
||||
paywall and are covered by other audit sections; this directory is a *consumer* of them.
|
||||
- **App.swift hooks to remove/neutralize** (all load-bearing): `App.swift:78, 116, 207, 328, 428,
|
||||
429, 448, 452, 458`. The `onSwitcherShown`/`onSwitcherDismissed` calls are in the switcher hot
|
||||
path; the `pendingDismissAction` 1s-deferred dispatch must be dropped with them.
|
||||
- **`ProFeature.attemptUse()` (`ProFeature.swift:74-83`)** calls into `attemptHardGatedFeature`.
|
||||
For a fully-unlocked build this must unconditionally return `true` (delete the
|
||||
`ProTransitionManager` call and the `isFreePassSessionActive` short-circuit at `ProFeature.swift:76`).
|
||||
- **`PreferenceDefinition.read()` (`PreferenceDefinition.swift:32-44`)** references
|
||||
`isFreePassSessionActive` and `ProTransitionState.int(...)`. With no lock these branches are dead;
|
||||
removal must keep `read()` returning the stored value unconditionally.
|
||||
- **`Menubar.swift:205`** reads `shouldShowBadgeDot`; remove the badge-dot call.
|
||||
- **Settings observers** (`AppearanceTab.swift:416`, `ControlsTab.swift:141`) subscribe to
|
||||
`proLockStateDidChangeNotification`; with no lock these never fire but the symbol must still
|
||||
resolve or be removed alongside the manager.
|
||||
- **`ProGatedPreferences.all`** is iterated by `onProLockEngaged`/`onProUnlocked`. If those prefs
|
||||
become permanently unlocked, the snapshot/restore logic is dead code; ensure no stored value is
|
||||
ever forced to the Free equivalent on launch.
|
||||
- **Risk — orphaned UserDefaults:** existing installs carry `proTransition.*` keys in the
|
||||
`<bundleId>.license` suite (and `trialStartDate`). A free build that ignores them is safe, but if
|
||||
any remaining read of `isProLocked` survives, a previously-expired user could still see a degraded
|
||||
pref. Confirm `isProLocked` always evaluates to `false` in the free build.
|
||||
- **DEBUG-only QAMenu** (`QAMenu.swift:170-241`) directly instantiates every Day-X window and pokes
|
||||
manager flags; it must be removed/updated in lockstep or it won't compile.
|
||||
- **Tests:** `ProTransitionTests.swift` (729 lines) and `ProTransitionManagerTestable.swift` are
|
||||
pure-logic and will need to be deleted with the system; nothing outside scheduling imports them.
|
||||
@@ -0,0 +1,215 @@
|
||||
# 06 — ProFeature Model & Marketing/Conversion Copy
|
||||
|
||||
Scope: `src/pro/ProFeature.swift`, `src/pro/ProFeatureCopy.swift`, `src/pro/ProConversionCopy.swift`, and the Pro-gating machinery in `src/preferences/PreferenceDefinition.swift`. Cross-references to call sites are included so a clean free-build removal can be planned without breaking compilation.
|
||||
|
||||
All paths are absolute under `/Users/james.maes/Git.Local/kof22/alt-tab-free`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The `ProFeature` model
|
||||
|
||||
Defined in `src/pro/ProFeature.swift:8`. `ProFeature` is an `enum: Equatable, Hashable` that is described in its own doc comment (`src/pro/ProFeature.swift:3-7`) as the "single source of truth" for: which preferences degrade on lock, which runtime actions are hard-gated, which preference keys the click-interceptor routes to the Upgrade tab, and which copy the Day-X views show.
|
||||
|
||||
### 1.1 Cases (`src/pro/ProFeature.swift:9-16`)
|
||||
|
||||
```swift
|
||||
case appIconsAndTitlesStyle // degradable preference
|
||||
case autoSize // degradable preference
|
||||
case searchOnReleaseShortcut // degradable preference
|
||||
case extraShortcut(index: Int) // hard-gated runtime action, no stored pref
|
||||
case searchInSwitcher // hard-gated runtime action, no stored pref
|
||||
case lockSearchInSwitcher // hard-gated runtime action, no stored pref
|
||||
```
|
||||
|
||||
### 1.2 `GateKind` and `gateKind` (`src/pro/ProFeature.swift:18-33`)
|
||||
|
||||
Three gate kinds:
|
||||
- `.degradable` — silent fallback only; the stored value is downgraded on lock; never triggers the `[C]` upgrade prompt by itself.
|
||||
- `.hardGated` — use-time free-pass → `[C]` ladder; no stored preference.
|
||||
- `.degradableAndHardGated` — both: silent downgrade AND the first post-expiration switcher summon triggers `[C]`.
|
||||
|
||||
Mapping in `gateKind` (`src/pro/ProFeature.swift:27-33`):
|
||||
- `.autoSize` → `.degradable`
|
||||
- `.appIconsAndTitlesStyle`, `.searchOnReleaseShortcut` → `.degradableAndHardGated`
|
||||
- `.extraShortcut`, `.searchInSwitcher`, `.lockSearchInSwitcher` → `.hardGated`
|
||||
|
||||
### 1.3 `gatedPreference` — link to the backing preference (`src/pro/ProFeature.swift:35-44`)
|
||||
|
||||
Returns the type-erased `AnyProGatedPreference` (defined in `PreferenceDefinition.swift`) that backs a degradable feature, or `nil` for hard-gated cases:
|
||||
- `.appIconsAndTitlesStyle` → `ProGatedPreferences.appearanceStyle.erased`
|
||||
- `.autoSize` → `ProGatedPreferences.appearanceSize.erased`
|
||||
- `.searchOnReleaseShortcut` → `ProGatedPreferences.shortcutStyle.erased`
|
||||
- `.extraShortcut`, `.searchInSwitcher`, `.lockSearchInSwitcher` → `nil`
|
||||
|
||||
### 1.4 `copy` — per-case marketing line (`src/pro/ProFeature.swift:46-56`)
|
||||
|
||||
Maps each case to a `ProFeatureCopy` string. Note multiple cases share a line:
|
||||
- `.appIconsAndTitlesStyle` → `ProFeatureCopy.appIconsAndTitles`
|
||||
- `.autoSize` → `ProFeatureCopy.autoSize`
|
||||
- `.searchOnReleaseShortcut` → `ProFeatureCopy.extraShortcuts` ("grouped under keyboard shortcuts")
|
||||
- `.extraShortcut` → `ProFeatureCopy.extraShortcuts`
|
||||
- `.searchInSwitcher`, `.lockSearchInSwitcher` → `ProFeatureCopy.search`
|
||||
|
||||
### 1.5 `degradable` list (`src/pro/ProFeature.swift:59`)
|
||||
|
||||
```swift
|
||||
static let degradable: [ProFeature] = [.appIconsAndTitlesStyle, .autoSize, .searchOnReleaseShortcut]
|
||||
```
|
||||
|
||||
### 1.6 Availability & runtime gating
|
||||
|
||||
- `isAvailable` (`src/pro/ProFeature.swift:63`) → `LicenseManager.shared.isProAvailable`
|
||||
- `isLocked` (`src/pro/ProFeature.swift:65`) → `LicenseManager.shared.isProLocked`
|
||||
- `attemptUse() -> Bool` (`src/pro/ProFeature.swift:74-83`): returns `true` if Pro is available or a free-pass session is active; otherwise hard-gated cases consult `ProTransitionManager.shared.attemptHardGatedFeature(self)` while degradable cases always return `true` (they are gated at preference-write time, not use time).
|
||||
- `isStoredValuePro(preferenceKey:)` (`src/pro/ProFeature.swift:87-89`): static helper that delegates to `ProGatedPreferences.forPreferenceKey(...)?.isStoredValuePro()`. **Note:** the `snapshotAndDowngradeStored` / `restoreStored` / `preferenceKey` / `rememberedKey` methods named in the audit prompt no longer live on `ProFeature` — the doc comments in `PreferenceDefinition.swift:16-17` and `:99-101` explicitly state this logic was extracted out of `ProFeature` into `PreferenceDefinition`. They are documented in section 3 below at their current home.
|
||||
|
||||
---
|
||||
|
||||
## 2. Marketing copy strings (these get deleted)
|
||||
|
||||
### 2.1 `ProFeatureCopy` (`src/pro/ProFeatureCopy.swift:3-8`)
|
||||
|
||||
The canonical feature-list strings. Every one of these is a `NSLocalizedString`:
|
||||
|
||||
| Constant | String |
|
||||
|---|---|
|
||||
| `appIconsAndTitles` | `"App Icons & Titles styles"` |
|
||||
| `search` | `"Search — find a window by typing"` |
|
||||
| `autoSize` | `"Auto Size — larger previews with few windows, more at once with many"` |
|
||||
| `extraShortcuts` | `"Up to 9 keyboard shortcuts"` |
|
||||
|
||||
Consumers (all upsell UI, to be removed/neutralised):
|
||||
- `src/pro/scheduling/Day1WelcomeLetterWindow.swift:77-80` — the Free-vs-Pro comparison table rows.
|
||||
- `src/pro/ProFeature.swift:50-54` — the `copy` accessor.
|
||||
|
||||
### 2.2 `ProConversionCopy` (`src/pro/ProConversionCopy.swift`)
|
||||
|
||||
View-layer presenter for trial-nag prompts. Reads from `UsageStats` and emits formatted upsell strings. All strings are `NSLocalizedString` and are pure upsell/nag copy.
|
||||
|
||||
`day12Subtitle()` (`src/pro/ProConversionCopy.swift:11-24`):
|
||||
- empty usage: `"On Day 15, Pro features revert to the free version."`
|
||||
- 1–2 used: `"You've been using %@.\nThese will switch back to defaults on Day 15."`
|
||||
- 3+ used: `"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15."`
|
||||
|
||||
`day21Body()` (`src/pro/ProConversionCopy.swift:26-44`):
|
||||
- both counts > 0: `"You've used AltTab %@ times — %@ of those used Pro features."`
|
||||
- triggers only: `"You've used AltTab %@ times."`
|
||||
- neither: `"Pro is still available whenever you're ready."`
|
||||
|
||||
Consumers:
|
||||
- `src/pro/scheduling/Day12HeadsUpPopover.swift:21` — `ProConversionCopy.day12Subtitle()`
|
||||
- `src/pro/scheduling/Day21ReminderPopover.swift:18` — `ProConversionCopy.day21Body()`
|
||||
|
||||
### 2.3 Adjacent upsell copy that references this scope (out of file, in scope by linkage)
|
||||
|
||||
These strings live in sibling files but are wired directly to `ProFeatureCopy`/feature names and are also upsell copy slated for deletion:
|
||||
|
||||
- `src/pro/scheduling/Day1WelcomeLetterWindow.swift:20-26` — welcome/"now has a Pro tier" titles and the 14-day-trial body copy; `:36` `"Start my 14-day trial"`; `:75-76,89-92` table headers (`"Feature"`, `"Free"`, gradient "Pro" header) and the two always-true free rows (`"Reliable, fast window switching with high-quality thumbnails"`, `"Dark Mode, live preview, window controls, trackpad gestures, and more"`).
|
||||
- `src/pro/scheduling/Day15FullUpgradeWindow.swift:22-44` — supporting lines: `"AltTab Pro adds 4 features beyond the free switcher."`, `"Some Pro features have reverted to free defaults."`, plus per-reason lines (`"Extra shortcuts are a Pro feature."`, `"Search is a Pro feature."`, `"The App Icons style is a Pro feature."`, `"The Titles style is a Pro feature."`); `:59` `"Get Pro"`; `:66` `"Continue with Free"`.
|
||||
- `src/util/UsageStatsTestable.swift:4-9` — feature display names fed into `ProConversionCopy.day12Subtitle()` via `UsageStats.usedProFeatureNames()`: `"Search"`, `"App Icons appearance"`, `"Titles appearance"`, `"Extra shortcuts"`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Snapshot / downgrade / restore machinery (now in `PreferenceDefinition.swift`)
|
||||
|
||||
The methods the prompt calls `snapshotAndDowngradeStored` / `restoreStored` / `isStoredValuePro` / `preferenceKey` / `rememberedKey` have been refactored out of `ProFeature` and now live on `PreferenceDefinition<T>` / `AnyProGatedPreference` in `src/preferences/PreferenceDefinition.swift`.
|
||||
|
||||
### 3.1 `PreferenceGate<T>` (`src/preferences/PreferenceDefinition.swift:7-11`)
|
||||
|
||||
Per-preference gate policy carrying:
|
||||
- `freeEquivalent: T` — the value to fall back to when locked.
|
||||
- `rememberedKey: String` — UserDefaults sub-key under which the Pro value index is snapshotted.
|
||||
- `isProValue: (T) -> Bool` — predicate identifying which stored values count as "Pro".
|
||||
|
||||
### 3.2 `PreferenceDefinition<T>` (`src/preferences/PreferenceDefinition.swift:18-74`)
|
||||
|
||||
Single-source-of-truth per macro preference. Carries `key`, `default`, optional `gate`. Methods:
|
||||
- `read() -> T` (`:32-44`) — reads stored value via `CachedUserDefaults.macroPref`; if locked + gated, three branches: (1) active free-pass + valid remembered index → return remembered Pro selection; (2) stored value is still Pro → return `freeEquivalent`; (3) otherwise return stored (already downgraded).
|
||||
- `snapshotAndDowngrade() -> Int?` (`:51-57`) — if stored value is a Pro selection, overwrite with `freeEquivalent` via `Preferences.set(...)` and return the original index for the caller to persist under `rememberedKey`. Returns `nil` if no gate or already free.
|
||||
- `restore(from rememberedIndex:)` (`:62-65`) — writes the remembered index back via `Preferences.set(key, ..., false)` (notify:false so the restore pass doesn't bounce observers to Upgrade).
|
||||
- `isStoredValuePro() -> Bool` (`:69-73`) — true when the stored value is a Pro selection, regardless of lock state. This is what `ProFeature.isStoredValuePro(preferenceKey:)` and `PreferencesEvents` consult.
|
||||
|
||||
### 3.3 `AnyProGatedPreference` (`src/preferences/PreferenceDefinition.swift:79-96`)
|
||||
|
||||
Type-erased descriptor exposing concrete-typed ops only: `preferenceKey`, `rememberedKey`, `snapshotAndDowngrade: () -> Int?`, `restoreFromIndex: (Int) -> Void`, `isStoredValuePro: () -> Bool`. Produced by `PreferenceDefinition.erased` (`:88-95`). This is what `ProFeature.gatedPreference` returns.
|
||||
|
||||
### 3.4 `ProGatedPreferences` registry (`src/preferences/PreferenceDefinition.swift:102-170`)
|
||||
|
||||
Holds all gated `PreferenceDefinition`s, the `all` list, and `forPreferenceKey(_:)` lookup. See section 4 for the value-level mapping.
|
||||
|
||||
### 3.5 Where the passes are driven (`src/pro/scheduling/ProTransitionState.swift`)
|
||||
|
||||
- `onProLockEngaged()` (`:73-79`) — iterates `ProGatedPreferences.all`, calls `pref.snapshotAndDowngrade()`, and persists the returned index under `pref.rememberedKey`. Idempotent.
|
||||
- `onProUnlocked()` (`:85-92`) — iterates `ProGatedPreferences.all`, restores from remembered index via `pref.restoreFromIndex(idx)`, then clears the remembered key.
|
||||
- `resetAll()` (DEBUG, `:125-131`) — clears the remembered keys among other transition flags.
|
||||
|
||||
---
|
||||
|
||||
## 4. Definitive list of "Pro" features and their preference mappings
|
||||
|
||||
### 4.1 Degradable features (have a backing preference)
|
||||
|
||||
| ProFeature case | Backing `ProGatedPreferences` def | preferenceKey | rememberedKey | default | freeEquivalent | isProValue (= what counts as Pro) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `.appIconsAndTitlesStyle` | `appearanceStyle` (`PreferenceDefinition.swift:103-109`) | `appearanceStyle` | `rememberedAppearanceStyle` | `.thumbnails` | `.thumbnails` | `$0 != .thumbnails` (i.e. `.appIcons` or `.titles`) |
|
||||
| `.autoSize` | `appearanceSize` (`:111-117`) | `appearanceSize` | `rememberedAppearanceSize` | `.auto` | `.medium` | `$0 == .auto` |
|
||||
| `.searchOnReleaseShortcut` | `shortcutStyle` (`:119-125`) | `shortcutStyle` | `rememberedShortcutStyle` | `.focusOnRelease` | `.doNothingOnRelease` | `$0 == .searchOnRelease` |
|
||||
|
||||
Underlying enums (`src/preferences/MacroPreferences.swift`):
|
||||
- `AppearanceStylePreference` (`:284-287`): `.thumbnails`, `.appIcons`, `.titles` → Pro = `.appIcons`/`.titles`.
|
||||
- `AppearanceSizePreference` (`:308-312`): `.small`, `.medium`, `.large`, `.auto` → Pro = `.auto`.
|
||||
- `ShortcutStylePreference` (`:100-103`): `.focusOnRelease`, `.doNothingOnRelease`, `.searchOnRelease` → Pro = `.searchOnRelease`.
|
||||
|
||||
### 4.2 Per-shortcut override gated preferences (no direct ProFeature case)
|
||||
|
||||
`ProGatedPreferences.all` (`PreferenceDefinition.swift:157-164`) also includes three per-shortcut "override 0" definitions that are NOT directly mapped from a `ProFeature` case but ARE snapshotted/restored on lock/unlock. Their registered defaults are the FREE values, so `snapshotAndDowngrade` is a no-op unless the user explicitly set a Pro override:
|
||||
|
||||
| Def | preferenceKey | rememberedKey | default | freeEquivalent | isProValue |
|
||||
|---|---|---|---|---|---|
|
||||
| `appearanceStyleOverride0` (`:131-137`) | `appearanceStyleOverride` | `rememberedAppearanceStyleOverride` | `.thumbnails` | `.thumbnails` | `$0 != .thumbnails` |
|
||||
| `appearanceSizeOverride0` (`:139-145`) | `appearanceSizeOverride` | `rememberedAppearanceSizeOverride` | `.medium` | `.medium` | `$0 == .auto` |
|
||||
| `shortcutStyleOverride0` (`:147-153`) | `shortcutStyleOverride` | `rememberedShortcutStyleOverride` | `.doNothingOnRelease` | `.doNothingOnRelease` | `$0 == .searchOnRelease` |
|
||||
|
||||
### 4.3 Hard-gated features (no backing preference)
|
||||
|
||||
| ProFeature case | Gate site | Behaviour |
|
||||
|---|---|---|
|
||||
| `.extraShortcut(index:)` | `src/switcher/ShortcutAction.swift:56` — gates pressing shortcut slot index ≥ 1 | `attemptUse()` → `ProTransitionManager.attemptHardGatedFeature`. Also recorded by `UsageStats.recordTrigger` under `triggersExtraShortcuts`. |
|
||||
| `.searchInSwitcher` | `src/switcher/main-window/TilesView.swift:92` (`enableSearchEditing`) | gated via `SearchModeResolver.enableEditing(...canSearch:)` |
|
||||
| `.lockSearchInSwitcher` | `src/switcher/main-window/TilesView.swift:79` (`lockSearchMode`) | gated via `SearchModeResolver.lock(...canLockSearch:)` |
|
||||
|
||||
The four marketing-level "Pro features" advertised to users (per `Day15FullUpgradeWindow.swift:23-25` "adds 4 features" and the `Day1` table `:77-80`) are: **App Icons & Titles styles, Search, Auto Size, and up to 9 keyboard shortcuts.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-reference: all known consumers of this scope
|
||||
|
||||
| Symbol | Consumer site |
|
||||
|---|---|
|
||||
| `ProFeature.extraShortcut(index:).attemptUse()` | `src/switcher/ShortcutAction.swift:56` |
|
||||
| `ProFeature.lockSearchInSwitcher.attemptUse()` | `src/switcher/main-window/TilesView.swift:79` |
|
||||
| `ProFeature.searchInSwitcher.attemptUse()` | `src/switcher/main-window/TilesView.swift:92` |
|
||||
| `ProFeature.isStoredValuePro(preferenceKey:)` | `src/events/PreferencesEvents.swift:62` (bounces to `UpgradeTab.navigateToUpgradeTab()` when locked) |
|
||||
| `ProFeatureCopy.*` (4 strings) | `src/pro/scheduling/Day1WelcomeLetterWindow.swift:77-80`; `src/pro/ProFeature.swift:50-54` |
|
||||
| `ProConversionCopy.day12Subtitle()` | `src/pro/scheduling/Day12HeadsUpPopover.swift:21` |
|
||||
| `ProConversionCopy.day21Body()` | `src/pro/scheduling/Day21ReminderPopover.swift:18` |
|
||||
| `ProGatedPreferences.all` / `forPreferenceKey` | `src/pro/scheduling/ProTransitionState.swift:74,86,129`; `src/preferences/Preferences.swift`; `src/pro/ProFeature.swift:39-41,88` |
|
||||
| `SearchModeResolver` gating note | `src/switcher/state/SearchModeResolver.swift:9` |
|
||||
|
||||
`SearchModeResolver` itself does not depend on `ProFeature` — it is purely a pure-state resolver that receives `canSearch`/`canLockSearch` booleans, so in a free build those callers can simply pass `true` (or be inlined) without touching `SearchModeResolver`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Removal notes for a clean free build
|
||||
|
||||
1. **`attemptUse()` must become an unconditional `true`.** The three runtime gate sites (`ShortcutAction.swift:56`, `TilesView.swift:79,92`) all call `ProFeature.*.attemptUse()`. For a fully-unlocked build, either delete the `if !... { return }` guard at `ShortcutAction.swift:53-58` entirely, and pass `canSearch: true` / `canLockSearch: true` directly at `TilesView.swift:79,92`, or stub `attemptUse()` to always return `true`. Removing `ProFeature` wholesale requires editing all three call sites.
|
||||
|
||||
2. **`PreferencesEvents.swift:62` bounce-to-Upgrade must be removed.** This is the only consumer of `ProFeature.isStoredValuePro(preferenceKey:)`. Deleting the `if LicenseManager.shared.isProLocked && ProFeature.isStoredValuePro(...)` block (`:62-64`) prevents settings changes from yanking users to a (deleted) Upgrade tab. Note `UpgradeTab.navigateToUpgradeTab()` is itself paywall UI removed elsewhere.
|
||||
|
||||
3. **Preference gating (`PreferenceDefinition.read()`) must stop downgrading.** `read()` (`PreferenceDefinition.swift:32-44`) returns `freeEquivalent` when `LicenseManager.shared.isProLocked`. With a free build `isProLocked` should be hard-wired to `false` (license layer is audited separately), which neutralises `read()`, `snapshotAndDowngrade()`, and `onProLockEngaged()` without code changes here. Cleaner removal: strip the `gate:` arguments from all six `ProGatedPreferences` definitions (`:103-153`) so every preference is ungated, then `ProGatedPreferences.all` becomes empty and the snapshot/restore passes in `ProTransitionState` become no-ops. Confirm `Preferences.swift` getters still resolve through `PreferenceDefinition.read()` (referenced by the doc comment at `:99-100`) before stripping.
|
||||
|
||||
4. **`rememberedKey` migration risk.** Existing installs may have `proTransition.rememberedAppearanceStyle` / `...Size` / `...Style` (and the three `*Override` variants) persisted from a prior lock. If gating is removed without an unlock pass, a user's pre-lock Pro selection stays downgraded in `appearanceStyle`/`appearanceSize`/`shortcutStyle`. Consider running `onProUnlocked()` (`ProTransitionState.swift:85-92`) once on upgrade-to-free-build, or document that users may need to re-select their preferred style/size after migrating. The remembered keys are namespaced `proTransition.<key>` in UserDefaults.
|
||||
|
||||
5. **Copy deletion is self-contained.** `ProFeatureCopy.swift` and `ProConversionCopy.swift` can be deleted in full once `ProFeature.copy` (`ProFeature.swift:48-56`), `Day1WelcomeLetterWindow.swift:77-80`, `Day12HeadsUpPopover.swift:21`, and `Day21ReminderPopover.swift:18` are removed. The feature-name strings in `UsageStatsTestable.swift:4-9` and the `UsageStats.usedProFeatureNames()`/`usedProFeaturesSessionCount` telemetry feed only the conversion copy — they can be deleted with the nag system. `Day15FullUpgradeWindow.swift` supporting lines (`:22-44`) and `HardGateReason` are tied to the hard-gate ladder and are removed with that subsystem (covered by other audit sections).
|
||||
|
||||
6. **`UsageStats` trigger recording** (`src/util/UsageStats.swift:8-15`) exists only to power conversion copy and the Day-X nags. It has no functional effect on switching; safe to remove along with the prompt system, but it is harmless if left (writes to a separate `.usage` UserDefaults suite).
|
||||
@@ -0,0 +1,332 @@
|
||||
# 07 — Pro Upsell UI Overlay & Menu-bar Integration
|
||||
|
||||
**Scope:** `src/pro/ui/` (the shared upsell UI toolkit) and `src/Menubar.swift` (the
|
||||
`NSStatusItem` menu and its license/trial/badge integration).
|
||||
|
||||
This section documents every upsell-specific UI component, how `ProPromptHost.dispatch`
|
||||
routes the timed Day-X prompts, every menu-bar item that is license / trial / upsell
|
||||
related, and what the menu bar should look like once the paywall is removed.
|
||||
|
||||
> Cross-references: the Day-X window/popover classes live in `src/pro/scheduling/`
|
||||
> (audited separately). The coordinator that *emits* prompt actions is
|
||||
> `ProTransitionManager` (`src/pro/scheduling/ProTransitionManager.swift`). License state
|
||||
> lives in `src/pro/license/`. This section only covers the *UI receivers* of those
|
||||
> emissions and the menu-bar surface.
|
||||
|
||||
---
|
||||
|
||||
## 1. The `src/pro/ui/` toolkit
|
||||
|
||||
`src/pro/ui/` is a small library of reusable AppKit building blocks. None of these classes
|
||||
contain license/trial *logic* themselves — they are pure presentation primitives consumed
|
||||
by the Day-X scheduling windows (in `src/pro/scheduling/`) and by the Settings "Upgrade"
|
||||
tab (`src/preferences/settings-window/tabs/UpgradeTab.swift`). They exist *only* to render
|
||||
the paywall/upsell, so all of them are deletion candidates for a fully-free build, but
|
||||
several are referenced from non-pro code and must be untangled (see §1.8).
|
||||
|
||||
### 1.1 `ProPromptHost.swift` — the dispatch hub
|
||||
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProPromptHost.swift`
|
||||
|
||||
A singleton (`ProPromptHost.shared`, line 9) whose sole job is to translate the abstract
|
||||
`ProPromptAction` enum into concrete Day-X UI calls. It is wired at launch in
|
||||
`src/App.swift:428`:
|
||||
|
||||
```swift
|
||||
ProTransitionManager.shared.onAction = { ProPromptHost.shared.dispatch($0) }
|
||||
```
|
||||
|
||||
`dispatch(_:)` (lines 11–38) is a single `switch` over `ProPromptAction`
|
||||
(defined in `src/pro/scheduling/ProTransitionManager.swift:87-98`). The complete routing
|
||||
table:
|
||||
|
||||
| `ProPromptAction` case | Source line | UI call made | Day label |
|
||||
|---|---|---|---|
|
||||
| `.showWelcome` | `ProPromptHost.swift:13-14` | `Day1WelcomeLetterWindow.show()` | [A] Day 1 |
|
||||
| `.showDay4Tour` | `:15-16` | `Day4TourPopover.show()` | [H] Day 4 |
|
||||
| `.showDay12HeadsUp` | `:17-19` | `Day12HeadsUpPopover.show()` **then** `Menubar.menubarIconCallback(nil)` | [B] Day 12 |
|
||||
| `.showDay15Proactive` | `:20-21` | `Day15ProactiveWindow.show()` | [D] Day 15 |
|
||||
| `.showDay15FullUpgrade(reason)` | `:22-23` | `Day15FullUpgradeWindow.show(for: reason)` | [C] Day 15 |
|
||||
| `.showDay15HardGatePopover(reason)` | `:24-25` | `Day15HardGatePopover.show(for: reason)` | [E] Day 15 hard gate |
|
||||
| `.showDay21Reminder` | `:26-27` | `Day21ReminderPopover.show()` | [F] Day 21 |
|
||||
| `.showDay35Final` | `:28-29` | `Day35FinalWindow.show()` | [G] Day 35 |
|
||||
| `.dismissAllProWindows` | `:30-34` | closes the four singleton windows (Day1/Day15Full/Day15Proactive/Day35) | on upgrade-to-pro |
|
||||
| `.refreshBadge` | `:35-36` | `Menubar.menubarIconCallback(nil)` | menu-bar badge dot refresh |
|
||||
|
||||
Two cases reach back into the menu bar: `.showDay12HeadsUp` (line 19) and `.refreshBadge`
|
||||
(line 36) both call `Menubar.menubarIconCallback(nil)` to repaint the orange badge dot
|
||||
(see §2.5). The window-class APIs the host calls were verified to exist:
|
||||
`show()` / `show(for:)` static methods and `shared?` optionals on the window subclasses.
|
||||
|
||||
### 1.2 `ProPromptPopover.swift` — popover plumbing
|
||||
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProPromptPopover.swift`
|
||||
|
||||
An `enum` namespace (no instances) with two static helpers used by the four menu-bar-anchored
|
||||
Day-X popovers ([B] Day 12, [E] Day 15 hard gate, [F] Day 21, [H] Day 4):
|
||||
- `make(contentSize:)` (lines 10–15) builds a `.transient` `NSPopover`.
|
||||
- `present(_:content:)` (lines 18–25) sets the content VC, activates the app, then anchors
|
||||
the popover under the menu-bar icon via `Menubar.showPopoverFromMenubar(popover)`
|
||||
(line 23) — its only coupling to `Menubar`.
|
||||
|
||||
### 1.3 `ProPromptWindow.swift` — base window chrome
|
||||
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProPromptWindow.swift`
|
||||
|
||||
`NSWindow` subclass (lines 7–26) providing the shared chrome for the four non-modal Day-X
|
||||
*windows* ([A] Welcome, [C] Full Upgrade, [D] Proactive, [G] Final): hidden titlebar,
|
||||
hidden traffic-light buttons, `hidesOnDeactivate = false`, `isReleasedWhenClosed = false`,
|
||||
and `Esc`→`close()` (line 23-25). Title is hard-coded `"AltTab Pro"` (line 12).
|
||||
|
||||
### 1.4 `ProPromptHeader.swift` — branded window header
|
||||
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProPromptHeader.swift`
|
||||
|
||||
`NSStackView` subclass (lines 10–64) drawing an app-icon + title row at the top of every
|
||||
Day-X window. Notable: it auto-replaces the last `"Pro"` substring in the title with a
|
||||
gradient text attachment from `ProGradient.makeProTextAttachment` (lines 55–63). Consumed by
|
||||
Day1/Day15Full/Day15Proactive/Day35 windows (see §1.8).
|
||||
|
||||
### 1.5 `ProGradientButton.swift` — the gradient CTA button
|
||||
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProGradientButton.swift`
|
||||
|
||||
`NSButton` subclass (lines 3–100) rendering the pink→blue gradient "buy" button used as the
|
||||
primary CTA across the upsell. Custom layer-based gradient fill (line 8, using
|
||||
`ProGradient.makeLayer`), drop shadow, and a hover "shine" sweep animation
|
||||
(`playShineAnimation`, lines 74–99). Subclassed by Settings: `UpgradeButton`
|
||||
(`SettingsWindow.swift:133`) and `ProHeroButton` (`UpgradeTab.swift:493`).
|
||||
|
||||
### 1.6 `UsageStatHeroView.swift` — "your usage so far" stat block
|
||||
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/UsageStatHeroView.swift`
|
||||
|
||||
`NSStackView` subclass (lines 22–140) rendering a guilt/value-framing hero block driven by
|
||||
`UsageStats.triggerCount` and `UsageStats.usedProFeaturesSessionCount` (read at init and on
|
||||
`refresh()`, lines 64–65). Three render modes (both counts >0 → two columns; only triggers
|
||||
→ one column; both zero → omitted). The "Pro feature uses" column number is drawn as a
|
||||
gradient image (lines 84-88, 112-117). Lead-in copy is `"YOUR USAGE SO FAR"` (line 69).
|
||||
Consumed by Day15Full, Day15Proactive, Day35 windows and the Upgrade tab (see §1.8).
|
||||
|
||||
### 1.7 `ProBadgeView.swift` — the "Pro" pill + gradient utilities (largest file)
|
||||
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProBadgeView.swift`
|
||||
|
||||
This 458-line file actually contains **five** distinct types, only the last of which is the
|
||||
badge view proper:
|
||||
|
||||
1. **`enum ProGradient`** (lines 3–119) — the gradient color/geometry definitions
|
||||
(lines 4–13) plus rendering helpers: `makeLayer`, `makeGradientTextImage`,
|
||||
`makeProTextAttachment`, `makeGradientTextAttachment` (used for menu rows showing
|
||||
"Get Pro" per the line 70 comment), `makeFullProBadgeImage` (renders the badge into an
|
||||
`NSImage` for `NSMenuItem.image` / closed `NSPopUpButton` face, lines 90–101), and
|
||||
`drawGradientFill`. This enum is the shared dependency of basically every other upsell
|
||||
surface (the gradient menu pill, the Pro segment overlays in Settings, etc.).
|
||||
2. **`class ProDropdownItemView`** (lines 124–187) — a custom `NSMenuItem.view` that draws a
|
||||
title + full Pro pill in a dropdown row, with its own highlight handling. (Used by
|
||||
Settings dropdowns, **not** the menu bar.)
|
||||
3. **`class NotAdvisedButton`** (lines 190–200) — the small gray "discouraged action" link
|
||||
button ("Not now", "Maybe later", "Continue with Free", "No thanks…"). Consumed by all
|
||||
Day-X dismiss links (see §1.8).
|
||||
4. **`class DynamicColorImageView`** (lines 205–213) — state-aware tinted image view used by
|
||||
the Pro-segment overlay in Settings.
|
||||
5. **`class ProBadgeView`** (lines 215–458) — the actual "Pro" pill. Includes static helpers
|
||||
`attach(to:segmentIndex:…)` (lines 257–318) and `refreshSelection(…)` (lines 323–327)
|
||||
that overlay a Pro badge on a `NSSegmentedControl` segment in Settings. Registers `"Pro"`
|
||||
with the Settings search index at init (line 344: `SettingsSearchIndex.registerString`).
|
||||
|
||||
> Note: despite the filename, the menu-bar code does **not** instantiate `ProBadgeView`
|
||||
> directly. Menubar uses `ProGradient.makeLayer` (indirectly, via `UpgradeMenuItemView`)
|
||||
> and the orange dot is a hand-rolled `CALayer`, not a `ProBadgeView`.
|
||||
|
||||
### 1.8 Consumers of the toolkit (untangle map for removal)
|
||||
|
||||
Confirmed via grep across `src/`. These are the call sites that will break if `src/pro/ui/`
|
||||
is deleted wholesale:
|
||||
|
||||
| Toolkit type | Consumed by | File:line |
|
||||
|---|---|---|
|
||||
| `ProGradientButton` | `UpgradeButton` (Settings) | `preferences/settings-window/SettingsWindow.swift:133` |
|
||||
| `ProGradientButton` | `ProHeroButton` (Upgrade tab) | `preferences/settings-window/tabs/UpgradeTab.swift:493` |
|
||||
| `UsageStatHeroView` | Upgrade tab hero | `preferences/settings-window/tabs/UpgradeTab.swift:5,37` |
|
||||
| `UsageStatHeroView` | Day15Full / Day15Proactive / Day35 | `pro/scheduling/Day15FullUpgradeWindow.swift:56`, `Day15ProactiveWindow.swift:38`, `Day35FinalWindow.swift:27` |
|
||||
| `ProPromptHeader` | Day1 / Day15Full / Day15Proactive / Day35 | `pro/scheduling/Day1WelcomeLetterWindow.swift:22`, `Day15FullUpgradeWindow.swift:53`, `Day15ProactiveWindow.swift:34`, `Day35FinalWindow.swift:23` |
|
||||
| `NotAdvisedButton` | Day12 / Day15Full / Day15HardGate / Day15Proactive / Day21 / Day35 | `pro/scheduling/Day12HeadsUpPopover.swift:28`, `Day15FullUpgradeWindow.swift:66`, `Day15HardGatePopover.swift:28`, `Day15ProactiveWindow.swift:48`, `Day21ReminderPopover.swift:34`, `Day35FinalWindow.swift:37` |
|
||||
| `ProGradient` / `ProBadgeView.attach` | Settings tabs (Appearance/Controls Pro segments, search index) | `SettingsSearchIndex.swift`, `AppearanceTab.swift`, `ControlsTab.swift`, `LabelAndControl.swift` (per earlier grep on `ProTransitionManager`/badge usage) |
|
||||
|
||||
Because every Day-X scheduling class is being removed in the same effort, the
|
||||
`src/pro/ui/` types whose *only* surviving consumers are the Settings Upgrade tab are
|
||||
`ProGradientButton`, `UsageStatHeroView`, and the `ProGradient`/`ProBadgeView` segment
|
||||
helpers. Those will be resolved when the Upgrade tab and Pro-segment overlays are removed.
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/Menubar.swift` — the `NSStatusItem` menu
|
||||
|
||||
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/Menubar.swift`
|
||||
|
||||
`Menubar` is the always-present `NSStatusItem`. Most of it (Show / Settings / Check for
|
||||
updates / Check permissions / About / Send feedback / Quit, the menu-bar icon preference,
|
||||
removal-from-menubar observation, the Screen-Recording `PermissionCallout`) is **not**
|
||||
paywall-related and must survive. The paywall touch-points are isolated to the three
|
||||
license-driven menu items, the `refreshLicenseMenuItems` state machine, the
|
||||
`UpgradeMenuItemView`, and the badge-dot overlay.
|
||||
|
||||
### 2.1 Stored upsell menu items
|
||||
Lines 8–10 declare three license-driven items as static vars:
|
||||
```swift
|
||||
private static var upgradeToProMenuItem: NSMenuItem! // line 8
|
||||
private static var supportProjectMenuItem: NSMenuItem! // line 9
|
||||
private static var myAccountMenuItem: NSMenuItem! // line 10
|
||||
```
|
||||
|
||||
### 2.2 Menu construction (`initialize()`, lines 27–66)
|
||||
The non-pro items are added at lines 37–45 (Show, Settings, Check for updates, Check
|
||||
permissions, About, **Debug tools**, Send feedback). The three pro items are added
|
||||
immediately after:
|
||||
|
||||
- **Line 46:** `upgradeToProMenuItem = addMenuItem("Get Pro", App.upgradeToProAction, "", "star.fill", …)`
|
||||
— the "Get Pro" upsell entry. Its action `App.upgradeToProAction`
|
||||
(`App.swift:26`) → `@objc App.upgradeToPro()` (`App.swift:115-117`) →
|
||||
`ProTransitionManager.openCheckout()` → opens `Endpoints.checkoutUrl`
|
||||
(`ProTransitionManager.swift:307-308`; URL `…/pricing`, `Endpoints.swift:9`).
|
||||
- **Line 47:** `upgradeToProMenuItem.view = UpgradeMenuItemView()` — replaces the row with the
|
||||
custom gradient view (§2.4).
|
||||
- **Line 48:** `myAccountMenuItem = addMenuItem("My Account", App.openAccountAction, …)` —
|
||||
action `App.openAccountAction` (`App.swift:27`) → `App.openAccount()`
|
||||
(`App.swift:119-121`) → `UpgradeTab.openAccountPage()` → opens `Endpoints.accountUrl`
|
||||
(`…/my-account`, `Endpoints.swift:10`).
|
||||
- **Line 49:** `supportProjectMenuItem = addMenuItem("Support this project", App.supportProjectAction, …, .red, …)`
|
||||
— heart icon, action `App.supportProjectAction` (`App.swift:25`) →
|
||||
`App.supportProject()` (`App.swift:111-113`) → opens `Endpoints.supportUrl`
|
||||
(`…/support`, `Endpoints.swift:8`).
|
||||
- **Line 50:** `refreshLicenseMenuItems()` — runs the state machine to show/hide these three.
|
||||
|
||||
> Upstream AltTab (the GPL parent) historically shipped a "Support this project" /
|
||||
> donate item; "Get Pro" and "My Account" are the fork's proprietary additions. Whether to
|
||||
> keep "Support this project" in the free build is a product decision (see §3), but it is
|
||||
> currently gated by the same `refreshLicenseMenuItems` state machine.
|
||||
|
||||
### 2.3 `refreshLicenseMenuItems()` state machine (lines 84–107)
|
||||
Reads `LicenseManager.shared.state` (`LicenseState`, defined
|
||||
`pro/license/LicenseState.swift:1-21`) and shows/hides the three items per state:
|
||||
|
||||
| `LicenseState` | "Get Pro" (upgrade) | "Support this project" | "My Account" |
|
||||
|---|---|---|---|
|
||||
| `.trial(daysRemaining)` | shown | hidden | hidden |
|
||||
| `.pro` | **removed** (`toggleUpgradeMenuItem(false)`) | hidden | shown |
|
||||
| `.proExpired` | shown | shown | shown |
|
||||
| `.trialExpired` | shown | shown | hidden |
|
||||
|
||||
- `.pro` early-returns at line 105 *before* calling `updateContent`, so the gradient view's
|
||||
text is only refreshed for non-pro states (line 106).
|
||||
- `toggleUpgradeMenuItem(_:)` (lines 109–118) inserts/removes `upgradeToProMenuItem`
|
||||
relative to `supportProjectMenuItem`'s index — the "Get Pro" row is fully removed from the
|
||||
menu (not just `isHidden`) for Pro users, because `NSMenuItem.isHidden` is unreliable with
|
||||
custom views (same pattern as the permission callout, comment at line 135).
|
||||
|
||||
`refreshLicenseMenuItems()` is invoked from:
|
||||
- `Menubar.initialize()` line 50 (initial build).
|
||||
- `App.swift:450` inside `LicenseManager.shared.onStateChanged` (license state transitions).
|
||||
|
||||
### 2.4 `UpgradeMenuItemView` (lines 235–390)
|
||||
Custom `NSView` set as `upgradeToProMenuItem.view`. Renders the gradient "Get Pro" pill in
|
||||
the menu with a two-line label and a `star.fill` icon (lines 269–275, tinted white). Uses
|
||||
`ProGradient.makeLayer()` (line 247) for the backdrop and has its own hover "shine"
|
||||
animation (`playShineAnimation`, lines 328–355) mirroring `ProGradientButton`.
|
||||
|
||||
- `updateContent(_ state:)` (lines 357–382) builds the two-line attributed string. The
|
||||
**trial day count** appears here: line 370-371,
|
||||
`String(format: "Trial: %d days remaining", daysRemaining)` for `.trial`; line 372-373
|
||||
`"License doesn't cover this version"` for `.proExpired`; else `"Trial expired"`
|
||||
(line 375). Second line is always `"Get Pro"` (line 380).
|
||||
- `mouseUp` (lines 384–389) cancels menu tracking and calls `App.upgradeToPro()`
|
||||
(opens checkout).
|
||||
|
||||
### 2.5 The orange badge dot overlay (lines 187–227)
|
||||
A hand-rolled 7pt orange `CALayer` (`systemOrange`, line 221) anchored to the bottom-right
|
||||
of the menu-bar icon, signalling "the trial is ending / there's an upsell to see."
|
||||
- `badgeDotLayer` static var (line 187).
|
||||
- `updateBadgeDotOverlay()` (lines 202–227): removes any existing dot, then
|
||||
**guards on `ProTransitionManager.shared.shouldShowBadgeDot`** (line 205). If false (or
|
||||
no button), it stops — so the dot only appears when the transition manager says so
|
||||
(`shouldShowBadgeDot` defined `ProTransitionManager.swift:161-162`, delegating to
|
||||
`ProTransitionManagerTestable.shouldShowBadgeDot`). Otherwise it builds the orange dot
|
||||
layer.
|
||||
- Invoked only from `loadPreferredIcon()` (line 196), which runs inside
|
||||
`applyMenubarIconPreferences()` → `menubarIconCallback(_:)` (lines 155–164). That callback
|
||||
is the entry point `ProPromptHost` calls (lines 19, 36) to repaint the dot, and is also
|
||||
called from `PreferencesEvents.swift:79` and `QAMenu.swift:240` (non-pro icon-pref
|
||||
changes also repaint, harmlessly).
|
||||
|
||||
### 2.6 `MenubarMenuDelegate` (lines 392–399)
|
||||
`menuWillOpen` calls `LicenseManager.shared.refreshState()` (line 396) so the trial-day
|
||||
subtitle in `UpgradeMenuItemView` reflects the current clock at open time, then
|
||||
`Menubar.refreshPermissionCallout()` (line 397, non-pro). The `refreshState()` call is the
|
||||
license/trial coupling here; the permission-callout refresh is independent and must stay.
|
||||
|
||||
### 2.7 Non-pro menu-bar code (must survive untouched)
|
||||
For clarity on what is *not* in scope for removal: `addMenuItem` helper (14–25), the
|
||||
QA-menu middle-click monitor (DEBUG only, 68–82), `refreshPermissionCallout` /
|
||||
`togglePermissionCallout` / `PermissionCallout` (120–144, 401–445), `statusItemOnClick`
|
||||
(146–153), the icon-preference + removal-observer machinery (166–197 minus the badge-dot
|
||||
call at 196), and `showPopoverFromMenubar` (229–232). Note `showPopoverFromMenubar` is used
|
||||
*only* by the Day-X popovers via `ProPromptPopover.present`, so after the paywall is removed
|
||||
it becomes dead code and can be deleted too.
|
||||
|
||||
---
|
||||
|
||||
## 3. What the menu bar should look like with the paywall gone
|
||||
|
||||
After removing the trial/license/upsell system, the `NSStatusItem` menu should reduce to
|
||||
the upstream-style set of items:
|
||||
|
||||
```
|
||||
Show
|
||||
─────────
|
||||
Settings…
|
||||
Check for updates…
|
||||
Check permissions…
|
||||
─────────
|
||||
About AltTab
|
||||
Debug tools (DEBUG builds only — see note)
|
||||
Send feedback…
|
||||
Support this project (product decision — see below)
|
||||
─────────
|
||||
Quit AltTab
|
||||
```
|
||||
|
||||
Concrete `Menubar.swift` changes required for a clean free build:
|
||||
|
||||
- **Remove the three license-driven items and their wiring:**
|
||||
- Delete the three static vars at lines 8–10.
|
||||
- Delete line 46 (`Get Pro`), line 47 (`.view = UpgradeMenuItemView()`), line 48
|
||||
(`My Account`), and the `refreshLicenseMenuItems()` call at line 50. ("Support this
|
||||
project" at line 49 is the only one of the four that has upstream precedent — keep or
|
||||
drop per product decision; if kept, add it unconditionally and stop hiding it.)
|
||||
- **Delete `refreshLicenseMenuItems()` (84–107) and `toggleUpgradeMenuItem(_:)` (109–118)**
|
||||
entirely — both reference `LicenseManager`/`LicenseState`.
|
||||
- **Delete the `UpgradeMenuItemView` class (235–390)** — it is the gradient pill, the trial
|
||||
day-count renderer, and the checkout `mouseUp`. Its only consumer is line 47.
|
||||
- **Delete the badge-dot overlay** (`badgeDotLayer` line 187; `updateBadgeDotOverlay()`
|
||||
202–227) and remove its call at line 196 inside `loadPreferredIcon()`. With it gone,
|
||||
`loadPreferredIcon` no longer depends on `ProTransitionManager`.
|
||||
- **Simplify `MenubarMenuDelegate.menuWillOpen` (392–399)** — drop the
|
||||
`LicenseManager.shared.refreshState()` call at line 396; keep
|
||||
`refreshPermissionCallout()` (line 397).
|
||||
- **`menubarIconCallback(_:)` (155–164)** can stay (it is also the icon-preference entry
|
||||
point), but its callers in `ProPromptHost.swift:19,36` disappear with the host. The
|
||||
`applyMenubarIconPreferences`/`loadPreferredIcon` path remains valid for the
|
||||
PreferencesEvents/QAMenu callers.
|
||||
- **`showPopoverFromMenubar` (229–232)** becomes dead code (only the Day-X popovers used it)
|
||||
and can be removed.
|
||||
|
||||
In `src/App.swift`: remove `supportProjectAction`/`upgradeToProAction`/`openAccountAction`
|
||||
(25–27) and the `@objc supportProject/upgradeToPro/openAccount` methods (111–121) if their
|
||||
endpoints (`pricing`, `my-account`, `support`) are also dropped; remove the
|
||||
`ProTransitionManager.shared.onAction = …` wiring (line 428) and the
|
||||
`Menubar.refreshLicenseMenuItems()` call inside `onStateChanged` (line 450).
|
||||
|
||||
The entire `src/pro/ui/` directory can be deleted **once** its non-pro consumers in
|
||||
`src/preferences/settings-window/` (the Upgrade tab, the Pro-segment overlays via
|
||||
`ProBadgeView.attach` / `ProGradient`, and the search-index `"Pro"` registration at
|
||||
`ProBadgeView.swift:344`) are removed in the Settings audit. Until then,
|
||||
`ProGradient`, `ProGradientButton`, `UsageStatHeroView`, and the `ProBadgeView` segment
|
||||
helpers are still referenced and would break the build if deleted prematurely.
|
||||
|
||||
> **Note on "Debug tools":** it is added unconditionally at `Menubar.swift:44` in this fork
|
||||
> (not behind `#if DEBUG`), unlike the QA-menu monitor. Leaving it is orthogonal to the
|
||||
> paywall; flag it for the product owner but it is not a removal blocker.
|
||||
@@ -0,0 +1,259 @@
|
||||
# 08 — Build, Signing, Auto-Update & Publishing Pipeline
|
||||
|
||||
How `alt-tab-free` (a fork of `lwouis/alt-tab-macos`) is actually built, versioned, signed,
|
||||
notarized, shipped, and auto-updated. This section is written for the republish-as-free goal:
|
||||
every place the pipeline still points at the **original upstream / paywall vendor** (`lwouis`,
|
||||
`alt-tab.app`, AppCenter, the upstream Developer ID and Sparkle key) is enumerated at the end.
|
||||
|
||||
---
|
||||
|
||||
## 1. How to build locally
|
||||
|
||||
There is no Makefile / wrapper — builds are raw `xcodebuild` invocations over
|
||||
`alt-tab-macos.xcodeproj`. There are exactly **three schemes** (`alt-tab-macos.xcodeproj/xcshareddata/xcschemes/`):
|
||||
`Debug.xcscheme`, `Release.xcscheme`, `Test.xcscheme`.
|
||||
|
||||
### Debug build (everyday dev loop)
|
||||
`ai/build.sh:3-7`:
|
||||
```
|
||||
xcodebuild -project alt-tab-macos.xcodeproj -scheme Debug -configuration Debug -derivedDataPath DerivedData
|
||||
```
|
||||
- Per `AGENTS.md` the sanctioned workflow is "copy commands from `ai/build.sh` and run them" — never drive Xcode interactively.
|
||||
- Run the built app: `ai/run.sh:3` runs `DerivedData/Build/Products/Debug/AltTab.app/Contents/MacOS/AltTab --logs=debug --benchmark showUi 3`.
|
||||
- Profile: `ai/profile.sh` records a 20s Time Profiler trace via `xcrun xctrace` and exports it to XML.
|
||||
|
||||
### Release build (what CI ships)
|
||||
`scripts/build_app.sh:5`:
|
||||
```
|
||||
xcodebuild -project alt-tab-macos.xcodeproj -scheme Release -derivedDataPath DerivedData | scripts/xcbeautify
|
||||
```
|
||||
Release products land in `DerivedData/Build/Products/Release` (`XCODE_BUILD_PATH` env, `ci_cd.yml:17`).
|
||||
|
||||
### Build configuration (xcconfig layering)
|
||||
Build settings live in `config/*.xcconfig`, attached to the project via `baseConfigurationReference`
|
||||
entries in `alt-tab-macos.xcodeproj/project.pbxproj` (lines ~2429–2562). The layering:
|
||||
|
||||
- `config/base.xcconfig` — shared identity & build settings. Key values:
|
||||
- `PRODUCT_NAME = AltTab` (`base.xcconfig:3`)
|
||||
- `PRODUCT_BUNDLE_IDENTIFIER = com.lwouis.alt-tab-macos` (`base.xcconfig:4`) **[upstream identity — see §7]**
|
||||
- `MACOSX_DEPLOYMENT_TARGET = 10.13` (`base.xcconfig:5`); `SWIFT_VERSION = 5.8` (`:6`)
|
||||
- `INFOPLIST_FILE = Info.plist`, `CODE_SIGN_ENTITLEMENTS = alt_tab_macos.entitlements` (`:8-9`)
|
||||
- `ENABLE_HARDENED_RUNTIME = YES` (`:12`, required for notarization)
|
||||
- `DOMAIN = alt-tab.app` and `API_DOMAIN = alt-tab.app/api` (`:20-21`) **[upstream domains — see §7]**
|
||||
- `config/debug.xcconfig` — `#include "base.xcconfig"`, then `CODE_SIGN_IDENTITY = Local Self-Signed` (`:5`), `SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG` (`:6`), `OTHER_CODE_SIGN_FLAGS = --timestamp=none` (`:8`). Ends with `#include? "local.xcconfig"` (`:13`).
|
||||
- `config/release.xcconfig` — `#include "base.xcconfig"`, then `CODE_SIGN_IDENTITY = Developer ID Application: Louis Pontoise (QXD7GW8FHY)` (`:5`) **[upstream Developer ID — see §7]**, `OTHER_CODE_SIGN_FLAGS = --timestamp --deep --options runtime` (`:7`, for notarization), wholemodule + `-O` + ThinLTO + full stripping (`:8-18`). Ends with `#include? "local.xcconfig"` (`:21`).
|
||||
- `config/test-base.xcconfig` / `test-debug.xcconfig` / `test-release.xcconfig` — for the Test scheme; signing disabled (`CODE_SIGNING_REQUIRED = NO`, `CODE_SIGNING_ALLOWED = NO`, `test-base.xcconfig:6-7`), `ONLY_ACTIVE_ARCH = YES`.
|
||||
|
||||
`config/local.xcconfig` is **not committed** (referenced via `#include?`, so optional). It is the
|
||||
per-developer / CI override channel for `CODE_SIGN_IDENTITY`, `APPCENTER_SECRET`, `CURRENT_PROJECT_VERSION`,
|
||||
`DOMAIN`, `API_DOMAIN`. The pbxproj has a stale `local.xcconfig` file reference (`project.pbxproj:458`)
|
||||
but it is *not* set as a `baseConfigurationReference` — the `#include?` in debug/release is what loads it.
|
||||
|
||||
### `DEVELOPMENT_TEAM` is empty at build time
|
||||
In `project.pbxproj` the app target sets `DEVELOPMENT_TEAM = "${TEAM_ID}"` (lines 2462, 2520, 2554),
|
||||
but **`TEAM_ID` is never defined** anywhere (not in any xcconfig, not in CI env). It therefore
|
||||
expands to empty, so signing is driven purely by `CODE_SIGN_IDENTITY`. The CI's `APPLE_TEAM_ID`
|
||||
secret is used *only* by `notarytool` (`package_and_notarize_release.sh:17`), not by `xcodebuild`.
|
||||
The Debug target explicitly sets `DEVELOPMENT_TEAM = ""` (`project.pbxproj:2562`). Net effect:
|
||||
Release is signed with whatever identity `release.xcconfig` (or a `local.xcconfig` override) names.
|
||||
|
||||
### Local Release signing without the upstream cert
|
||||
`scripts/codesign/setup_local.sh` generates a throwaway self-signed cert and imports it into the
|
||||
main keychain (calls `generate_selfsigned_certificate.sh` → `import_certificate_into_main_keychain.sh`).
|
||||
This lets a developer who lacks the `Developer ID Application: Louis Pontoise` cert still produce a
|
||||
signed-but-not-notarizable Release (typically combined with a `local.xcconfig` overriding
|
||||
`CODE_SIGN_IDENTITY`).
|
||||
|
||||
### Tooling prerequisites
|
||||
- **Xcode 26.0.1** pinned in CI (`ci_cd.yml:45`: `sudo xcode-select -s /Applications/Xcode_26.0.1.app/...`).
|
||||
- **Node** (16 in CI, `package.json` `engines` says `>=18`) for `commitlint`, `semantic-release`, `swiftformat.js`, `ts-node`.
|
||||
- **Python 3.6** (`Pipfile`) only for asset/l10n tooling (`fonttools`, `googletrans`), not the app build.
|
||||
- Bundled binaries in `scripts/`: `xcbeautify` (6.5 MB), `notarytool` (17 MB), `assets/createicns`; and `vendor/Sparkle/bin/sign_update`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Versioning & release cutting (semantic-release)
|
||||
|
||||
Releases are fully automated from Conventional Commits on `master`; there is **no manual version
|
||||
bump**. `package.json` `version` (`1.0.0`) is irrelevant — the app version is computed by semantic-release.
|
||||
|
||||
### Commit linting → version derivation
|
||||
- `commitlint.config.js` extends `@commitlint/config-conventional`. CI enforces it across the pushed range: `npx commitlint --from "$GITHUB_EVENT_BEFORE" --to "$GITHUB_EVENT_AFTER"` (`ci_cd.yml:48`).
|
||||
- `release.config.js` configures `@semantic-release/commit-analyzer` (angular preset) with extra `releaseRules` so `perf/docs/style/refactor/test/chore/ci` all force at least a **patch** bump (`release.config.js:6-14`). `feat` → minor, breaking → major (defaults).
|
||||
- `scripts/determine_next_version.sh:5-8` runs `npx semantic-release --dry-run`, greps "The next release version is …", and writes the bare version (e.g. `11.3.0`) to `VERSION.txt` (the `$VERSION_FILE`, `ci_cd.yml:18`). `VERSION.txt` is **not committed** — it is a CI-only scratch file consumed by later steps.
|
||||
|
||||
### Version injection into the app
|
||||
`scripts/replace_environment_variables_in_app.sh:7-11` writes a fresh `config/local.xcconfig`:
|
||||
```
|
||||
CURRENT_PROJECT_VERSION = <contents of VERSION.txt>
|
||||
APPCENTER_SECRET = $APPCENTER_SECRET
|
||||
```
|
||||
`Info.plist` consumes `$(CURRENT_PROJECT_VERSION)` for both `CFBundleShortVersionString` and
|
||||
`CFBundleVersion` (`Info.plist:26-29`), and `$(APPCENTER_SECRET)` for `AppCenterSecret` (`Info.plist:73-74`).
|
||||
|
||||
### Changelog, tag & GitHub Release
|
||||
- `npx semantic-release` (`ci_cd.yml:59`) runs the full plugin chain in `release.config.js`: generates release notes → updates `changelog.md` (`@semantic-release/changelog`) → commits `changelog.md`, `appcast.xml`, `README.md` back to the repo (`@semantic-release/git`), and creates the git tag.
|
||||
- `scripts/extract_latest_changelog.sh` writes `tag_name=v<version>` and the top changelog block to `$GITHUB_OUTPUT` (`:6-9`).
|
||||
- `softprops/action-gh-release@v3` (`ci_cd.yml:62-66`) publishes the GitHub Release with that tag/body and attaches `DerivedData/Build/Products/Release/*.zip`.
|
||||
- `changelog.md` is already populated with upstream history; entries link to `github.com/lwouis/alt-tab-macos/...` (e.g. `changelog.md:1`). The most recent committed release is **11.3.0** (matches `git log`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Code signing & notarization
|
||||
|
||||
### Signing
|
||||
- Release is signed by Xcode using `CODE_SIGN_IDENTITY = Developer ID Application: Louis Pontoise (QXD7GW8FHY)` (`release.xcconfig:5`) with hardened runtime + secure timestamp (`base.xcconfig:12`, `release.xcconfig:7`).
|
||||
- The signing cert reaches CI via a base64 P12 secret: `scripts/codesign/setup_ci_master.sh:8` decodes `$APPLE_P12_CERTIFICATE` to `codesign.p12`, then `import_certificate_into_new_keychain.sh` creates/unlocks a dedicated `alt-tab-macos.keychain`, imports the P12 for `/usr/bin/codesign`, and runs `security set-key-partition-list` (`import_certificate_into_new_keychain.sh:12-19`).
|
||||
- `setup_ci_pr.sh` (used on PRs, not wired into the current `master`-only workflow) generates a self-signed cert instead — PRs build but cannot notarize.
|
||||
|
||||
### Entitlements
|
||||
`alt_tab_macos.entitlements`: `app-sandbox = false` (it needs Accessibility/Screen Recording) and
|
||||
`com.apple.security.cs.disable-library-validation = true` (to load the embedded Sparkle/AppCenter
|
||||
frameworks under hardened runtime).
|
||||
|
||||
### Sparkle framework re-sealing (build phase)
|
||||
`scripts/copy_sparkle_helpers.sh` is a "Copy Sparkle Helpers" build phase that runs after Embed
|
||||
Frameworks and before Xcode's final code sign. It copies the prebuilt `Updater.app` + `Autoupdate`
|
||||
into `Sparkle.framework/Versions/A/`, mirrors SPM-generated resources, rewrites the framework
|
||||
`CFBundleIdentifier` to `org.sparkle-project.Sparkle`, then **re-signs** the framework with
|
||||
`EXPANDED_CODE_SIGN_IDENTITY`/`CODE_SIGN_IDENTITY` (final lines of the script). The `Updater.app`/
|
||||
`Autoupdate` helpers are pre-signed with the maintainer's Developer ID at vendor time
|
||||
(`vendor/scripts/update_sparkle.sh`).
|
||||
|
||||
### Notarization (`scripts/package_and_notarize_release.sh`)
|
||||
1. `ditto -c -k --keepParent AltTab.app AltTab-<version>.zip` (`:11`).
|
||||
2. Submit to Apple: `scripts/notarytool submit --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" <zip> --wait --timeout 15m` (`:14-19`). Exits non-zero unless status is `Accepted` (`:22`).
|
||||
3. `xcrun stapler staple AltTab.app` (`:25`) then re-zips the stapled app over the same name (`:26`). That stapled zip is the GitHub Release asset and the Sparkle download.
|
||||
|
||||
### Keychain / identity invariant (from `AGENTS.md`)
|
||||
The app's Developer ID, TeamID, and bundle ID must stay stable across builds: Keychain items
|
||||
(license keys) are tied to the code signature. Changing any one orphans every user's stored
|
||||
license. **For the free fork this is the reason §7 matters** — but since the fork is *removing*
|
||||
the license/Keychain layer entirely, the invariant's coupling concern goes away once the `src/pro`
|
||||
license code is gone (no Keychain license to orphan). The identity still must change because the
|
||||
upstream Developer ID cannot be used.
|
||||
|
||||
---
|
||||
|
||||
## 4. Auto-update (Sparkle)
|
||||
|
||||
The app embeds **Sparkle 2.9.1** (`vendor/Sparkle/UPSTREAM`), vendored as source via SPM plus
|
||||
prebuilt helper apps.
|
||||
|
||||
### Wiring in code
|
||||
- `src/App.swift:407-413` builds an `SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: App.sparkleDelegate!, ...)` and calls `startUpdater()` 30s after launch.
|
||||
- `src/vendors/SparkleDelegate.swift:26-28` supplies the feed URL: `feedURLString → Endpoints.appcastUrl`.
|
||||
- `src/api/Endpoints.swift`: `website = "https://\(domain)"` where `domain` is read from the `Domain` Info.plist key (`Endpoints.swift:4,6`); `appcastUrl = "\(website)/appcast.xml"` (`:7`). With `DOMAIN = alt-tab.app` that resolves to **`https://alt-tab.app/appcast.xml`** **[upstream feed — see §7]**.
|
||||
- `feedParameters` (`SparkleDelegate.swift:30-38`) appends `version`, `macos`, `arch`, `lang` query params to the feed request.
|
||||
- **Tier-aware appcast:** `src/pro/license/LicenseCookie.swift` writes a `license` cookie (`pro`/`proExpired`/empty) on the `alt-tab.app` domain so the appcast server can tailor responses per license tier (`LicenseCookie.swift:3,9-21`). This is paywall-coupled and goes away with `src/pro` removal.
|
||||
- Update preferences are toggled in `src/events/UserDefaultsEvents.swift`, `src/events/PreferencesEvents.swift:85-86`, and `src/preferences/settings-window/tabs/GeneralTab.swift:100-101`. `Info.plist:63-66` sets `SUEnableAutomaticChecks = true`, `SUScheduledCheckInterval = 604800` (weekly).
|
||||
|
||||
### EdDSA signing
|
||||
- The **public** EdDSA key is baked into `Info.plist:61-62`: `SUPublicEDKey = 2e9SQOBoaKElchSa/4QDli/nvYkyuDNfynfzBF6vJK4=` **[upstream key — see §7]**.
|
||||
- The **private** key lives only in CI as `$SPARKLE_ED_PRIVATE_KEY`. `scripts/update_appcast.sh:9` signs each zip with `vendor/Sparkle/bin/sign_update -s $SPARKLE_ED_PRIVATE_KEY <zip>` and embeds the `sparkle:edSignature` + `length` into a new `<item>`.
|
||||
|
||||
### Appcast file (`appcast.xml`)
|
||||
- RSS 2.0 with Sparkle namespace; `<title>alt-tab-macos</title>`. 3876 lines, newest item first (currently 11.3.0).
|
||||
- `scripts/update_appcast.sh:11-26` builds an `ITEM.txt` block and `sed`-inserts it right after the `</language>` tag. Each `<enclosure url=...>` points at **`https://github.com/lwouis/alt-tab-macos/releases/download/v<version>/AltTab-<version>.zip`** (`update_appcast.sh:18`) **[upstream download host — see §7]**, and the release-notes link is **`https://alt-tab.app/changelog-bare`** (`:16`) **[upstream — see §7]**.
|
||||
- `appcast.xml` is committed back to the repo by semantic-release (`release.config.js:21-26`). The *file* is what Sparkle fetches from `alt-tab.app/appcast.xml`, so the hosting website serves this committed file.
|
||||
|
||||
### Distribution surface summary
|
||||
The download artifacts live on **GitHub Releases of `lwouis/alt-tab-macos`** (per the appcast
|
||||
enclosure URLs and the `update_readme_and_website.sh` API calls), and the appcast/release-notes are
|
||||
served from the **`alt-tab.app`** website. The website itself is a *separate* repo
|
||||
(`lwouis/alt-tab-website`, see §6).
|
||||
|
||||
---
|
||||
|
||||
## 5. Crash reporting & symbols (Microsoft AppCenter)
|
||||
|
||||
- The app subclasses `AppCenterApplication` (`Info.plist:36-37` `NSPrincipalClass`; `src/App.swift:7`). `src/vendors/AppCenterCrashes.swift:20` calls `AppCenter.start(withAppSecret: Secrets.appCenterSecret, services: [Crashes.self])`. The secret is read from the `AppCenterSecret` Info.plist key (`src/api/Secrets.swift:6`), injected from `$APPCENTER_SECRET` (`replace_environment_variables_in_app.sh:9`).
|
||||
- Network is gated off unless a crash is actually sent (`AppCenterCrashes.swift:13-14, 49`).
|
||||
- AppCenter is vendored (4.3.0 + PLCrashReporter 1.11.1, `vendor/scripts/update_appcenter.sh`).
|
||||
- **dSYM upload:** `scripts/upload_symbols_to_appcenter.sh` zips and uploads `AltTab.app.dSYM` and `Sparkle.framework.dSYM` to `https://api.appcenter.ms/v0.1/apps/alt-tab-macos/alt-tab-macos` (`:4-7`) using `$APPCENTER_TOKEN`. **[upstream AppCenter account — see §7]**
|
||||
|
||||
> NOTE: Microsoft App Center is being retired (Microsoft announced March 31 2025 EoL). Even
|
||||
> independent of the fork, this crash-reporting path is dead-ending and is a candidate for removal,
|
||||
> not just re-pointing.
|
||||
|
||||
---
|
||||
|
||||
## 6. CI/CD workflow (`.github/workflows/ci_cd.yml`)
|
||||
|
||||
Single workflow, single `build` job, triggered only on **push to `master`** (`ci_cd.yml:1-4`),
|
||||
runs on `macos-15`, environment `production` (`:22-23`). Step order (`:38-69`):
|
||||
|
||||
1. `actions/checkout@v6` with computed `fetch-depth` (commits + 30, `:28-40`) — needs history for commitlint/semantic-release.
|
||||
2. `actions/setup-node@v6` (node 16) + `npm ci` (`:41-47`).
|
||||
3. `npx commitlint --from <before> --to <after>` (`:48`).
|
||||
4. `scripts/ensure_generated_files_are_up_to_date.sh` (`:49`) — runs l10n extraction and fails if it produced a diff (`git diff-files --exit-code`).
|
||||
5. `scripts/determine_next_version.sh` (`:50`) → `VERSION.txt`.
|
||||
6. `scripts/replace_environment_variables_in_app.sh` (`:51`) → `config/local.xcconfig`.
|
||||
7. `scripts/codesign/setup_ci_master.sh` (`:52`) — import Developer ID P12.
|
||||
8. `scripts/run_tests.sh` (`:53`) — `xcodebuild test -scheme Test -configuration Release`.
|
||||
9. `scripts/build_app.sh` (`:54`) — Release build.
|
||||
10. `scripts/package_and_notarize_release.sh` (`:55`) — zip + notarize + staple.
|
||||
11. `scripts/upload_symbols_to_appcenter.sh` (`:56`) — dSYMs → AppCenter.
|
||||
12. `scripts/update_appcast.sh` (`:57`) — sign zip + prepend appcast item.
|
||||
13. `scripts/update_readme_and_website.sh` (`:58`) — refresh README stats SVG + contributors.
|
||||
14. `npx semantic-release` (`:59`) — changelog/tag/commit-back.
|
||||
15. `extract_latest_changelog.sh` (`:60-61`) → release notes.
|
||||
16. `softprops/action-gh-release@v3` (`:62-66`) — publish GitHub Release + attach `*.zip`.
|
||||
17. `scripts/update_website.sh` (`:67-69`) — `gh api repos/lwouis/alt-tab-website/dispatches -f event_type=update-website`, authed with `$WEBSITE_DISPATCH_TOKEN` (`update_website.sh:5`). **[upstream website repo — see §7]**
|
||||
|
||||
### CI secrets / env (`ci_cd.yml:5-19`)
|
||||
`GITHUB_TOKEN`, `APPCENTER_SECRET`, `APPCENTER_TOKEN`, `APPLE_ID`, `APPLE_PASSWORD`,
|
||||
`APPLE_TEAM_ID`, `APPLE_P12_CERTIFICATE` (base64 Developer ID P12), `SPARKLE_ED_PRIVATE_KEY`,
|
||||
`WEBSITE_DISPATCH_TOKEN`. Plus computed `BUILD_DIR`, `XCODE_BUILD_PATH=DerivedData/Build/Products/Release`,
|
||||
`VERSION_FILE=VERSION.txt`, `APP_NAME=AltTab`.
|
||||
|
||||
`scripts/update_readme_and_website.sh` hits `https://api.github.com/repos/lwouis/alt-tab-macos/...`
|
||||
for contributors / star / download counts (`:10`) — another hardcoded upstream slug.
|
||||
|
||||
`.github/FUNDING.yml` points at upstream donation channels (`github: lwouis`, `patreon: alt_tab_macos`,
|
||||
`ko_fi: alt_tab`, a PayPal button id). Cosmetic, but fork-specific.
|
||||
|
||||
---
|
||||
|
||||
## 7. CRITICAL — identities / URLs / secrets a fork MUST change or remove
|
||||
|
||||
Everything below still targets the **original upstream / paywall vendor**. A republished free fork
|
||||
must change (C) or remove (R) each item. Build will not *break* if these are left as-is (it will
|
||||
build and sign with whatever cert is present), but the app would phone home to upstream, fail to
|
||||
update from the fork's own releases, and attempt to notarize/sign under the upstream account.
|
||||
|
||||
| # | What | Where (file:line) | Current value | Action |
|
||||
|---|------|-------------------|---------------|--------|
|
||||
| 1 | **Bundle ID** | `config/base.xcconfig:4` (+ unit-tests id `project.pbxproj:2477,2542`; mocks `src/_test-support/Mocks.swift:133`) | `com.lwouis.alt-tab-macos` | **C** — set fork's reverse-DNS id (e.g. `com.koftwentytwo.alt-tab-free`). |
|
||||
| 2 | **Developer ID signing cert** | `config/release.xcconfig:5` | `Developer ID Application: Louis Pontoise (QXD7GW8FHY)` | **C** — fork's own Developer ID Application cert + TeamID. |
|
||||
| 3 | **Signing P12 secret** | `ci_cd.yml:12` / `setup_ci_master.sh` | `$APPLE_P12_CERTIFICATE` | **C** — replace with fork's exported Developer ID P12. |
|
||||
| 4 | **Notarization Apple account** | `ci_cd.yml:9-11`; `package_and_notarize_release.sh:15-18` | `$APPLE_ID` / `$APPLE_PASSWORD` / `$APPLE_TEAM_ID` | **C** — fork's Apple ID + app-specific password + TeamID. |
|
||||
| 5 | **Sparkle public EdDSA key** | `Info.plist:62` | `2e9SQOBoaKElchSa/4QDli/nvYkyuDNfynfzBF6vJK4=` | **C** — generate a NEW EdDSA keypair (`generate_keys`); put public half here. |
|
||||
| 6 | **Sparkle private EdDSA key** | `ci_cd.yml:13`; `update_appcast.sh:9` | `$SPARKLE_ED_PRIVATE_KEY` | **C** — fork's new private key. Must match #5 or updates won't verify. |
|
||||
| 7 | **Appcast feed domain** | `config/base.xcconfig:20` (`DOMAIN`) → `Endpoints.swift:6-7` | `alt-tab.app` → `https://alt-tab.app/appcast.xml` | **C** — fork's domain, OR repoint feed at fork's GitHub-hosted appcast. |
|
||||
| 8 | **API domain (license + feedback)** | `config/base.xcconfig:21` (`API_DOMAIN`) → `Endpoints.swift:5,11-12` | `alt-tab.app/api` → `https://alt-tab.app/api/v1/license`, `.../v1/feedback` | **R** — license API is paywall; remove with `src/pro`. Feedback endpoint must be repointed or removed. |
|
||||
| 9 | **Appcast download URL** | `update_appcast.sh:18` | `https://github.com/lwouis/alt-tab-macos/releases/download/v<v>/...` | **C** — fork's GitHub releases URL (`KofTwentyTwo/CommandTabFree`). |
|
||||
| 10 | **Appcast release-notes link** | `update_appcast.sh:16`; existing items in `appcast.xml` | `https://alt-tab.app/changelog-bare` | **C/R** — fork URL or drop the link. |
|
||||
| 11 | **License tier cookie** | `src/pro/license/LicenseCookie.swift` | cookie on `alt-tab.app` | **R** — remove with paywall. |
|
||||
| 12 | **Website dispatch repo + token** | `update_website.sh:5`; `ci_cd.yml:69` | `lwouis/alt-tab-website` + `$WEBSITE_DISPATCH_TOKEN` | **R/C** — fork has no upstream website; remove this CI step or repoint. |
|
||||
| 13 | **README stats / contributors API** | `update_readme_and_website.sh:10` | `api.github.com/repos/lwouis/alt-tab-macos` | **C/R** — fork slug, or drop the step. |
|
||||
| 14 | **AppCenter app + secret** | `update_symbols`→`upload_symbols_to_appcenter.sh:4-7`; `ci_cd.yml:7-8`; `Info.plist:73`; `Secrets.swift:6` | `apps/alt-tab-macos/alt-tab-macos`, `$APPCENTER_SECRET`, `$APPCENTER_TOKEN` | **R** — AppCenter is EoL; remove crash-reporting + the upload step (or repoint to a fork-owned analytics-free build). |
|
||||
| 15 | **`App.repository`** | `src/App.swift:16` | `https://github.com/lwouis/alt-tab-macos` | **C** — fork repo URL (used in About/links). |
|
||||
| 16 | **GitHub Release target** | implicit via `GITHUB_TOKEN` repo + appcast URLs | upstream releases | **C** — fork publishes to its own repo; ensure appcast URLs (#9) match. |
|
||||
| 17 | **FUNDING.yml** | `.github/FUNDING.yml` | upstream Patreon/Ko-fi/PayPal/github sponsor | **C/R** — fork's funding or remove. |
|
||||
| 18 | **Vendored Developer-ID-signed Sparkle helpers** | `vendor/Sparkle/Helpers/Updater.app`, `Autoupdate` (re-signed by `copy_sparkle_helpers.sh`) | signed with maintainer Developer ID at vendor time | **C** — re-vendor/re-sign with fork's Developer ID, else `--deep --strict` verify may flag mismatched seals once the app id changes. |
|
||||
|
||||
### Notes for the removal plan
|
||||
- Items **8, 11, 14** (license API, license cookie, AppCenter) are *removals* that should fall out
|
||||
naturally when `src/pro/` and the AppCenter vendor wiring are stripped — they are not just
|
||||
re-pointing.
|
||||
- Items **1, 2, 3, 4, 5, 6** are the hard prerequisites to ship a notarized, self-updating fork:
|
||||
without a new Developer ID + a new Sparkle EdDSA keypair the fork can neither notarize nor sign
|
||||
its own updates. The `AGENTS.md` Keychain-orphaning concern is **moot** for the free fork because
|
||||
the license/Keychain code is being removed — there is no stored license to orphan.
|
||||
- The `appcast.xml` already in the repo contains ~3876 lines of upstream EdDSA-signed enclosures
|
||||
pointing at upstream releases. A clean fork should **truncate/regenerate** it (old entries are
|
||||
signed by the upstream private key #6 and reference upstream downloads #9), otherwise existing
|
||||
AltTab users' Sparkle could see fork updates or vice-versa.
|
||||
- `DEVELOPMENT_TEAM = "${TEAM_ID}"` (undefined) is harmless today but should be set to the fork's
|
||||
TeamID (or left empty with explicit `CODE_SIGN_IDENTITY`) for clarity.
|
||||
@@ -0,0 +1,390 @@
|
||||
# 09 — Telemetry / Analytics & Relationship to Upstream
|
||||
|
||||
Audit scope: every network/telemetry surface in the app, the fork's divergence from
|
||||
upstream AltTab (`lwouis/alt-tab-macos`), and every branding/identity string that ties
|
||||
this binary to the paywalled "AltTab Pro" product. Read-only analysis; no source was
|
||||
modified. All paths are relative to the repo root unless noted.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
- There is **no third-party analytics or product-tracking SDK** in this app. A broad
|
||||
sweep for Mixpanel / Amplitude / Sentry / Firebase / Crashlytics / Segment / PostHog /
|
||||
Datadog / Heap / Matomo / Plausible / Google Analytics found **nothing** in shipped
|
||||
source, `Info.plist`, or xcconfig.
|
||||
- The only vendored telemetry SDK is **Microsoft AppCenter**, and only its **Crashes**
|
||||
module is used — there is no `import AppCenterAnalytics` anywhere. Crash reporting is
|
||||
**opt-in / ask-by-default** and is deliberately wired so AppCenter makes **no network
|
||||
request at all** unless the user agrees to send a specific crash report.
|
||||
- The app makes network calls from exactly **four** places, all to first-party
|
||||
`alt-tab.app` infrastructure or to AppCenter:
|
||||
1. AppCenter crash uploads (opt-in).
|
||||
2. The license backend (`alt-tab.app/api/v1/license/*`) — part of the paywall.
|
||||
3. The feedback form (`alt-tab.app/api/v1/feedback`) — posts to a server that opens a
|
||||
public GitHub issue.
|
||||
4. Sparkle auto-update (`alt-tab.app/appcast.xml`), which sends a small system profile
|
||||
and a per-license-tier cookie.
|
||||
- **This repo is a near-verbatim copy of upstream `lwouis/alt-tab-macos`, not a
|
||||
diverged fork.** Every one of the last 40 commits is authored by `lwouis` or
|
||||
`semantic-release-bot`; there are **no fork-specific commits**. The entire paywall was
|
||||
introduced **upstream** by lwouis in a single commit, `9147a4a8 "feat: introducing
|
||||
alt-tab pro!"` (v11.0.0, 2026-04-05). The "alt-tab-free" repo has only re-hosted the
|
||||
code under `KofTwentyTwo/CommandTabFree` — **no code-level rebrand has happened yet**:
|
||||
bundle id, app name, domain, GitHub URL, and SUPublicEDKey are all still the upstream
|
||||
`com.lwouis.alt-tab-macos` / `alt-tab.app` / `lwouis/alt-tab-macos` values.
|
||||
|
||||
This has a major strategic implication discussed in §6: because upstream is alive,
|
||||
GPL-3.0, and is the source of both the features *and* the paywall, the cleanest path may
|
||||
be to **rebase a free build on a pre-11.0.0 upstream tag** rather than surgically
|
||||
extracting the paywall from this snapshot.
|
||||
|
||||
---
|
||||
|
||||
## 2. Telemetry / crash reporting (AppCenter)
|
||||
|
||||
### 2.1 What is vendored
|
||||
|
||||
- `vendor/AppCenter/` — Microsoft AppCenter SDK, version pinned in
|
||||
`vendor/AppCenter/UPSTREAM`:
|
||||
- `VERSION=appcenter-4.3.0 + PLCrashReporter-1.11.1`
|
||||
- `SOURCE=https://github.com/microsoft/appcenter-sdk-apple.git`
|
||||
- `DATE=2026-05-12`
|
||||
- `vendor/AppCenter/Package.swift` declares **only two** products/targets:
|
||||
`AppCenter` and `AppCenterCrashes` (plus the `PLCrashReporter` binary target). The
|
||||
**Analytics** module is not vendored at all (`Package.swift:8-9`). So even if someone
|
||||
wanted product analytics, the SDK pieces are not present.
|
||||
|
||||
### 2.2 The wrapper — `src/vendors/AppCenterCrashes.swift`
|
||||
|
||||
This is the single integration point. Key behaviors:
|
||||
|
||||
- `src/vendors/AppCenterCrashes.swift:6` — `static let secret = Secrets.appCenterSecret`
|
||||
reads the AppCenter app secret from `Info.plist` (see §5).
|
||||
- `src/vendors/AppCenterCrashes.swift:14` —
|
||||
`AppCenter.networkRequestsAllowed = false` is set **before** `AppCenter.start`,
|
||||
specifically (per the inline comment) so that "appcenter makes network call just from
|
||||
AppCenter.start; we only want networking when sending reports."
|
||||
- `src/vendors/AppCenterCrashes.swift:18-20` — wires `Crashes.delegate = self`, a
|
||||
`userConfirmationHandler`, then `AppCenter.start(withAppSecret:services:[Crashes.self])`.
|
||||
Only the `Crashes` service is registered.
|
||||
- `src/vendors/AppCenterCrashes.swift:31-54` — `confirmationHandler` defers a modal
|
||||
`NSAlert` and only allows the network channel to flush
|
||||
(`AppCenter.networkRequestsAllowed = shouldSend`,
|
||||
`Crashes.notify(with: shouldSend ? .send : .dontSend)`) if the user agrees.
|
||||
- `src/vendors/AppCenterCrashes.swift:56-77` — `checkIfShouldSend()` reads
|
||||
`Preferences.crashPolicy` (`.ask` shows the dialog; `.always` auto-sends; otherwise
|
||||
don't send). The user-facing copy is "AltTab crashed last time you used it…"
|
||||
(`:61-62`) and a "Remember my choice" checkbox.
|
||||
- `src/vendors/AppCenterCrashes.swift:94-96` — on a crash upload, the SDK attaches a
|
||||
**debug profile** (`DebugProfile.make()`) as `debug-profile.md`. See §2.4 for what's
|
||||
in it.
|
||||
- `src/vendors/AppCenterCrashes.swift:99` & `:103` — after each send succeeds/fails,
|
||||
`AppCenter.networkRequestsAllowed` is forced back to `false`, so the channel goes
|
||||
silent again between reports.
|
||||
|
||||
### 2.3 Instantiation and plist hooks
|
||||
|
||||
- `src/App.swift:4` `import AppCenterCrashes`; `src/App.swift:33`
|
||||
`private static var appCenterDelegate: AppCenterCrash?`;
|
||||
`src/App.swift:436` `App.appCenterDelegate = AppCenterCrash()` — created at the very
|
||||
top of `applicationDidFinishLaunching`.
|
||||
- `Info.plist:36-37` — `NSPrincipalClass = AppCenterApplication` (the AppCenter
|
||||
exception-catching `NSApplication` subclass).
|
||||
- `Info.plist:69-70` — `AppCenterApplicationForwarderEnabled = 0`.
|
||||
- `Info.plist:73-74` — `AppCenterSecret = $(APPCENTER_SECRET)` (substituted from
|
||||
xcconfig at build time).
|
||||
|
||||
### 2.4 What leaves the device on a crash — `DebugProfile`
|
||||
|
||||
`src/secondary-windows/DebugProfile.swift` builds the attachment. Notable fields
|
||||
(`DebugProfile.swift:15-34`): app name+version, macOS version, **License state label**
|
||||
(`LicenseManager.shared.state.debugProfileLabel`), OS architecture
|
||||
(`Sysctl.run("hw.machine")`), **hardware model** (`Sysctl.run("hw.model")`), and
|
||||
hardware-utilization data. This same profile is also attached to feedback submissions
|
||||
(§4). It does **not** appear to include the user's name/email or the machine fingerprint,
|
||||
but it does include the license tier and hardware model — worth a privacy note.
|
||||
|
||||
### 2.5 Recommendation for a privacy-respecting free build
|
||||
|
||||
Two viable options, in order of how clean they are:
|
||||
|
||||
1. **Remove AppCenter entirely.** Delete `vendor/AppCenter/`, `src/vendors/AppCenterCrashes.swift`,
|
||||
the `App.swift` instantiation (`:4`, `:33`, `:436`), the `Info.plist` keys
|
||||
(`NSPrincipalClass` -> revert to plain `NSApplication`; drop
|
||||
`AppCenterApplicationForwarderEnabled`, `AppCenterSecret`), and the
|
||||
`Secrets.appCenterSecret` accessor. Remove the crash-policy UI/preference if nothing
|
||||
else uses it. This is the most defensible "free + private" posture and removes a
|
||||
~Microsoft-hosted network dependency and the `com.lwouis` AppCenter app secret. Note
|
||||
`DebugProfile.make()` is also used by the feedback path, so keep `DebugProfile.swift`
|
||||
if feedback is retained; only the crash-attachment call site goes away.
|
||||
2. **Keep AppCenter but neuter the default.** The current design is already opt-in
|
||||
(`networkRequestsAllowed=false` until consent), so a minimal change is to ship with
|
||||
`crashPolicy` defaulting to ask/never and point the secret at a self-owned AppCenter
|
||||
app. This keeps crash diagnostics but still ships a Microsoft SDK; less aligned with a
|
||||
"fully free, no strings" rebrand.
|
||||
|
||||
The grafted **upsell** uses of usage data (see §3) are independent of AppCenter and must
|
||||
be removed regardless.
|
||||
|
||||
---
|
||||
|
||||
## 3. `UsageStats` — local-only behavioral counters (NOT telemetry, but paywall fuel)
|
||||
|
||||
`src/util/UsageStats.swift` records how often the switcher is triggered and which
|
||||
"pro" features were used. **This is local-only**: it writes to a private `UserDefaults`
|
||||
suite `"\(App.bundleIdentifier).usage"` (`UsageStats.swift:2`) and **is never sent over
|
||||
the network** (no URLSession reference anywhere in the file; data is pruned after 365
|
||||
days at `:59-69`). So it is not a telemetry concern per se.
|
||||
|
||||
However, it exists **to drive the paywall**: the recorded counts feed the Day1->Day35
|
||||
trial-nag prompts and the upgrade UI:
|
||||
|
||||
- `src/pro/ProConversionCopy.swift:12,27,28` — reads `usedProFeatureNames()`,
|
||||
`triggerCount`, `usedProFeaturesSessionCount` to compose upsell copy.
|
||||
- `src/pro/ui/UsageStatHeroView.swift:64-86` — renders trigger/pro-use counts in the
|
||||
upgrade hero.
|
||||
- `src/pro/scheduling/Day15FullUpgradeWindow.swift:22`,
|
||||
`Day15ProactiveWindow.swift:21` — branch on `usedProFeaturesSessionCount`.
|
||||
- `src/App.swift:315` `UsageStats.recordTrigger`, `:427` `UsageStats.prune`,
|
||||
`:67` `resetSession`.
|
||||
- `src/preferences/settings-window/tabs/AboutTab.swift:148-152` — also shows week/month/
|
||||
year trigger counts in the About tab (a benign stat display).
|
||||
|
||||
Removal note: when the `src/pro/` tree is removed, `UsageStats` callers in `src/pro/*`
|
||||
disappear. The neutral callers (`App.swift` recording, `AboutTab` display) can stay or be
|
||||
removed; nothing about `UsageStats` requires the paywall. It does **not** make network
|
||||
calls, so it is not a privacy blocker on its own.
|
||||
|
||||
---
|
||||
|
||||
## 4. Other network surfaces (outside AppCenter)
|
||||
|
||||
### 4.1 License backend — `src/pro/license/RemoteLicenseClient.swift` (PAYWALL)
|
||||
|
||||
- POSTs to `Endpoints.licenseApiBaseUrl` = `https://alt-tab.app/api/v1/license`
|
||||
(`src/api/Endpoints.swift:11`), endpoints `activate` / `validate` / `deactivate`
|
||||
(`RemoteLicenseClient.swift:104`, network at `:109` `URLSession.shared.dataTask`,
|
||||
`:127` `.resume()`).
|
||||
- The activation body sends a **machine fingerprint** and the local trial-start timestamp
|
||||
(`RemoteLicenseClient.swift:16-25`). The fingerprint is the IOKit
|
||||
`IOPlatformUUID` with a keychain fallback (`src/pro/license/MachineFingerprint.swift:9-20`).
|
||||
This is a stable per-machine identifier transmitted to the backend — a privacy-relevant
|
||||
data flow that exists **only** to support the paywall.
|
||||
- Removal note: this entire client and all of `src/pro/license/` goes away in a free
|
||||
build. It is the most clearly paywall-coupled network call.
|
||||
|
||||
### 4.2 License-tier cookie for Sparkle — `src/pro/license/LicenseCookie.swift` (PAYWALL)
|
||||
|
||||
- `syncLicenseCookie(state:)` writes a `license` cookie (value `pro` / `proExpired` /
|
||||
empty) on the `alt-tab.app` domain so "Sparkle's appcast request can be tailored per
|
||||
tier" (`LicenseCookie.swift:3-21`). Called from `src/App.swift:451` on every license
|
||||
state change.
|
||||
- This means the **auto-update check itself transmits the user's license tier** to the
|
||||
server via cookie. In a free build with no tiers, this should be deleted; the appcast
|
||||
becomes a single feed for everyone.
|
||||
|
||||
### 4.3 Feedback form — `src/secondary-windows/FeedbackWindow.swift`
|
||||
|
||||
- POSTs to `Endpoints.feedbackUrl` = `https://alt-tab.app/api/v1/feedback`
|
||||
(`Endpoints.swift:12`; request built `FeedbackWindow.swift:444-456`, sent `:390`/`:412`).
|
||||
- Body includes the user's title/body, a `kind`, and **the `DebugProfile`**
|
||||
(`FeedbackWindow.swift:449-454`). The UI explicitly warns "Your feedback will be
|
||||
submitted as a public GitHub issue" with "A debug profile (versions, settings,
|
||||
hardware) is attached" (`:380-381`).
|
||||
- This is **first-party and consent-gated**, not analytics. It is not paywall code, but
|
||||
it is tied to the `alt-tab.app` backend and the upstream GitHub repo. A free rebrand
|
||||
must repoint or remove this (the backend that turns the POST into a GitHub issue is not
|
||||
in this repo and presumably belongs to lwouis). Note: a prior upstream commit
|
||||
(`da9a46bf "feat: remove the need to add email in feedback form"`) already minimized PII
|
||||
here.
|
||||
|
||||
### 4.4 Sparkle auto-update — `src/vendors/SparkleDelegate.swift`
|
||||
|
||||
- Feed URL = `Endpoints.appcastUrl` = `https://alt-tab.app/appcast.xml`
|
||||
(`SparkleDelegate.swift:26-28`, `Endpoints.swift:7`).
|
||||
- `feedParameters` sends a small system profile on each check: app version, macOS
|
||||
version, CPU arch (`Sysctl.run("hw.machine")`), and preferred language
|
||||
(`SparkleDelegate.swift:30-38`). Combined with the license cookie (§4.2), update checks
|
||||
currently carry version + OS + arch + lang + license tier.
|
||||
- `Info.plist:61-66` — `SUPublicEDKey` (the upstream lwouis signing key),
|
||||
`SUEnableAutomaticChecks=true`, weekly interval (604800s).
|
||||
- Removal note: a fork **cannot** ship updates against `SUPublicEDKey` /
|
||||
`alt-tab.app/appcast.xml` unless it controls that domain and private signing key. A free
|
||||
republish must either point the appcast at its own host with its own EdDSA key, or strip
|
||||
auto-update. The license-tier cookie should be removed regardless.
|
||||
|
||||
---
|
||||
|
||||
## 5. Build-time identity/secrets wiring
|
||||
|
||||
- `src/api/Secrets.swift:6` — `appCenterSecret` from `Info.plist` key `AppCenterSecret`.
|
||||
- `src/api/Endpoints.swift` — all URLs derive from `Domain` (`alt-tab.app`) and
|
||||
`ApiDomain` (`alt-tab.app/api`) plist keys (`Endpoints.swift:4-5`), themselves from
|
||||
`config/base.xcconfig:20-21` (`DOMAIN`, `API_DOMAIN`).
|
||||
- `config/base.xcconfig:3-4` — `PRODUCT_NAME = AltTab`,
|
||||
`PRODUCT_BUNDLE_IDENTIFIER = com.lwouis.alt-tab-macos`.
|
||||
- `Info.plist:73-78` — `AppCenterSecret`, `Domain`, `ApiDomain` substituted from xcconfig.
|
||||
- `alt_tab_macos.entitlements` — app-sandbox **off**, library validation disabled. No
|
||||
app-group / keychain-access-group is declared here (per AGENTS.md the keychain items are
|
||||
tied to the code signature / bundle id, so the bundle id is load-bearing for stored
|
||||
license keys — relevant only if you keep licensing, which a free build would not).
|
||||
|
||||
---
|
||||
|
||||
## 6. Fork vs upstream — git characterization
|
||||
|
||||
### 6.1 Commands run and what they showed
|
||||
|
||||
```
|
||||
git log --oneline -40 # last 40 commits: ALL authored by lwouis / semantic-release-bot
|
||||
git log --oneline -- src/pro # only 3 commits ever touch src/pro
|
||||
git log --oneline --diff-filter=A -- src/pro | tail -5 # src/pro first appears in 9147a4a8
|
||||
git log -40 --format='%an' | sort | uniq -c # 34 lwouis, 6 semantic-release-bot
|
||||
git rev-list --count HEAD # 1850 commits total
|
||||
git remote -v # origin = github.com:KofTwentyTwo/CommandTabFree.git
|
||||
```
|
||||
|
||||
Findings:
|
||||
|
||||
- **The paywall was born in one commit, upstream.** `src/pro/` has only ever been touched
|
||||
by three commits, and it was **added** in `9147a4a8 "feat: introducing alt-tab pro!"`,
|
||||
authored by `lwouis <lwouis@gmail.com>` on **2026-04-05**, shipped as **v11.0.0**
|
||||
(`1f2b5a51 "chore(release): 11.0.0 [skip ci]"`). The commit immediately before the pro
|
||||
era (`317a485b`) is v10.12.0. Confirmed `src/pro/` did not exist before 9147a4a8
|
||||
(`git log 9147a4a8~1 -- src/pro` is empty).
|
||||
- **This is not a diverged fork.** The author of every recent commit — including bug
|
||||
fixes after the pro launch (`f3fe535a`, `cddc89dd`, `e0b1778d`, etc.) — is `lwouis`.
|
||||
There are **zero commits by KofTwentyTwo / the current owner**. The "alt-tab-free" repo
|
||||
is a mirror of upstream `lwouis/alt-tab-macos` re-hosted under a new origin
|
||||
(`git@github.com:KofTwentyTwo/CommandTabFree.git`), with the codebase otherwise unchanged.
|
||||
The README still markets it as "AltTab Pro" and links `https://alt-tab.app/`
|
||||
(`README.md:1`).
|
||||
|
||||
### 6.2 What `9147a4a8` actually changed
|
||||
|
||||
It was a **1,869-file** commit — far more than just adding `src/pro/`. It simultaneously
|
||||
introduced the paywall *and* performed a large repo-wide reorganization (many files were
|
||||
moved into new folder structures: `macos/api-wrappers/`, `switcher/main-window/`,
|
||||
`switcher/state/`, `kit/`, `preferences/settings-window/tabs/...`, etc.). So the v11.0.0
|
||||
boundary is both "Pro launches" and "big refactor," which makes a literal
|
||||
`git revert 9147a4a8` impractical.
|
||||
|
||||
**Files added under `src/pro/` in that commit (the paywall proper):**
|
||||
- `src/pro/license/` — `Clock.swift`, `Keychain.swift`, `LicenseAPI.swift`,
|
||||
`LicenseCookie.swift`, `LicenseManager.swift`, `LicenseState.swift`,
|
||||
`MachineFingerprint.swift`, `RemoteLicenseClient.swift`.
|
||||
- `src/pro/scheduling/` — `ProTransitionManager.swift`, `ProTransitionScheduler.swift`,
|
||||
`ProTransitionState.swift`, and the trial-nag windows/popovers
|
||||
`Day1WelcomeLetterWindow`, `Day4TourPopover`, `Day12HeadsUpPopover`,
|
||||
`Day15FullUpgradeWindow`, `Day15HardGatePopover`, `Day15ProactiveWindow`,
|
||||
`Day21ReminderPopover`, `Day35FinalWindow`.
|
||||
- `src/pro/ui/` — `ProBadgeView`, `ProGradientButton`, `ProPromptHeader`,
|
||||
`ProPromptHost`, `ProPromptPopover`, `ProPromptWindow`, `UsageStatHeroView`.
|
||||
- `src/pro/` root — `ProConversionCopy.swift`, `ProFeature.swift`, `ProFeatureCopy.swift`.
|
||||
|
||||
(Subsequent commit `60cca89b` added co-located `*Specs.md` / `*Tests.swift` under
|
||||
`src/pro`, the only other src/pro churn.)
|
||||
|
||||
### 6.3 Upstream vs fork-added top-level areas
|
||||
|
||||
Because this repo *is* upstream-as-of-v11.x, the right framing is **upstream AltTab core
|
||||
vs the v11.0.0 paywall graft**, not "fork-added" (there is no fork-added code):
|
||||
|
||||
- **Upstream AltTab core (keep):** `src/switcher/`, `src/events/`, `src/macos/`,
|
||||
`src/preferences/` (minus pro-gating hooks), `src/kit/`, `src/util/`,
|
||||
`src/experimentations/`, `src/secondary-windows/` (feedback/debug), `App.swift`,
|
||||
`main.swift`, `MainMenu.swift`, `Menubar.swift`, and the `vendor/Sparkle` +
|
||||
`vendor/ShortcutRecorder` deps. These predate and are independent of the paywall.
|
||||
- **Paywall graft (remove):** the entire `src/pro/` tree, plus the call sites that hook it
|
||||
into the core — e.g. `App.swift:428-429` (`ProTransitionManager` wiring), `:448-459`
|
||||
(`LicenseManager` callbacks, `syncLicenseCookie`), `Menubar.swift:46/48/49` ("Get Pro",
|
||||
"My Account", "Support this project"), `App.swift:111-120`
|
||||
(`supportProject`/`upgradeToPro`/`openAccount` selectors), `UpgradeTab`, and the
|
||||
`src/pro/license/` network/keychain/fingerprint machinery (`src/api/Endpoints.swift`
|
||||
license/account/checkout URLs, `LicenseCookie`). These are detailed in the
|
||||
paywall-specific audit sections; this section flags them only as the fork/upstream
|
||||
boundary.
|
||||
- **Telemetry/diagnostics (decide):** `vendor/AppCenter` + `src/vendors/AppCenterCrashes.swift`
|
||||
(crash reporting), feedback POST, Sparkle appcast — first-party, not paywall, but tied to
|
||||
`alt-tab.app` and the lwouis signing identity.
|
||||
|
||||
---
|
||||
|
||||
## 7. Branding / identity strings that tie the binary to the paywalled product
|
||||
|
||||
Everything below is still the **upstream lwouis identity** — none of it has been rebranded
|
||||
for "alt-tab-free" yet. Each is a string a clean free build must change or remove.
|
||||
|
||||
| What | Value | Location |
|
||||
|---|---|---|
|
||||
| App name | `AltTab` | `config/base.xcconfig:3` (`PRODUCT_NAME`), surfaced via `App.name` `src/App.swift:13` |
|
||||
| Bundle id | `com.lwouis.alt-tab-macos` | `config/base.xcconfig:4`; also hardcoded in `src/_test-support/Mocks.swift:133`; consumed by `UsageStats.swift:2` and the URL scheme `Info.plist:46-58` |
|
||||
| Source-code / repo URL | `https://github.com/lwouis/alt-tab-macos` | `src/App.swift:16` (`App.repository`); shown as "Source code" link in `AboutTab.swift:19` |
|
||||
| Website domain | `alt-tab.app` | `config/base.xcconfig:20` (`DOMAIN`), `Info.plist:75-76` |
|
||||
| API domain | `alt-tab.app/api` | `config/base.xcconfig:21` (`API_DOMAIN`), `Info.plist:77-78` |
|
||||
| Website link | `https://alt-tab.app` | `Endpoints.website` `src/api/Endpoints.swift:6`; "Website" link `AboutTab.swift:18` |
|
||||
| Support/donate URL | `alt-tab.app/support` | `Endpoints.supportUrl` `Endpoints.swift:8`; opened by `App.supportProject()` `App.swift:111-112`; menubar "Support this project" `Menubar.swift:49` |
|
||||
| Checkout/pricing URL | `alt-tab.app/pricing` | `Endpoints.checkoutUrl` `Endpoints.swift:9` (paywall) |
|
||||
| Account URL | `alt-tab.app/my-account` | `Endpoints.accountUrl` `Endpoints.swift:10`; opened by `App.openAccount()`/`UpgradeTab.swift:273`; menubar "My Account" `Menubar.swift:48` |
|
||||
| License API base | `https://alt-tab.app/api/v1/license` | `Endpoints.licenseApiBaseUrl` `Endpoints.swift:11` (paywall) |
|
||||
| Feedback URL | `https://alt-tab.app/api/v1/feedback` | `Endpoints.feedbackUrl` `Endpoints.swift:12` |
|
||||
| Appcast URL | `https://alt-tab.app/appcast.xml` | `Endpoints.appcastUrl` `Endpoints.swift:7`; `SparkleDelegate.swift:27` |
|
||||
| Sparkle signing key | `SUPublicEDKey = 2e9SQOBoaKElchSa/4QDli/nvYkyuDNfynfzBF6vJK4=` | `Info.plist:62` (the upstream EdDSA key; cannot sign updates without lwouis's private key) |
|
||||
| AppCenter app secret | `$(APPCENTER_SECRET)` -> lwouis's AppCenter app | `Info.plist:73-74`, `config/*.xcconfig`, `Secrets.swift:6` |
|
||||
| Menubar upsell text | "Get Pro" | `Menubar.swift:46`, `:380` |
|
||||
| Crash dialog copy | "AltTab crashed last time you used it…" | `AppCenterCrashes.swift:61-62` |
|
||||
| Copyright | "GPL-3.0 licence" | `Info.plist:20-21` (license — keep, it's correct) |
|
||||
| README marketing | "AltTab Pro — … — Get AltTab", links `https://alt-tab.app/` | `README.md:1` |
|
||||
|
||||
Code identifiers named `AltTab*` (e.g. `AltTabKey`, image asset names) and references to
|
||||
`lwouis/alt-tab-macos` GitHub **issue numbers** inside code comments
|
||||
(`src/macos/api-wrappers/MissionControl.swift:32`, `src/switcher/state/Applications.swift:109-110`,
|
||||
`src/switcher/state/WindowThumbnails.swift:35`, etc.) are benign provenance/attribution and
|
||||
can stay; they don't gate behavior. The `changelog.md` is entirely upstream-linked but is a
|
||||
doc artifact, not shipped behavior.
|
||||
|
||||
**Keychain/identity caveat (from AGENTS.md):** the bundle id `com.lwouis.alt-tab-macos`
|
||||
is coupled to Developer ID / TeamID and to keychain items used by the license code.
|
||||
For a free build that *removes* licensing, the keychain coupling stops mattering (no
|
||||
stored license keys to orphan), so rebranding the bundle id to a KofTwentyTwo identifier
|
||||
is safe and recommended. If any licensing were retained, a keychain migration would be
|
||||
required first.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rebase-on-upstream vs clean-this-fork
|
||||
|
||||
Because the audit shows this repo is **upstream code re-hosted, with the paywall authored
|
||||
upstream**, the owner has a real choice:
|
||||
|
||||
**Option A — Rebase the free build on a pre-Pro upstream tag.**
|
||||
Upstream `lwouis/alt-tab-macos` is alive and GPL-3.0. v10.12.0 (`317a485b`) is the last
|
||||
release **before** the Pro paywall (`9147a4a8` / v11.0.0). A free build could branch from
|
||||
the pre-11.0.0 state and then cherry-pick only the *non-paywall* bug fixes that landed in
|
||||
the 11.x line. Pros: no paywall code to extract, no `src/pro/` entanglement, no license
|
||||
cookie/fingerprint surfaces, no need to reverse the 1,869-file Pro reorg. Cons: loses any
|
||||
genuinely useful non-paywall improvements made *after* v11.0.0 unless individually
|
||||
cherry-picked; the v11 refactor is intermixed with the paywall, so picking later fixes
|
||||
that depend on the new folder layout is harder.
|
||||
|
||||
**Option B — Clean this snapshot (remove `src/pro/` + hooks in place).**
|
||||
Keep the current v11.x codebase (with its newer fixes/refactor) and surgically remove the
|
||||
paywall: delete `src/pro/`, the App.swift/Menubar/UpgradeTab hooks, the license/account/
|
||||
checkout endpoints, the license cookie, and decide on AppCenter. Pros: keeps the latest
|
||||
window-switching improvements. Cons: more invasive, must verify the build still links and
|
||||
that nothing in the core silently depends on `LicenseManager`/`ProTransitionManager`
|
||||
(the App.swift wiring at `:428-459` is the main coupling to untangle).
|
||||
|
||||
GPL-3.0 permits either path (the fork must stay GPL-3.0 and preserve `LICENCE.md` and the
|
||||
`NSHumanReadableCopyright` notice). Given that the goal is "all features unlocked," and the
|
||||
paywall gates **existing** AltTab features rather than adding new ones, **both options yield
|
||||
the same feature set**; the decision is really "do we want the post-v11 fixes badly enough
|
||||
to do the harder in-place removal (B), or do we prefer the clean slate of pre-paywall
|
||||
upstream (A)?" A pragmatic hybrid is to start from this snapshot (B) for the newer fixes
|
||||
but use upstream's v10.12.0 as a reference for what the core looked like before the graft.
|
||||
|
||||
Either way, the telemetry/branding cleanup in §2, §4, §5, and §7 is identical and must be
|
||||
done on top of whichever base is chosen — most importantly: drop the license-tier cookie,
|
||||
repoint or remove the appcast + Sparkle signing key, decide AppCenter's fate, and rebrand
|
||||
the bundle id / name / URLs off the `lwouis` / `alt-tab.app` identity.
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"name": "alt-tab-macos",
|
||||
"version": "1.0.0",
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"license": "GPL-3.0",
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
|
||||
|
||||
+4
-1
@@ -18,9 +18,12 @@ module.exports = {
|
||||
'changelogFile': 'changelog.md',
|
||||
}],
|
||||
['@semantic-release/git', {
|
||||
// alt-tab-free [depaywall]: 'appcast.xml' REMOVED from assets (PLAN §3.3/§4.3). The fork
|
||||
// publishes the Sparkle feed OUT OF TREE (gh-pages) via scripts/update_appcast.sh, and
|
||||
// appcast.xml is removed from the tree. If it stayed here, semantic-release would try to
|
||||
// commit the now-untracked file back every release.
|
||||
'assets': [
|
||||
'changelog.md',
|
||||
'appcast.xml',
|
||||
'README.md',
|
||||
],
|
||||
}],
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,6 @@
|
||||
/* %@ is the app name */
|
||||
"%@ is a free, de-paywalled fork. All upstream work and credit belong to Louis Pontoise and AltTab’s contributors." = "%@ is a free, de-paywalled fork. All upstream work and credit belong to Louis Pontoise and AltTab’s contributors.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"%@ now has a Pro tier" = "%@ now has a Pro tier";
|
||||
|
||||
@@ -23,7 +26,10 @@
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "A debug profile (versions, settings, hardware) is attached to help diagnose the issue.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"A new version of AltTab is available" = "A new version of AltTab is available";
|
||||
"A KofTwentyTwo project" = "A KofTwentyTwo project";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"A new version of CommandTabFree is available" = "A new version of CommandTabFree is available";
|
||||
|
||||
/* Menubar option. %@ is AltTab */
|
||||
"About %@" = "About %@";
|
||||
@@ -82,18 +88,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Alphabetical Order" = "Alphabetical Order";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab crashed last time you used it. Sending a crash report will help get the issue fixed";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial.";
|
||||
|
||||
/* Menubar callout. %@ is one or more feature names, e.g. Thumbnails */
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab is running without Screen Recording permissions. %@ won’t show.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"AltTab needs some permissions" = "AltTab needs some permissions";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
|
||||
@@ -103,9 +100,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"AltTab Pro is still available" = "AltTab Pro is still available";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab will show if the window is currently minimized or fullscreen with a status icon.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Always" = "Always";
|
||||
|
||||
@@ -181,6 +175,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Automatic activation failed" = "Automatic activation failed";
|
||||
|
||||
/* Attribution to the upstream project AltTab; do not translate the product names */
|
||||
"Based on AltTab by Louis Pontoise" = "Based on AltTab by Louis Pontoise";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Brief description of the bug" = "Brief description of the bug";
|
||||
|
||||
@@ -211,6 +208,18 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Close window" = "Close window";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed";
|
||||
|
||||
/* Menubar callout. %@ is one or more feature names, e.g. Thumbnails */
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree is running without Screen Recording permissions. %@ won’t show.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree needs some permissions";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree will show if the window is currently minimized or fullscreen with a status icon.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Conflicting shortcut" = "Conflicting shortcut";
|
||||
|
||||
@@ -359,7 +368,7 @@
|
||||
"Group tabs" = "Group tabs";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Help improve AltTab" = "Help improve AltTab";
|
||||
"Help improve CommandTabFree" = "Help improve CommandTabFree";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Hide" = "Hide";
|
||||
@@ -431,7 +440,7 @@
|
||||
"Machine %@" = "Machine %@";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Manage activations, view receipts, etc" = "Manage activations, view receipts, etc";
|
||||
@@ -517,6 +526,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Ordering & Grouping" = "Ordering & Grouping";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Original source" = "Original source";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Overridden in Shortcut:" = "Overridden in Shortcut:";
|
||||
|
||||
@@ -527,7 +539,7 @@
|
||||
"Pick a machine to deactivate so you can activate here." = "Pick a machine to deactivate so you can activate here.";
|
||||
|
||||
/* LetsMove replacement alert */
|
||||
"Please drag AltTab into the Applications folder manually." = "Please drag AltTab into the Applications folder manually.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Please drag CommandTabFree into the Applications folder manually.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Please fill in the form" = "Please fill in the form";
|
||||
@@ -602,7 +614,7 @@
|
||||
"Screen Recording" = "Screen Recording";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Screen showing AltTab" = "Screen showing AltTab";
|
||||
"Screen showing CommandTabFree" = "Screen showing CommandTabFree";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Search" = "Search";
|
||||
@@ -871,9 +883,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Visible Spaces" = "Visible Spaces";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Website" = "Website";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Welcome to %@" = "Welcome to %@";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "تمرير أفقي بأربعة أصابع";
|
||||
"4-finger Vertical Swipe" = "تمرير عمودي بأربعة أصابع";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "تم إرفاق ملف تصحيح (الإصدارات، الإعدادات، العتاد) للمساعدة في تشخيص المشكلة.";
|
||||
"A new version of AltTab is available" = "يتوفر إصدار جديد من AltTab";
|
||||
"A new version of CommandTabFree is available" = "يتوفر إصدار جديد من CommandTabFree";
|
||||
"About %@" = "حول %@";
|
||||
"Accessibility" = "إمكانية الوصول";
|
||||
"Activate" = "تفعيل";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "كل المساحات";
|
||||
"Allowed" = "مسموح";
|
||||
"Alphabetical Order" = "ترتيب أبجدي";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "تعطّل AltTab في آخر مرة استخدمته فيها. إرسال تقرير الأعطال سيساعد في إصلاح المشكلة";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab هو مُحوّل نوافذ مجاني ومفتوح المصدر لـ macOS. تتوفر ميزات Pro مع فترة تجريبية مجانية لمدة 14 يومًا.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "يعمل AltTab بدون أذونات تسجيل الشاشة. لن تظهر %@.";
|
||||
"AltTab needs some permissions" = "يحتاج AltTab إلى بعض الأذونات";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "يضيف AltTab Pro 4 ميزات تتجاوز المُحوّل المجاني.";
|
||||
"AltTab Pro is still available" = "لا يزال AltTab Pro متاحًا";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "سيُظهر AltTab ما إذا كانت النافذة مصغّرة أو بملء الشاشة حاليًا برمز حالة.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "تعطّل CommandTabFree في آخر مرة استخدمته فيها. إرسال تقرير الأعطال سيساعد في إصلاح المشكلة";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree هو مُحوّل نوافذ مجاني ومفتوح المصدر لـ macOS. تتوفر ميزات Pro مع فترة تجريبية مجانية لمدة 14 يومًا.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "يعمل CommandTabFree بدون أذونات تسجيل الشاشة. لن تظهر %@.";
|
||||
"CommandTabFree needs some permissions" = "يحتاج CommandTabFree إلى بعض الأذونات";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "يضيف CommandTabFree Pro 4 ميزات تتجاوز المُحوّل المجاني.";
|
||||
"CommandTabFree Pro is still available" = "لا يزال CommandTabFree Pro متاحًا";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "سيُظهر CommandTabFree ما إذا كانت النافذة مصغّرة أو بملء الشاشة حاليًا برمز حالة.";
|
||||
"Always" = "دائمًا";
|
||||
"Always send crash reports" = "إرسال تقارير الأعطال دائمًا";
|
||||
"and press" = "ثم اضغط";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "ملء الشاشة/إلغاء ملء الشاشة للنافذة";
|
||||
"General" = "عام";
|
||||
"Gesture" = "الإيماءة";
|
||||
"Get more from AltTab with Pro" = "احصل على المزيد من AltTab مع Pro";
|
||||
"Get more from CommandTabFree with Pro" = "احصل على المزيد من CommandTabFree مع Pro";
|
||||
"Get Pro" = "الحصول على Pro";
|
||||
"Go back" = "العودة";
|
||||
"Grant permission" = "منح الإذن";
|
||||
"Group apps" = "تجميع التطبيقات";
|
||||
"Group tabs" = "تجميع علامات التبويب";
|
||||
"Help improve AltTab" = "ساعد في تحسين AltTab";
|
||||
"Help improve CommandTabFree" = "ساعد في تحسين CommandTabFree";
|
||||
"Hide" = "إخفاء";
|
||||
"Hide colored circles on mouse hover" = "إخفاء الدوائر الملوّنة عند تمرير الفأرة";
|
||||
"Hide Space number labels" = "إخفاء تسميات أرقام المساحات";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "كبير";
|
||||
"Later" = "لاحقًا";
|
||||
"License doesn't cover this version" = "الترخيص لا يغطي هذا الإصدار";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "سيظل الترخيص ساري المفعول وقابلاً للاستخدام لتفعيل AltTab على أي جهاز";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "سيظل الترخيص ساري المفعول وقابلاً للاستخدام لتفعيل CommandTabFree على أي جهاز";
|
||||
"Light" = "فاتح";
|
||||
"Lock search" = "قفل البحث";
|
||||
"Machine %@" = "الجهاز %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "يحجز macOS ⌘⌥⎋ و⌘⌥⇧⎋ و⌘⌥⇧⌃⎋ لإنهاء بالقوة ولا يمكن إلغاء ربطها. لا يمكن لـ AltTab استخدامها.\n\nسيؤدي تغييرك إلى تعيين أحدها إلى: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "يحجز macOS ⌘⌥⎋ و⌘⌥⇧⎋ و⌘⌥⇧⌃⎋ لإنهاء بالقوة ولا يمكن إلغاء ربطها. لا يمكن لـ CommandTabFree استخدامها.\n\nسيؤدي تغييرك إلى تعيين أحدها إلى: %@.";
|
||||
"Manage activations, view receipts, etc" = "إدارة عمليات التفعيل، عرض الإيصالات، إلخ";
|
||||
"Maybe later" = "ربما لاحقًا";
|
||||
"Medium" = "متوسط";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "تم تجاوزه في اختصار:";
|
||||
"Paste your license key:" = "الصق مفتاح الترخيص الخاص بك:";
|
||||
"Pick a machine to deactivate so you can activate here." = "اختر جهازًا لإلغاء تفعيله حتى تتمكن من التفعيل هنا.";
|
||||
"Please drag AltTab into the Applications folder manually." = "يرجى سحب AltTab إلى مجلد التطبيقات يدويًا.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "يرجى سحب CommandTabFree إلى مجلد التطبيقات يدويًا.";
|
||||
"Please fill in the form" = "يرجى ملء النموذج";
|
||||
"Preview selected window" = "معاينة النافذة المحددة";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "الشاشة التي تتضمن شريط القائمة";
|
||||
"Screen including mouse" = "الشاشة التي تتضمن الفأرة";
|
||||
"Screen Recording" = "تسجيل الشاشة";
|
||||
"Screen showing AltTab" = "الشاشة التي تعرض AltTab";
|
||||
"Screen showing CommandTabFree" = "الشاشة التي تعرض CommandTabFree";
|
||||
"Search" = "البحث";
|
||||
"Search is a Pro feature." = "البحث ميزة من ميزات Pro.";
|
||||
"Search windows by typing" = "البحث في النوافذ بالكتابة";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "عنوان النافذة";
|
||||
"Window title contains" = "عنوان النافذة يحتوي على";
|
||||
"You can’t undo this action." = "لا يمكنك التراجع عن هذا الإجراء.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "لديك 14 يومًا لتجربة جميع ميزات Pro. بعد ذلك، ستتراجع ميزات Pro وسيستمر مُحوّل النوافذ الأساسي في العمل تمامًا كما كان من قبل.\n\nيظل AltTab مجانيًا ومفتوح المصدر. Pro عبارة عن شراء اختياري لمرة واحدة يموّل التطوير المستمر.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "لديك 14 يومًا لتجربة جميع ميزات Pro. بعد ذلك، ستتراجع ميزات Pro وسيستمر مُحوّل النوافذ الأساسي في العمل تمامًا كما كان من قبل.\n\nيظل CommandTabFree مجانيًا ومفتوح المصدر. Pro عبارة عن شراء اختياري لمرة واحدة يموّل التطوير المستمر.";
|
||||
"You may need to disable some conflicting system gestures" = "قد تحتاج إلى تعطيل بعض إيماءات النظام المتعارضة";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "أنت في اليوم 4 من 14 — جرّب هذه قبل انتهاء فترتك التجريبية:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "أنت تستخدم الإصدار v%1$@. الإصدار v%2$@ متاح. ربما تم إصلاح الخلل الذي تراه بالفعل — يرجى التحديث أولاً والتحقق قبل الإبلاغ.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "كنت تستخدم %@.\nستعود هذه إلى الإعدادات الافتراضية في اليوم 15.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "كنت تستخدم %d من ميزات Pro.\nستعود إلى الإعدادات الافتراضية في اليوم 15.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "استخدمت AltTab %1$@ مرة — استخدم %2$@ منها ميزات Pro.";
|
||||
"You've used AltTab %@ times." = "استخدمت AltTab %@ مرة.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "استخدمت CommandTabFree %1$@ مرة — استخدم %2$@ منها ميزات Pro.";
|
||||
"You've used CommandTabFree %@ times." = "استخدمت CommandTabFree %@ مرة.";
|
||||
"Your 14-day Pro trial just ended" = "انتهت للتو فترة Pro التجريبية لمدة 14 يومًا";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "سيتم إرسال ملاحظاتك كمشكلة عامة على GitHub.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "ترخيصك لا يغطي هذا الإصدار. قم بالترقية إلى Lifetime Pro.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Horizontales 4-Finger-Wischen";
|
||||
"4-finger Vertical Swipe" = "Vertikales 4-Finger-Wischen";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "Ein Debug-Profil (Versionen, Einstellungen, Hardware) ist angehängt, um das Problem zu diagnostizieren.";
|
||||
"A new version of AltTab is available" = "Eine neue Version von AltTab ist verfügbar";
|
||||
"A new version of CommandTabFree is available" = "Eine neue Version von CommandTabFree ist verfügbar";
|
||||
"About %@" = "Über %@";
|
||||
"Accessibility" = "Bedienungshilfen";
|
||||
"Activate" = "Aktivieren";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Alle Spaces";
|
||||
"Allowed" = "Erlaubt";
|
||||
"Alphabetical Order" = "Alphabetische Reihenfolge";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab ist bei der letzten Verwendung abgestürzt. Das Senden eines Absturzberichts hilft bei der Behebung des Problems";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab ist ein kostenloser, quelloffener Fensterumschalter für macOS. Pro-Funktionen sind mit einer 14-tägigen kostenlosen Testversion verfügbar.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab läuft ohne die Berechtigung für Bildschirmaufnahme. %@ werden nicht angezeigt.";
|
||||
"AltTab needs some permissions" = "AltTab benötigt einige Berechtigungen";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro fügt 4 Funktionen über den kostenlosen Umschalter hinaus hinzu.";
|
||||
"AltTab Pro is still available" = "AltTab Pro ist weiterhin verfügbar";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab zeigt mit einem Statussymbol an, ob das Fenster derzeit minimiert oder im Vollbild ist.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree ist bei der letzten Verwendung abgestürzt. Das Senden eines Absturzberichts hilft bei der Behebung des Problems";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree ist ein kostenloser, quelloffener Fensterumschalter für macOS. Pro-Funktionen sind mit einer 14-tägigen kostenlosen Testversion verfügbar.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree läuft ohne die Berechtigung für Bildschirmaufnahme. %@ werden nicht angezeigt.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree benötigt einige Berechtigungen";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro fügt 4 Funktionen über den kostenlosen Umschalter hinaus hinzu.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro ist weiterhin verfügbar";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree zeigt mit einem Statussymbol an, ob das Fenster derzeit minimiert oder im Vollbild ist.";
|
||||
"Always" = "Immer";
|
||||
"Always send crash reports" = "Absturzberichte immer senden";
|
||||
"and press" = "und drücken";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Fenster in Vollbild/aus Vollbild";
|
||||
"General" = "Allgemein";
|
||||
"Gesture" = "Geste";
|
||||
"Get more from AltTab with Pro" = "Mehr aus AltTab mit Pro herausholen";
|
||||
"Get more from CommandTabFree with Pro" = "Mehr aus CommandTabFree mit Pro herausholen";
|
||||
"Get Pro" = "Pro holen";
|
||||
"Go back" = "Zurück";
|
||||
"Grant permission" = "Berechtigung erteilen";
|
||||
"Group apps" = "Apps gruppieren";
|
||||
"Group tabs" = "Tabs gruppieren";
|
||||
"Help improve AltTab" = "Helfen Sie, AltTab zu verbessern";
|
||||
"Help improve CommandTabFree" = "Helfen Sie, CommandTabFree zu verbessern";
|
||||
"Hide" = "Ausblenden";
|
||||
"Hide colored circles on mouse hover" = "Farbige Kreise beim Überfahren mit der Maus ausblenden";
|
||||
"Hide Space number labels" = "Space-Nummern ausblenden";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Groß";
|
||||
"Later" = "Später";
|
||||
"License doesn't cover this version" = "Lizenz deckt diese Version nicht ab";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "Die Lizenz bleibt gültig und kann zum Aktivieren von AltTab auf einem beliebigen Computer verwendet werden";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "Die Lizenz bleibt gültig und kann zum Aktivieren von CommandTabFree auf einem beliebigen Computer verwendet werden";
|
||||
"Light" = "Hell";
|
||||
"Lock search" = "Suche sperren";
|
||||
"Machine %@" = "Computer %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserviert ⌘⌥⎋, ⌘⌥⇧⎋ und ⌘⌥⇧⌃⎋ für „Sofort beenden“ und sie können nicht freigegeben werden. AltTab kann sie nicht verwenden.\n\nIhre Änderung würde eines davon zuweisen an: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserviert ⌘⌥⎋, ⌘⌥⇧⎋ und ⌘⌥⇧⌃⎋ für „Sofort beenden“ und sie können nicht freigegeben werden. CommandTabFree kann sie nicht verwenden.\n\nIhre Änderung würde eines davon zuweisen an: %@.";
|
||||
"Manage activations, view receipts, etc" = "Aktivierungen verwalten, Belege anzeigen usw.";
|
||||
"Maybe later" = "Vielleicht später";
|
||||
"Medium" = "Mittel";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Überschrieben in Tastenkürzel:";
|
||||
"Paste your license key:" = "Lizenzschlüssel einfügen:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Wählen Sie einen Computer zur Deaktivierung, um hier zu aktivieren.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Bitte ziehen Sie AltTab manuell in den Programme-Ordner.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Bitte ziehen Sie CommandTabFree manuell in den Programme-Ordner.";
|
||||
"Please fill in the form" = "Bitte füllen Sie das Formular aus";
|
||||
"Preview selected window" = "Ausgewähltes Fenster in der Vorschau anzeigen";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Bildschirm mit Menüleiste";
|
||||
"Screen including mouse" = "Bildschirm mit Mauszeiger";
|
||||
"Screen Recording" = "Bildschirmaufnahme";
|
||||
"Screen showing AltTab" = "Bildschirm, der AltTab anzeigt";
|
||||
"Screen showing CommandTabFree" = "Bildschirm, der CommandTabFree anzeigt";
|
||||
"Search" = "Suche";
|
||||
"Search is a Pro feature." = "Suche ist eine Pro-Funktion.";
|
||||
"Search windows by typing" = "Fenster durch Tippen suchen";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Fenstertitel";
|
||||
"Window title contains" = "Fenstertitel enthält";
|
||||
"You can’t undo this action." = "Diese Aktion kann nicht rückgängig gemacht werden.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Sie haben 14 Tage Zeit, um alle Pro-Funktionen auszuprobieren. Danach treten die Pro-Funktionen zurück und der Kern-Fensterumschalter funktioniert weiterhin genau wie zuvor.\n\nAltTab bleibt kostenlos und Open-Source. Pro ist ein optionaler einmaliger Kauf, der die weitere Entwicklung finanziert.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Sie haben 14 Tage Zeit, um alle Pro-Funktionen auszuprobieren. Danach treten die Pro-Funktionen zurück und der Kern-Fensterumschalter funktioniert weiterhin genau wie zuvor.\n\nCommandTabFree bleibt kostenlos und Open-Source. Pro ist ein optionaler einmaliger Kauf, der die weitere Entwicklung finanziert.";
|
||||
"You may need to disable some conflicting system gestures" = "Möglicherweise müssen Sie einige kollidierende Systemgesten deaktivieren";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Sie sind an Tag 4 von 14 — probieren Sie diese aus, bevor Ihre Testphase endet:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "Sie verwenden v%1$@. v%2$@ ist verfügbar. Der Fehler, den Sie sehen, könnte bereits behoben sein — bitte aktualisieren Sie zuerst und prüfen Sie, bevor Sie ihn melden.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Sie haben %@ verwendet.\nDiese werden am Tag 15 auf die Standardwerte zurückgesetzt.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Sie haben %d Pro-Funktionen verwendet.\nSie werden am Tag 15 auf die Standardwerte zurückgesetzt.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Sie haben AltTab %1$@ Mal verwendet — %2$@ davon nutzten Pro-Funktionen.";
|
||||
"You've used AltTab %@ times." = "Sie haben AltTab %@ Mal verwendet.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Sie haben CommandTabFree %1$@ Mal verwendet — %2$@ davon nutzten Pro-Funktionen.";
|
||||
"You've used CommandTabFree %@ times." = "Sie haben CommandTabFree %@ Mal verwendet.";
|
||||
"Your 14-day Pro trial just ended" = "Ihre 14-tägige Pro-Testphase ist gerade abgelaufen";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Ihr Feedback wird als öffentliches GitHub-Issue eingereicht.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Ihre Lizenz deckt diese Version nicht ab. Upgrade auf Lifetime Pro.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "4-finger Horizontal Swipe";
|
||||
"4-finger Vertical Swipe" = "4-finger Vertical Swipe";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "A debug profile (versions, settings, hardware) is attached to help diagnose the issue.";
|
||||
"A new version of AltTab is available" = "A new version of AltTab is available";
|
||||
"A new version of CommandTabFree is available" = "A new version of CommandTabFree is available";
|
||||
"About %@" = "About %@";
|
||||
"Accessibility" = "Accessibility";
|
||||
"Activate" = "Activate";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "All Spaces";
|
||||
"Allowed" = "Allowed";
|
||||
"Alphabetical Order" = "Alphabetical Order";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab crashed last time you used it. Sending a crash report will help get the issue fixed";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab is running without Screen Recording permissions. %@ won’t show.";
|
||||
"AltTab needs some permissions" = "AltTab needs some permissions";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro adds 4 features beyond the free switcher.";
|
||||
"AltTab Pro is still available" = "AltTab Pro is still available";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab will show if the window is currently minimized or fullscreen with a status icon.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree is running without Screen Recording permissions. %@ won’t show.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree needs some permissions";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro adds 4 features beyond the free switcher.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro is still available";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree will show if the window is currently minimized or fullscreen with a status icon.";
|
||||
"Always" = "Always";
|
||||
"Always send crash reports" = "Always send crash reports";
|
||||
"and press" = "and press";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Fullscreen/Defullscreen window";
|
||||
"General" = "General";
|
||||
"Gesture" = "Gesture";
|
||||
"Get more from AltTab with Pro" = "Get more from AltTab with Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Get more from CommandTabFree with Pro";
|
||||
"Get Pro" = "Get Pro";
|
||||
"Go back" = "Go back";
|
||||
"Grant permission" = "Grant permission";
|
||||
"Group apps" = "Group apps";
|
||||
"Group tabs" = "Group tabs";
|
||||
"Help improve AltTab" = "Help improve AltTab";
|
||||
"Help improve CommandTabFree" = "Help improve CommandTabFree";
|
||||
"Hide" = "Hide";
|
||||
"Hide colored circles on mouse hover" = "Hide colored circles on mouse hover";
|
||||
"Hide Space number labels" = "Hide Space number labels";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Large";
|
||||
"Later" = "Later";
|
||||
"License doesn't cover this version" = "License doesn't cover this version";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "License will remain valid and usable to activate AltTab on any machine";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "License will remain valid and usable to activate CommandTabFree on any machine";
|
||||
"Light" = "Light";
|
||||
"Lock search" = "Lock search";
|
||||
"Machine %@" = "Machine %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@.";
|
||||
"Manage activations, view receipts, etc" = "Manage activations, view receipts, etc";
|
||||
"Maybe later" = "Maybe later";
|
||||
"Medium" = "Medium";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Overridden in Shortcut:";
|
||||
"Paste your license key:" = "Paste your license key:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Pick a machine to deactivate so you can activate here.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Please drag AltTab into the Applications folder manually.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Please drag CommandTabFree into the Applications folder manually.";
|
||||
"Please fill in the form" = "Please fill in the form";
|
||||
"Preview selected window" = "Preview selected window";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Screen including menu bar";
|
||||
"Screen including mouse" = "Screen including mouse";
|
||||
"Screen Recording" = "Screen Recording";
|
||||
"Screen showing AltTab" = "Screen showing AltTab";
|
||||
"Screen showing CommandTabFree" = "Screen showing CommandTabFree";
|
||||
"Search" = "Search";
|
||||
"Search is a Pro feature." = "Search is a Pro feature.";
|
||||
"Search windows by typing" = "Search windows by typing";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Window Title";
|
||||
"Window title contains" = "Window title contains";
|
||||
"You can’t undo this action." = "You can’t undo this action.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development.";
|
||||
"You may need to disable some conflicting system gestures" = "You may need to disable some conflicting system gestures";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "You're on day 4 of 14 — try these before your trial ends:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "You've been using %@.\nThese will switch back to defaults on Day 15.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "You've been using %d Pro features.\nThey'll switch back to defaults on Day 15.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "You've used AltTab %@ times — %@ of those used Pro features.";
|
||||
"You've used AltTab %@ times." = "You've used AltTab %@ times.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "You've used CommandTabFree %@ times — %@ of those used Pro features.";
|
||||
"You've used CommandTabFree %@ times." = "You've used CommandTabFree %@ times.";
|
||||
"Your 14-day Pro trial just ended" = "Your 14-day Pro trial just ended";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Your feedback will be submitted as a public GitHub issue.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Your license doesn't cover this version. Upgrade to Lifetime Pro.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Deslizamiento horizontal con 4 dedos";
|
||||
"4-finger Vertical Swipe" = "Deslizamiento vertical con 4 dedos";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "Se adjunta un perfil de depuración (versiones, ajustes, hardware) para ayudar a diagnosticar el problema.";
|
||||
"A new version of AltTab is available" = "Hay una nueva versión de AltTab disponible";
|
||||
"A new version of CommandTabFree is available" = "Hay una nueva versión de CommandTabFree disponible";
|
||||
"About %@" = "Acerca de %@";
|
||||
"Accessibility" = "Accesibilidad";
|
||||
"Activate" = "Activar";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Todos los Espacios";
|
||||
"Allowed" = "Permitido";
|
||||
"Alphabetical Order" = "Orden alfabético";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab se cerró inesperadamente la última vez que lo usaste. Enviar un informe de problema ayudará a solucionarlo";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab es un selector de ventanas gratuito y de código abierto para macOS. Las funciones Pro están disponibles con una prueba gratuita de 14 días.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab se está ejecutando sin permisos de grabación de pantalla. %@ no se mostrarán.";
|
||||
"AltTab needs some permissions" = "AltTab necesita algunos permisos";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro añade 4 funciones más allá del selector gratuito.";
|
||||
"AltTab Pro is still available" = "AltTab Pro sigue disponible";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab indica con un icono de estado si la ventana está minimizada o en pantalla completa.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree se cerró inesperadamente la última vez que lo usaste. Enviar un informe de problema ayudará a solucionarlo";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree es un selector de ventanas gratuito y de código abierto para macOS. Las funciones Pro están disponibles con una prueba gratuita de 14 días.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree se está ejecutando sin permisos de grabación de pantalla. %@ no se mostrarán.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree necesita algunos permisos";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro añade 4 funciones más allá del selector gratuito.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro sigue disponible";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree indica con un icono de estado si la ventana está minimizada o en pantalla completa.";
|
||||
"Always" = "Siempre";
|
||||
"Always send crash reports" = "Enviar siempre los informes de problema";
|
||||
"and press" = "y pulsa";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Activar/desactivar pantalla completa de la ventana";
|
||||
"General" = "General";
|
||||
"Gesture" = "Gesto";
|
||||
"Get more from AltTab with Pro" = "Obtén más de AltTab con Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Obtén más de CommandTabFree con Pro";
|
||||
"Get Pro" = "Obtener Pro";
|
||||
"Go back" = "Volver";
|
||||
"Grant permission" = "Conceder permiso";
|
||||
"Group apps" = "Agrupar apps";
|
||||
"Group tabs" = "Agrupar pestañas";
|
||||
"Help improve AltTab" = "Ayuda a mejorar AltTab";
|
||||
"Help improve CommandTabFree" = "Ayuda a mejorar CommandTabFree";
|
||||
"Hide" = "Ocultar";
|
||||
"Hide colored circles on mouse hover" = "Ocultar los círculos de colores al pasar el ratón";
|
||||
"Hide Space number labels" = "Ocultar las etiquetas de número de Espacio";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Grande";
|
||||
"Later" = "Más tarde";
|
||||
"License doesn't cover this version" = "La licencia no cubre esta versión";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "La licencia seguirá siendo válida y se podrá usar para activar AltTab en cualquier equipo";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "La licencia seguirá siendo válida y se podrá usar para activar CommandTabFree en cualquier equipo";
|
||||
"Light" = "Claro";
|
||||
"Lock search" = "Bloquear búsqueda";
|
||||
"Machine %@" = "Equipo %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserva ⌘⌥⎋, ⌘⌥⇧⎋ y ⌘⌥⇧⌃⎋ para Forzar salida y no se pueden reasignar. AltTab no puede usarlas.\n\nTu cambio asignaría una de estas a: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserva ⌘⌥⎋, ⌘⌥⇧⎋ y ⌘⌥⇧⌃⎋ para Forzar salida y no se pueden reasignar. CommandTabFree no puede usarlas.\n\nTu cambio asignaría una de estas a: %@.";
|
||||
"Manage activations, view receipts, etc" = "Gestionar activaciones, ver recibos, etc.";
|
||||
"Maybe later" = "Quizás más tarde";
|
||||
"Medium" = "Medio";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Anulado en el atajo:";
|
||||
"Paste your license key:" = "Pega tu clave de licencia:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Elige un equipo para desactivar y poder activar aquí.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Arrastra AltTab a la carpeta Aplicaciones manualmente.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Arrastra CommandTabFree a la carpeta Aplicaciones manualmente.";
|
||||
"Please fill in the form" = "Rellena el formulario";
|
||||
"Preview selected window" = "Previsualizar la ventana seleccionada";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Pantalla con la barra de menús";
|
||||
"Screen including mouse" = "Pantalla con el ratón";
|
||||
"Screen Recording" = "Grabación de pantalla";
|
||||
"Screen showing AltTab" = "Pantalla que muestra AltTab";
|
||||
"Screen showing CommandTabFree" = "Pantalla que muestra CommandTabFree";
|
||||
"Search" = "Buscar";
|
||||
"Search is a Pro feature." = "La búsqueda es una función Pro.";
|
||||
"Search windows by typing" = "Buscar ventanas escribiendo";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Título de la ventana";
|
||||
"Window title contains" = "El título de la ventana contiene";
|
||||
"You can’t undo this action." = "No puedes deshacer esta acción.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Tienes 14 días para probar todas las funciones Pro. Después, las funciones Pro se retirarán y el selector de ventanas básico seguirá funcionando exactamente igual que antes.\n\nAltTab se mantiene gratuito y de código abierto. Pro es una compra única opcional que financia el desarrollo continuo.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Tienes 14 días para probar todas las funciones Pro. Después, las funciones Pro se retirarán y el selector de ventanas básico seguirá funcionando exactamente igual que antes.\n\nCommandTabFree se mantiene gratuito y de código abierto. Pro es una compra única opcional que financia el desarrollo continuo.";
|
||||
"You may need to disable some conflicting system gestures" = "Puede que tengas que desactivar algunos gestos del sistema en conflicto";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Vas por el día 4 de 14 — pruébalas antes de que termine tu prueba:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "Estás usando la v%1$@. Está disponible la v%2$@. El error que estás viendo puede que ya esté corregido — actualiza primero y compruébalo antes de informar.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Has estado usando %@.\nVolverán a los valores predeterminados el día 15.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Has estado usando %d funciones Pro.\nVolverán a los valores predeterminados el día 15.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Has usado AltTab %1$@ veces — %2$@ de esas veces con funciones Pro.";
|
||||
"You've used AltTab %@ times." = "Has usado AltTab %@ veces.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Has usado CommandTabFree %1$@ veces — %2$@ de esas veces con funciones Pro.";
|
||||
"You've used CommandTabFree %@ times." = "Has usado CommandTabFree %@ veces.";
|
||||
"Your 14-day Pro trial just ended" = "Tu prueba Pro de 14 días acaba de terminar";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Tus comentarios se enviarán como una incidencia pública en GitHub.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Tu licencia no cubre esta versión. Actualiza a Pro Lifetime.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Balayage horizontal à 4 doigts";
|
||||
"4-finger Vertical Swipe" = "Balayage vertical à 4 doigts";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "Un profil de débogage (versions, réglages, matériel) est joint pour aider à diagnostiquer le problème.";
|
||||
"A new version of AltTab is available" = "Une nouvelle version d'AltTab est disponible";
|
||||
"A new version of CommandTabFree is available" = "Une nouvelle version d'CommandTabFree est disponible";
|
||||
"About %@" = "À propos de %@";
|
||||
"Accessibility" = "Accessibilité";
|
||||
"Activate" = "Activer";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Tous les Bureaux";
|
||||
"Allowed" = "Autorisé";
|
||||
"Alphabetical Order" = "Ordre alphabétique";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab a planté lors de votre dernière utilisation. Envoyer un rapport de plantage aidera à résoudre le problème";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab est un sélecteur de fenêtres gratuit et open source pour macOS. Les fonctionnalités Pro sont disponibles avec un essai gratuit de 14 jours.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab fonctionne sans l'autorisation d'enregistrement de l'écran. %@ ne s'afficheront pas.";
|
||||
"AltTab needs some permissions" = "AltTab a besoin de certaines autorisations";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro ajoute 4 fonctionnalités au sélecteur gratuit.";
|
||||
"AltTab Pro is still available" = "AltTab Pro est toujours disponible";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab indique avec une icône d'état si la fenêtre est réduite ou en plein écran.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree a planté lors de votre dernière utilisation. Envoyer un rapport de plantage aidera à résoudre le problème";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree est un sélecteur de fenêtres gratuit et open source pour macOS. Les fonctionnalités Pro sont disponibles avec un essai gratuit de 14 jours.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree fonctionne sans l'autorisation d'enregistrement de l'écran. %@ ne s'afficheront pas.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree a besoin de certaines autorisations";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro ajoute 4 fonctionnalités au sélecteur gratuit.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro est toujours disponible";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree indique avec une icône d'état si la fenêtre est réduite ou en plein écran.";
|
||||
"Always" = "Toujours";
|
||||
"Always send crash reports" = "Toujours envoyer les rapports de plantage";
|
||||
"and press" = "et appuyer sur";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Mettre/Quitter le plein écran";
|
||||
"General" = "Général";
|
||||
"Gesture" = "Geste";
|
||||
"Get more from AltTab with Pro" = "Profitez davantage d'AltTab avec Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Profitez davantage d'CommandTabFree avec Pro";
|
||||
"Get Pro" = "Obtenir Pro";
|
||||
"Go back" = "Retour";
|
||||
"Grant permission" = "Accorder l'autorisation";
|
||||
"Group apps" = "Grouper les apps";
|
||||
"Group tabs" = "Grouper les onglets";
|
||||
"Help improve AltTab" = "Aidez à améliorer AltTab";
|
||||
"Help improve CommandTabFree" = "Aidez à améliorer CommandTabFree";
|
||||
"Hide" = "Masquer";
|
||||
"Hide colored circles on mouse hover" = "Masquer les cercles colorés au survol de la souris";
|
||||
"Hide Space number labels" = "Masquer les numéros de Bureau";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Grand";
|
||||
"Later" = "Plus tard";
|
||||
"License doesn't cover this version" = "La licence ne couvre pas cette version";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "La licence restera valide et utilisable pour activer AltTab sur n'importe quelle machine";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "La licence restera valide et utilisable pour activer CommandTabFree sur n'importe quelle machine";
|
||||
"Light" = "Clair";
|
||||
"Lock search" = "Verrouiller la recherche";
|
||||
"Machine %@" = "Machine %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS réserve ⌘⌥⎋, ⌘⌥⇧⎋ et ⌘⌥⇧⌃⎋ pour Forcer à quitter et ils ne peuvent pas être réaffectés. AltTab ne peut pas les utiliser.\n\nVotre modification en affecterait un à : %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS réserve ⌘⌥⎋, ⌘⌥⇧⎋ et ⌘⌥⇧⌃⎋ pour Forcer à quitter et ils ne peuvent pas être réaffectés. CommandTabFree ne peut pas les utiliser.\n\nVotre modification en affecterait un à : %@.";
|
||||
"Manage activations, view receipts, etc" = "Gérer les activations, consulter les reçus, etc.";
|
||||
"Maybe later" = "Peut-être plus tard";
|
||||
"Medium" = "Moyen";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Remplacé dans le raccourci :";
|
||||
"Paste your license key:" = "Collez votre clé de licence :";
|
||||
"Pick a machine to deactivate so you can activate here." = "Choisissez une machine à désactiver pour pouvoir activer ici.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Veuillez glisser AltTab manuellement dans le dossier Applications.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Veuillez glisser CommandTabFree manuellement dans le dossier Applications.";
|
||||
"Please fill in the form" = "Veuillez remplir le formulaire";
|
||||
"Preview selected window" = "Aperçu de la fenêtre sélectionnée";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Écran avec la barre des menus";
|
||||
"Screen including mouse" = "Écran avec la souris";
|
||||
"Screen Recording" = "Enregistrement de l'écran";
|
||||
"Screen showing AltTab" = "Écran affichant AltTab";
|
||||
"Screen showing CommandTabFree" = "Écran affichant CommandTabFree";
|
||||
"Search" = "Recherche";
|
||||
"Search is a Pro feature." = "La recherche est une fonctionnalité Pro.";
|
||||
"Search windows by typing" = "Rechercher des fenêtres en saisissant";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Titre de fenêtre";
|
||||
"Window title contains" = "Le titre de la fenêtre contient";
|
||||
"You can’t undo this action." = "Vous ne pouvez pas annuler cette action.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Vous avez 14 jours pour essayer toutes les fonctionnalités Pro. Ensuite, les fonctionnalités Pro seront désactivées et le sélecteur de fenêtres principal continuera de fonctionner exactement comme avant.\n\nAltTab reste gratuit et open source. Pro est un achat unique facultatif qui finance le développement continu.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Vous avez 14 jours pour essayer toutes les fonctionnalités Pro. Ensuite, les fonctionnalités Pro seront désactivées et le sélecteur de fenêtres principal continuera de fonctionner exactement comme avant.\n\nCommandTabFree reste gratuit et open source. Pro est un achat unique facultatif qui finance le développement continu.";
|
||||
"You may need to disable some conflicting system gestures" = "Vous devrez peut-être désactiver certains gestes système conflictuels";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Vous êtes au 4ᵉ jour sur 14 — essayez ceci avant la fin de votre essai :";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "Vous utilisez la v%1$@. La v%2$@ est disponible. Le bug que vous rencontrez est peut-être déjà corrigé — mettez à jour d'abord et vérifiez avant de signaler.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Vous utilisez %@.\nElles reviendront aux valeurs par défaut au 15ᵉ jour.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Vous utilisez %d fonctionnalités Pro.\nElles reviendront aux valeurs par défaut au 15ᵉ jour.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Vous avez utilisé AltTab %1$@ fois — %2$@ de ces utilisations comprenaient des fonctionnalités Pro.";
|
||||
"You've used AltTab %@ times." = "Vous avez utilisé AltTab %@ fois.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Vous avez utilisé CommandTabFree %1$@ fois — %2$@ de ces utilisations comprenaient des fonctionnalités Pro.";
|
||||
"You've used CommandTabFree %@ times." = "Vous avez utilisé CommandTabFree %@ fois.";
|
||||
"Your 14-day Pro trial just ended" = "Votre essai Pro de 14 jours vient de se terminer";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Votre commentaire sera envoyé sous forme de ticket GitHub public.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Votre licence ne couvre pas cette version. Passez à Pro Lifetime.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "החלקה אופקית ב-4 אצבעות";
|
||||
"4-finger Vertical Swipe" = "החלקה אנכית ב-4 אצבעות";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "פרופיל איתור באגים (גרסאות, הגדרות, חומרה) מצורף כדי לסייע באבחון הבעיה.";
|
||||
"A new version of AltTab is available" = "גרסה חדשה של AltTab זמינה";
|
||||
"A new version of CommandTabFree is available" = "גרסה חדשה של CommandTabFree זמינה";
|
||||
"About %@" = "אודות %@";
|
||||
"Accessibility" = "נגישות";
|
||||
"Activate" = "הפעל";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "כל המרחבים";
|
||||
"Allowed" = "מותר";
|
||||
"Alphabetical Order" = "סדר אלפביתי";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab קרס בפעם האחרונה שהשתמשת בו. שליחת דיווח קריסה תסייע לתקן את הבעיה";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab הוא מחליף חלונות חינמי בקוד פתוח עבור macOS. תכונות Pro זמינות עם תקופת ניסיון חינמית של 14 יום.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab פועל ללא הרשאות הקלטת מסך. %@ לא יוצגו.";
|
||||
"AltTab needs some permissions" = "AltTab זקוק להרשאות מסוימות";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro מוסיף 4 תכונות מעבר למחליף החינמי.";
|
||||
"AltTab Pro is still available" = "AltTab Pro עדיין זמין";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab יציג באמצעות סמל מצב אם החלון ממוזער או במסך מלא.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree קרס בפעם האחרונה שהשתמשת בו. שליחת דיווח קריסה תסייע לתקן את הבעיה";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree הוא מחליף חלונות חינמי בקוד פתוח עבור macOS. תכונות Pro זמינות עם תקופת ניסיון חינמית של 14 יום.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree פועל ללא הרשאות הקלטת מסך. %@ לא יוצגו.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree זקוק להרשאות מסוימות";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro מוסיף 4 תכונות מעבר למחליף החינמי.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro עדיין זמין";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree יציג באמצעות סמל מצב אם החלון ממוזער או במסך מלא.";
|
||||
"Always" = "תמיד";
|
||||
"Always send crash reports" = "שלח תמיד דיווחי קריסה";
|
||||
"and press" = "ולחץ על";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "מסך מלא/יציאה ממסך מלא של חלון";
|
||||
"General" = "כללי";
|
||||
"Gesture" = "מחווה";
|
||||
"Get more from AltTab with Pro" = "הפק יותר מ-AltTab עם Pro";
|
||||
"Get more from CommandTabFree with Pro" = "הפק יותר מ-CommandTabFree עם Pro";
|
||||
"Get Pro" = "קבל את Pro";
|
||||
"Go back" = "חזור";
|
||||
"Grant permission" = "הענק הרשאה";
|
||||
"Group apps" = "קבץ יישומים";
|
||||
"Group tabs" = "קבץ לשוניות";
|
||||
"Help improve AltTab" = "סייע בשיפור AltTab";
|
||||
"Help improve CommandTabFree" = "סייע בשיפור CommandTabFree";
|
||||
"Hide" = "הסתר";
|
||||
"Hide colored circles on mouse hover" = "הסתר עיגולים צבעוניים בעת ריחוף עכבר";
|
||||
"Hide Space number labels" = "הסתר תוויות מספרי מרחבים";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "גדול";
|
||||
"Later" = "מאוחר יותר";
|
||||
"License doesn't cover this version" = "הרישיון אינו מכסה גרסה זו";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "הרישיון יישאר בתוקף וניתן יהיה להשתמש בו כדי להפעיל את AltTab בכל מכשיר";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "הרישיון יישאר בתוקף וניתן יהיה להשתמש בו כדי להפעיל את CommandTabFree בכל מכשיר";
|
||||
"Light" = "בהיר";
|
||||
"Lock search" = "נעל חיפוש";
|
||||
"Machine %@" = "מכשיר %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS שומר את ⌘⌥⎋, ⌘⌥⇧⎋ ו-⌘⌥⇧⌃⎋ עבור כפיית יציאה ולא ניתן לבטל את שיוכם. AltTab אינו יכול להשתמש בהם.\n\nהשינוי שלך ישייך אחד מהם ל: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS שומר את ⌘⌥⎋, ⌘⌥⇧⎋ ו-⌘⌥⇧⌃⎋ עבור כפיית יציאה ולא ניתן לבטל את שיוכם. CommandTabFree אינו יכול להשתמש בהם.\n\nהשינוי שלך ישייך אחד מהם ל: %@.";
|
||||
"Manage activations, view receipts, etc" = "נהל הפעלות, צפה בקבלות וכו׳";
|
||||
"Maybe later" = "אולי מאוחר יותר";
|
||||
"Medium" = "בינוני";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "נדרס בקיצור דרך:";
|
||||
"Paste your license key:" = "הדבק את מפתח הרישיון שלך:";
|
||||
"Pick a machine to deactivate so you can activate here." = "בחר מכשיר לביטול הפעלה כדי שתוכל להפעיל כאן.";
|
||||
"Please drag AltTab into the Applications folder manually." = "גרור את AltTab לתיקיית היישומים באופן ידני.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "גרור את CommandTabFree לתיקיית היישומים באופן ידני.";
|
||||
"Please fill in the form" = "מלא את הטופס";
|
||||
"Preview selected window" = "תצוגה מקדימה של החלון הנבחר";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "מסך הכולל את שורת התפריטים";
|
||||
"Screen including mouse" = "מסך הכולל את העכבר";
|
||||
"Screen Recording" = "הקלטת מסך";
|
||||
"Screen showing AltTab" = "מסך המציג את AltTab";
|
||||
"Screen showing CommandTabFree" = "מסך המציג את CommandTabFree";
|
||||
"Search" = "חיפוש";
|
||||
"Search is a Pro feature." = "חיפוש הוא תכונת Pro.";
|
||||
"Search windows by typing" = "חפש חלונות בהקלדה";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "כותרת חלון";
|
||||
"Window title contains" = "כותרת החלון מכילה";
|
||||
"You can’t undo this action." = "לא ניתן לבטל פעולה זו.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "יש לך 14 ימים לנסות את כל תכונות Pro. לאחר מכן, תכונות Pro ייסוגו ומחליף החלונות הבסיסי ימשיך לפעול בדיוק כמו קודם.\n\nAltTab נשאר חינמי ובקוד פתוח. Pro הוא רכישה חד-פעמית אופציונלית המממנת את המשך הפיתוח.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "יש לך 14 ימים לנסות את כל תכונות Pro. לאחר מכן, תכונות Pro ייסוגו ומחליף החלונות הבסיסי ימשיך לפעול בדיוק כמו קודם.\n\nCommandTabFree נשאר חינמי ובקוד פתוח. Pro הוא רכישה חד-פעמית אופציונלית המממנת את המשך הפיתוח.";
|
||||
"You may need to disable some conflicting system gestures" = "ייתכן שתצטרך להשבית מחוות מערכת מתנגשות מסוימות";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "אתה ביום 4 מתוך 14 — נסה את אלה לפני שתקופת הניסיון שלך מסתיימת:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "אתה מריץ את v%1$@. v%2$@ זמינה. ייתכן שהבאג שאתה רואה כבר תוקן — עדכן תחילה ובדוק לפני הדיווח.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "השתמשת ב-%@.\nאלה יחזרו לברירות המחדל ביום ה-15.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "השתמשת ב-%d תכונות Pro.\nהן יחזרו לברירות המחדל ביום ה-15.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "השתמשת ב-AltTab %1$@ פעמים — %2$@ מהן השתמשו בתכונות Pro.";
|
||||
"You've used AltTab %@ times." = "השתמשת ב-AltTab %@ פעמים.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "השתמשת ב-CommandTabFree %1$@ פעמים — %2$@ מהן השתמשו בתכונות Pro.";
|
||||
"You've used CommandTabFree %@ times." = "השתמשת ב-CommandTabFree %@ פעמים.";
|
||||
"Your 14-day Pro trial just ended" = "תקופת הניסיון של 14 יום ל-Pro שלך הסתיימה זה עתה";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "המשוב שלך יוגש כ-issue ציבורי ב-GitHub.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "הרישיון שלך אינו מכסה גרסה זו. שדרג ל-Pro Lifetime.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Gesek Horizontal 4 Jari";
|
||||
"4-finger Vertical Swipe" = "Gesek Vertikal 4 Jari";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "Profil debug (versi, pengaturan, perangkat keras) dilampirkan untuk membantu mendiagnosis masalah.";
|
||||
"A new version of AltTab is available" = "Versi baru AltTab tersedia";
|
||||
"A new version of CommandTabFree is available" = "Versi baru CommandTabFree tersedia";
|
||||
"About %@" = "Tentang %@";
|
||||
"Accessibility" = "Aksesibilitas";
|
||||
"Activate" = "Aktifkan";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Semua Ruang";
|
||||
"Allowed" = "Diizinkan";
|
||||
"Alphabetical Order" = "Urutan Abjad";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab mengalami kerusakan saat terakhir kali Anda menggunakannya. Mengirim laporan kerusakan akan membantu memperbaiki masalah";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab adalah pengalih jendela gratis dan sumber terbuka untuk macOS. Fitur Pro tersedia dengan uji coba gratis 14 hari.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab berjalan tanpa izin Perekaman Layar. %@ tidak akan ditampilkan.";
|
||||
"AltTab needs some permissions" = "AltTab memerlukan beberapa izin";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro menambahkan 4 fitur di luar pengalih gratis.";
|
||||
"AltTab Pro is still available" = "AltTab Pro masih tersedia";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab akan menampilkan ikon status jika jendela saat ini diminimalkan atau dalam layar penuh.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree mengalami kerusakan saat terakhir kali Anda menggunakannya. Mengirim laporan kerusakan akan membantu memperbaiki masalah";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree adalah pengalih jendela gratis dan sumber terbuka untuk macOS. Fitur Pro tersedia dengan uji coba gratis 14 hari.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree berjalan tanpa izin Perekaman Layar. %@ tidak akan ditampilkan.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree memerlukan beberapa izin";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro menambahkan 4 fitur di luar pengalih gratis.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro masih tersedia";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree akan menampilkan ikon status jika jendela saat ini diminimalkan atau dalam layar penuh.";
|
||||
"Always" = "Selalu";
|
||||
"Always send crash reports" = "Selalu kirim laporan kerusakan";
|
||||
"and press" = "lalu tekan";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Jendela layar penuh/Keluar dari layar penuh";
|
||||
"General" = "Umum";
|
||||
"Gesture" = "Gestur";
|
||||
"Get more from AltTab with Pro" = "Dapatkan lebih banyak dari AltTab dengan Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Dapatkan lebih banyak dari CommandTabFree dengan Pro";
|
||||
"Get Pro" = "Dapatkan Pro";
|
||||
"Go back" = "Kembali";
|
||||
"Grant permission" = "Berikan izin";
|
||||
"Group apps" = "Kelompokkan aplikasi";
|
||||
"Group tabs" = "Kelompokkan tab";
|
||||
"Help improve AltTab" = "Bantu tingkatkan AltTab";
|
||||
"Help improve CommandTabFree" = "Bantu tingkatkan CommandTabFree";
|
||||
"Hide" = "Sembunyikan";
|
||||
"Hide colored circles on mouse hover" = "Sembunyikan lingkaran berwarna saat kursor diarahkan";
|
||||
"Hide Space number labels" = "Sembunyikan label nomor Ruang";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Besar";
|
||||
"Later" = "Nanti";
|
||||
"License doesn't cover this version" = "Lisensi tidak mencakup versi ini";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "Lisensi akan tetap valid dan dapat digunakan untuk mengaktifkan AltTab pada mesin mana pun";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "Lisensi akan tetap valid dan dapat digunakan untuk mengaktifkan CommandTabFree pada mesin mana pun";
|
||||
"Light" = "Terang";
|
||||
"Lock search" = "Kunci pencarian";
|
||||
"Machine %@" = "Mesin %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS mencadangkan ⌘⌥⎋, ⌘⌥⇧⎋, dan ⌘⌥⇧⌃⎋ untuk Paksa Keluar dan tidak dapat dilepaskan. AltTab tidak dapat menggunakannya.\n\nPerubahan Anda akan menetapkan salah satunya ke: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS mencadangkan ⌘⌥⎋, ⌘⌥⇧⎋, dan ⌘⌥⇧⌃⎋ untuk Paksa Keluar dan tidak dapat dilepaskan. CommandTabFree tidak dapat menggunakannya.\n\nPerubahan Anda akan menetapkan salah satunya ke: %@.";
|
||||
"Manage activations, view receipts, etc" = "Kelola aktivasi, lihat tanda terima, dll";
|
||||
"Maybe later" = "Mungkin nanti";
|
||||
"Medium" = "Sedang";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Diganti dalam Pintasan:";
|
||||
"Paste your license key:" = "Tempel kunci lisensi Anda:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Pilih mesin untuk dinonaktifkan agar Anda dapat mengaktifkan di sini.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Seret AltTab ke folder Aplikasi secara manual.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Seret CommandTabFree ke folder Aplikasi secara manual.";
|
||||
"Please fill in the form" = "Isi formulir";
|
||||
"Preview selected window" = "Pratinjau jendela terpilih";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Layar termasuk bilah menu";
|
||||
"Screen including mouse" = "Layar termasuk tetikus";
|
||||
"Screen Recording" = "Perekaman Layar";
|
||||
"Screen showing AltTab" = "Layar yang menampilkan AltTab";
|
||||
"Screen showing CommandTabFree" = "Layar yang menampilkan CommandTabFree";
|
||||
"Search" = "Cari";
|
||||
"Search is a Pro feature." = "Pencarian adalah fitur Pro.";
|
||||
"Search windows by typing" = "Cari jendela dengan mengetik";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Judul Jendela";
|
||||
"Window title contains" = "Judul jendela mengandung";
|
||||
"You can’t undo this action." = "Anda tidak dapat membatalkan tindakan ini.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Anda memiliki 14 hari untuk mencoba semua fitur Pro. Setelah itu, fitur Pro akan dinonaktifkan dan pengalih jendela inti akan terus berfungsi persis seperti sebelumnya.\n\nAltTab tetap gratis dan sumber terbuka. Pro adalah pembelian satu kali opsional yang mendanai pengembangan berkelanjutan.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Anda memiliki 14 hari untuk mencoba semua fitur Pro. Setelah itu, fitur Pro akan dinonaktifkan dan pengalih jendela inti akan terus berfungsi persis seperti sebelumnya.\n\nCommandTabFree tetap gratis dan sumber terbuka. Pro adalah pembelian satu kali opsional yang mendanai pengembangan berkelanjutan.";
|
||||
"You may need to disable some conflicting system gestures" = "Anda mungkin perlu menonaktifkan beberapa gestur sistem yang bertentangan";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Anda berada di hari ke-4 dari 14 — coba ini sebelum uji coba Anda berakhir:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "Anda menjalankan v%1$@. v%2$@ tersedia. Bug yang Anda lihat mungkin sudah diperbaiki — harap perbarui dulu dan periksa sebelum melaporkan.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Anda telah menggunakan %@.\nIni akan kembali ke bawaan pada Hari ke-15.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Anda telah menggunakan %d fitur Pro.\nIni akan kembali ke bawaan pada Hari ke-15.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Anda telah menggunakan AltTab %1$@ kali — %2$@ di antaranya menggunakan fitur Pro.";
|
||||
"You've used AltTab %@ times." = "Anda telah menggunakan AltTab %@ kali.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Anda telah menggunakan CommandTabFree %1$@ kali — %2$@ di antaranya menggunakan fitur Pro.";
|
||||
"You've used CommandTabFree %@ times." = "Anda telah menggunakan CommandTabFree %@ kali.";
|
||||
"Your 14-day Pro trial just ended" = "Uji coba Pro 14 hari Anda baru saja berakhir";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Masukan Anda akan dikirim sebagai isu GitHub publik.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Lisensi Anda tidak mencakup versi ini. Tingkatkan ke Lifetime Pro.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Scorrimento orizzontale a 4 dita";
|
||||
"4-finger Vertical Swipe" = "Scorrimento verticale a 4 dita";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "È allegato un profilo di debug (versioni, impostazioni, hardware) per aiutare a diagnosticare il problema.";
|
||||
"A new version of AltTab is available" = "È disponibile una nuova versione di AltTab";
|
||||
"A new version of CommandTabFree is available" = "È disponibile una nuova versione di CommandTabFree";
|
||||
"About %@" = "Informazioni su %@";
|
||||
"Accessibility" = "Accessibilità";
|
||||
"Activate" = "Attiva";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Tutti gli Spazi";
|
||||
"Allowed" = "Consentito";
|
||||
"Alphabetical Order" = "Ordine alfabetico";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab si è arrestato in modo anomalo l'ultima volta che l'hai usato. L'invio di un resoconto di arresti anomali aiuterà a risolvere il problema";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab è un selettore di finestre gratuito e open source per macOS. Le funzionalità Pro sono disponibili con una prova gratuita di 14 giorni.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab è in esecuzione senza i permessi di Registrazione schermo. %@ non verranno mostrate.";
|
||||
"AltTab needs some permissions" = "AltTab richiede alcuni permessi";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro aggiunge 4 funzionalità oltre al selettore gratuito.";
|
||||
"AltTab Pro is still available" = "AltTab Pro è ancora disponibile";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab indicherà con un'icona di stato se la finestra è ridotta a icona o a tutto schermo.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree si è arrestato in modo anomalo l'ultima volta che l'hai usato. L'invio di un resoconto di arresti anomali aiuterà a risolvere il problema";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree è un selettore di finestre gratuito e open source per macOS. Le funzionalità Pro sono disponibili con una prova gratuita di 14 giorni.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree è in esecuzione senza i permessi di Registrazione schermo. %@ non verranno mostrate.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree richiede alcuni permessi";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro aggiunge 4 funzionalità oltre al selettore gratuito.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro è ancora disponibile";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree indicherà con un'icona di stato se la finestra è ridotta a icona o a tutto schermo.";
|
||||
"Always" = "Sempre";
|
||||
"Always send crash reports" = "Invia sempre i resoconti di arresti anomali";
|
||||
"and press" = "e premi";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Attiva/disattiva schermo intero";
|
||||
"General" = "Generali";
|
||||
"Gesture" = "Gesto";
|
||||
"Get more from AltTab with Pro" = "Ottieni di più da AltTab con Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Ottieni di più da CommandTabFree con Pro";
|
||||
"Get Pro" = "Ottieni Pro";
|
||||
"Go back" = "Indietro";
|
||||
"Grant permission" = "Concedi permesso";
|
||||
"Group apps" = "Raggruppa app";
|
||||
"Group tabs" = "Raggruppa schede";
|
||||
"Help improve AltTab" = "Aiuta a migliorare AltTab";
|
||||
"Help improve CommandTabFree" = "Aiuta a migliorare CommandTabFree";
|
||||
"Hide" = "Nascondi";
|
||||
"Hide colored circles on mouse hover" = "Nascondi i cerchi colorati al passaggio del mouse";
|
||||
"Hide Space number labels" = "Nascondi le etichette numeriche degli Spazi";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Grande";
|
||||
"Later" = "Più tardi";
|
||||
"License doesn't cover this version" = "La licenza non copre questa versione";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "La licenza rimarrà valida e utilizzabile per attivare AltTab su qualsiasi dispositivo";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "La licenza rimarrà valida e utilizzabile per attivare CommandTabFree su qualsiasi dispositivo";
|
||||
"Light" = "Chiaro";
|
||||
"Lock search" = "Blocca ricerca";
|
||||
"Machine %@" = "Dispositivo %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS riserva ⌘⌥⎋, ⌘⌥⇧⎋ e ⌘⌥⇧⌃⎋ per Uscita forzata e non possono essere disassegnati. AltTab non può usarli.\n\nLa tua modifica assegnerebbe uno di essi a: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS riserva ⌘⌥⎋, ⌘⌥⇧⎋ e ⌘⌥⇧⌃⎋ per Uscita forzata e non possono essere disassegnati. CommandTabFree non può usarli.\n\nLa tua modifica assegnerebbe uno di essi a: %@.";
|
||||
"Manage activations, view receipts, etc" = "Gestisci attivazioni, visualizza ricevute, ecc.";
|
||||
"Maybe later" = "Forse più tardi";
|
||||
"Medium" = "Medio";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Sovrascritto nella scorciatoia:";
|
||||
"Paste your license key:" = "Incolla la tua chiave di licenza:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Scegli un dispositivo da disattivare per poter attivare qui.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Trascina AltTab manualmente nella cartella Applicazioni.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Trascina CommandTabFree manualmente nella cartella Applicazioni.";
|
||||
"Please fill in the form" = "Compila il modulo";
|
||||
"Preview selected window" = "Anteprima della finestra selezionata";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Schermo con la barra dei menu";
|
||||
"Screen including mouse" = "Schermo con il mouse";
|
||||
"Screen Recording" = "Registrazione schermo";
|
||||
"Screen showing AltTab" = "Schermo che mostra AltTab";
|
||||
"Screen showing CommandTabFree" = "Schermo che mostra CommandTabFree";
|
||||
"Search" = "Cerca";
|
||||
"Search is a Pro feature." = "La ricerca è una funzionalità Pro.";
|
||||
"Search windows by typing" = "Cerca finestre digitando";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Titolo finestra";
|
||||
"Window title contains" = "Il titolo della finestra contiene";
|
||||
"You can’t undo this action." = "Non puoi annullare questa azione.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Hai 14 giorni per provare tutte le funzionalità Pro. Dopodiché, le funzionalità Pro si disattiveranno e il selettore di finestre principale continuerà a funzionare esattamente come prima.\n\nAltTab rimane gratuito e open source. Pro è un acquisto unico facoltativo che finanzia lo sviluppo continuo.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Hai 14 giorni per provare tutte le funzionalità Pro. Dopodiché, le funzionalità Pro si disattiveranno e il selettore di finestre principale continuerà a funzionare esattamente come prima.\n\nCommandTabFree rimane gratuito e open source. Pro è un acquisto unico facoltativo che finanzia lo sviluppo continuo.";
|
||||
"You may need to disable some conflicting system gestures" = "Potrebbe essere necessario disabilitare alcuni gesti di sistema in conflitto";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Sei al giorno 4 di 14 — provale prima che la tua prova finisca:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "Stai usando la v%1$@. La v%2$@ è disponibile. Il bug che vedi potrebbe essere già stato risolto — aggiorna prima e controlla prima di segnalarlo.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Hai usato %@.\nTorneranno ai valori predefiniti al giorno 15.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Hai usato %d funzionalità Pro.\nTorneranno ai valori predefiniti al giorno 15.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Hai usato AltTab %1$@ volte — %2$@ di queste hanno usato funzionalità Pro.";
|
||||
"You've used AltTab %@ times." = "Hai usato AltTab %@ volte.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Hai usato CommandTabFree %1$@ volte — %2$@ di queste hanno usato funzionalità Pro.";
|
||||
"You've used CommandTabFree %@ times." = "Hai usato CommandTabFree %@ volte.";
|
||||
"Your 14-day Pro trial just ended" = "La tua prova Pro di 14 giorni è appena terminata";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Il tuo feedback sarà inviato come issue pubblica su GitHub.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "La tua licenza non copre questa versione. Passa a Pro Lifetime.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "4 本指の水平スワイプ";
|
||||
"4-finger Vertical Swipe" = "4 本指の垂直スワイプ";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "問題の診断に役立てるため、デバッグ情報(バージョン、設定、ハードウェア)を添付しています。";
|
||||
"A new version of AltTab is available" = "AltTab の新しいバージョンが利用可能です";
|
||||
"A new version of CommandTabFree is available" = "CommandTabFree の新しいバージョンが利用可能です";
|
||||
"About %@" = "%@ について";
|
||||
"Accessibility" = "アクセシビリティ";
|
||||
"Activate" = "アクティベート";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "すべての Space";
|
||||
"Allowed" = "許可";
|
||||
"Alphabetical Order" = "アルファベット順";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "前回の使用時に AltTab がクラッシュしました。クラッシュレポートを送信すると問題の修正に役立ちます";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab は macOS 用の無料・オープンソースのウインドウスイッチャーです。Pro 機能は 14 日間の無料トライアルで利用できます。";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab は画面収録の権限なしで動作しています。%@は表示されません。";
|
||||
"AltTab needs some permissions" = "AltTab にはいくつかの権限が必要です";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro は無料版に加えて 4 つの機能を追加します。";
|
||||
"AltTab Pro is still available" = "AltTab Pro は引き続き利用可能です";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "ウインドウが現在最小化またはフルスクリーンの場合、AltTab はステータスアイコンで表示します。";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "前回の使用時に CommandTabFree がクラッシュしました。クラッシュレポートを送信すると問題の修正に役立ちます";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree は macOS 用の無料・オープンソースのウインドウスイッチャーです。Pro 機能は 14 日間の無料トライアルで利用できます。";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree は画面収録の権限なしで動作しています。%@は表示されません。";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree にはいくつかの権限が必要です";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro は無料版に加えて 4 つの機能を追加します。";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro は引き続き利用可能です";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "ウインドウが現在最小化またはフルスクリーンの場合、CommandTabFree はステータスアイコンで表示します。";
|
||||
"Always" = "常に";
|
||||
"Always send crash reports" = "常にクラッシュレポートを送信";
|
||||
"and press" = "を押す";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "ウインドウのフルスクリーン切り替え";
|
||||
"General" = "一般";
|
||||
"Gesture" = "ジェスチャ";
|
||||
"Get more from AltTab with Pro" = "Pro で AltTab をさらに活用";
|
||||
"Get more from CommandTabFree with Pro" = "Pro で CommandTabFree をさらに活用";
|
||||
"Get Pro" = "Pro を入手";
|
||||
"Go back" = "戻る";
|
||||
"Grant permission" = "権限を付与";
|
||||
"Group apps" = "App をグループ化";
|
||||
"Group tabs" = "タブをグループ化";
|
||||
"Help improve AltTab" = "AltTab の改善に協力";
|
||||
"Help improve CommandTabFree" = "CommandTabFree の改善に協力";
|
||||
"Hide" = "非表示";
|
||||
"Hide colored circles on mouse hover" = "マウスホバー時のカラーサークルを非表示";
|
||||
"Hide Space number labels" = "Space 番号ラベルを非表示";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "大";
|
||||
"Later" = "後で";
|
||||
"License doesn't cover this version" = "このバージョンはライセンスの対象外です";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "ライセンスは引き続き有効で、任意のマシンで AltTab をアクティベートできます";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "ライセンスは引き続き有効で、任意のマシンで CommandTabFree をアクティベートできます";
|
||||
"Light" = "ライト";
|
||||
"Lock search" = "検索をロック";
|
||||
"Machine %@" = "マシン %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS は ⌘⌥⎋、⌘⌥⇧⎋、⌘⌥⇧⌃⎋ を強制終了用に予約しており、解除できません。AltTab では使用できません。\n\n変更しようとしている割り当て: %@。";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS は ⌘⌥⎋、⌘⌥⇧⎋、⌘⌥⇧⌃⎋ を強制終了用に予約しており、解除できません。CommandTabFree では使用できません。\n\n変更しようとしている割り当て: %@。";
|
||||
"Manage activations, view receipts, etc" = "アクティベーションの管理、領収書の表示など";
|
||||
"Maybe later" = "後で";
|
||||
"Medium" = "中";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "上書きされているショートカット:";
|
||||
"Paste your license key:" = "ライセンスキーを貼り付け:";
|
||||
"Pick a machine to deactivate so you can activate here." = "ここでアクティベートできるよう、非アクティブ化するマシンを選択してください。";
|
||||
"Please drag AltTab into the Applications folder manually." = "AltTab を手動でアプリケーションフォルダにドラッグしてください。";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "CommandTabFree を手動でアプリケーションフォルダにドラッグしてください。";
|
||||
"Please fill in the form" = "フォームを入力してください";
|
||||
"Preview selected window" = "選択したウインドウをプレビュー";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "メニューバーを含む画面";
|
||||
"Screen including mouse" = "マウスのある画面";
|
||||
"Screen Recording" = "画面収録";
|
||||
"Screen showing AltTab" = "AltTab を表示する画面";
|
||||
"Screen showing CommandTabFree" = "CommandTabFree を表示する画面";
|
||||
"Search" = "検索";
|
||||
"Search is a Pro feature." = "検索は Pro 機能です。";
|
||||
"Search windows by typing" = "入力してウインドウを検索";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "ウインドウタイトル";
|
||||
"Window title contains" = "ウインドウタイトルに含まれる";
|
||||
"You can’t undo this action." = "この操作は取り消せません。";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "14 日間、すべての Pro 機能をお試しいただけます。その後、Pro 機能は無効になり、コアのウインドウスイッチャーは以前と同じように動作し続けます。\n\nAltTab は無料・オープンソースのままです。Pro は継続的な開発を支える任意の買い切り購入です。";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "14 日間、すべての Pro 機能をお試しいただけます。その後、Pro 機能は無効になり、コアのウインドウスイッチャーは以前と同じように動作し続けます。\n\nCommandTabFree は無料・オープンソースのままです。Pro は継続的な開発を支える任意の買い切り購入です。";
|
||||
"You may need to disable some conflicting system gestures" = "競合するシステムのジェスチャを一部無効にする必要があるかもしれません";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "14 日中 4 日目です — トライアル終了前にお試しください:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "現在のバージョンは v%1$@ です。v%2$@ が利用可能です。発生している不具合はすでに修正されている可能性があります — 報告前にまずアップデートしてご確認ください。";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "%@ を使用しています。\nこれらは 15 日目にデフォルトに戻ります。";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "%d 個の Pro 機能を使用しています。\nこれらは 15 日目にデフォルトに戻ります。";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "AltTab を %1$@ 回使用しました — そのうち %2$@ 回は Pro 機能を使用しました。";
|
||||
"You've used AltTab %@ times." = "AltTab を %@ 回使用しました。";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "CommandTabFree を %1$@ 回使用しました — そのうち %2$@ 回は Pro 機能を使用しました。";
|
||||
"You've used CommandTabFree %@ times." = "CommandTabFree を %@ 回使用しました。";
|
||||
"Your 14-day Pro trial just ended" = "14 日間の Pro トライアルが終了しました";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "フィードバックは公開の GitHub Issue として送信されます。";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "このバージョンはライセンスの対象外です。Lifetime Pro にアップグレードしてください。";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "네 손가락 가로 스와이프";
|
||||
"4-finger Vertical Swipe" = "네 손가락 세로 스와이프";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "문제 진단을 돕기 위해 디버그 프로파일(버전, 설정, 하드웨어)이 첨부됩니다.";
|
||||
"A new version of AltTab is available" = "AltTab의 새 버전이 출시되었습니다";
|
||||
"A new version of CommandTabFree is available" = "CommandTabFree의 새 버전이 출시되었습니다";
|
||||
"About %@" = "%@ 정보";
|
||||
"Accessibility" = "손쉬운 사용";
|
||||
"Activate" = "활성화";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "모든 Space";
|
||||
"Allowed" = "허용됨";
|
||||
"Alphabetical Order" = "알파벳 순서";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab이 마지막 사용 시 충돌했습니다. 충돌 보고서를 보내면 문제 해결에 도움이 됩니다";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab은 macOS용 무료 오픈 소스 윈도우 전환기입니다. Pro 기능은 14일 무료 체험으로 이용할 수 있습니다.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab이 화면 녹화 권한 없이 실행 중입니다. %@이(가) 표시되지 않습니다.";
|
||||
"AltTab needs some permissions" = "AltTab에 몇 가지 권한이 필요합니다";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro는 무료 전환기에 4가지 기능을 추가합니다.";
|
||||
"AltTab Pro is still available" = "AltTab Pro는 계속 이용할 수 있습니다";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab이 윈도우의 최소화 또는 전체 화면 상태를 상태 아이콘으로 표시합니다.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree이 마지막 사용 시 충돌했습니다. 충돌 보고서를 보내면 문제 해결에 도움이 됩니다";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree은 macOS용 무료 오픈 소스 윈도우 전환기입니다. Pro 기능은 14일 무료 체험으로 이용할 수 있습니다.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree이 화면 녹화 권한 없이 실행 중입니다. %@이(가) 표시되지 않습니다.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree에 몇 가지 권한이 필요합니다";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro는 무료 전환기에 4가지 기능을 추가합니다.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro는 계속 이용할 수 있습니다";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree이 윈도우의 최소화 또는 전체 화면 상태를 상태 아이콘으로 표시합니다.";
|
||||
"Always" = "항상";
|
||||
"Always send crash reports" = "항상 충돌 보고서 보내기";
|
||||
"and press" = "그리고 누르기";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "윈도우 전체 화면 전환";
|
||||
"General" = "일반";
|
||||
"Gesture" = "제스처";
|
||||
"Get more from AltTab with Pro" = "Pro로 AltTab을 더 활용하기";
|
||||
"Get more from CommandTabFree with Pro" = "Pro로 CommandTabFree을 더 활용하기";
|
||||
"Get Pro" = "Pro 받기";
|
||||
"Go back" = "뒤로 가기";
|
||||
"Grant permission" = "권한 부여";
|
||||
"Group apps" = "앱 그룹화";
|
||||
"Group tabs" = "탭 그룹화";
|
||||
"Help improve AltTab" = "AltTab 개선에 도움 주기";
|
||||
"Help improve CommandTabFree" = "CommandTabFree 개선에 도움 주기";
|
||||
"Hide" = "숨기기";
|
||||
"Hide colored circles on mouse hover" = "마우스 오버 시 색상 원 숨기기";
|
||||
"Hide Space number labels" = "Space 번호 레이블 숨기기";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "크게";
|
||||
"Later" = "나중에";
|
||||
"License doesn't cover this version" = "라이선스가 이 버전을 포함하지 않습니다";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "라이선스는 유효하며 어떤 기기에서도 AltTab을 활성화하는 데 사용할 수 있습니다";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "라이선스는 유효하며 어떤 기기에서도 CommandTabFree을 활성화하는 데 사용할 수 있습니다";
|
||||
"Light" = "라이트";
|
||||
"Lock search" = "검색 잠그기";
|
||||
"Machine %@" = "기기 %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS는 ⌘⌥⎋, ⌘⌥⇧⎋, ⌘⌥⇧⌃⎋를 강제 종료용으로 예약하며 해제할 수 없습니다. AltTab은 이를 사용할 수 없습니다.\n\n변경하면 이 중 하나가 %@에 할당됩니다.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS는 ⌘⌥⎋, ⌘⌥⇧⎋, ⌘⌥⇧⌃⎋를 강제 종료용으로 예약하며 해제할 수 없습니다. CommandTabFree은 이를 사용할 수 없습니다.\n\n변경하면 이 중 하나가 %@에 할당됩니다.";
|
||||
"Manage activations, view receipts, etc" = "활성화 관리, 영수증 보기 등";
|
||||
"Maybe later" = "나중에";
|
||||
"Medium" = "보통";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "단축키에서 재정의됨:";
|
||||
"Paste your license key:" = "라이선스 키 붙여넣기:";
|
||||
"Pick a machine to deactivate so you can activate here." = "여기서 활성화하려면 비활성화할 기기를 선택하세요.";
|
||||
"Please drag AltTab into the Applications folder manually." = "AltTab을 응용 프로그램 폴더로 직접 드래그해 주세요.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "CommandTabFree을 응용 프로그램 폴더로 직접 드래그해 주세요.";
|
||||
"Please fill in the form" = "양식을 작성해 주세요";
|
||||
"Preview selected window" = "선택한 윈도우 미리보기";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "메뉴 막대가 있는 화면";
|
||||
"Screen including mouse" = "마우스가 있는 화면";
|
||||
"Screen Recording" = "화면 녹화";
|
||||
"Screen showing AltTab" = "AltTab이 표시되는 화면";
|
||||
"Screen showing CommandTabFree" = "CommandTabFree이 표시되는 화면";
|
||||
"Search" = "검색";
|
||||
"Search is a Pro feature." = "검색은 Pro 기능입니다.";
|
||||
"Search windows by typing" = "입력하여 윈도우 검색";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "윈도우 제목";
|
||||
"Window title contains" = "윈도우 제목 포함";
|
||||
"You can’t undo this action." = "이 작업은 되돌릴 수 없습니다.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "14일 동안 모든 Pro 기능을 사용해 볼 수 있습니다. 이후에는 Pro 기능이 비활성화되고 핵심 윈도우 전환기는 이전과 똑같이 계속 작동합니다.\n\nAltTab은 무료이며 오픈 소스로 유지됩니다. Pro는 지속적인 개발을 후원하는 선택적 일회성 구매입니다.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "14일 동안 모든 Pro 기능을 사용해 볼 수 있습니다. 이후에는 Pro 기능이 비활성화되고 핵심 윈도우 전환기는 이전과 똑같이 계속 작동합니다.\n\nCommandTabFree은 무료이며 오픈 소스로 유지됩니다. Pro는 지속적인 개발을 후원하는 선택적 일회성 구매입니다.";
|
||||
"You may need to disable some conflicting system gestures" = "충돌하는 일부 시스템 제스처를 비활성화해야 할 수 있습니다";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "14일 중 4일째입니다 — 체험 종료 전에 사용해 보세요:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "현재 v%1$@를 사용 중입니다. v%2$@가 사용 가능합니다. 보고 있는 버그가 이미 수정되었을 수 있습니다 — 신고 전에 먼저 업데이트하고 확인해 주세요.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "%@을(를) 사용 중입니다.\n15일째에 기본값으로 되돌아갑니다.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "%d개의 Pro 기능을 사용 중입니다.\n15일째에 기본값으로 되돌아갑니다.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "AltTab을 %1$@번 사용했습니다 — 그중 %2$@번이 Pro 기능을 사용했습니다.";
|
||||
"You've used AltTab %@ times." = "AltTab을 %@번 사용했습니다.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "CommandTabFree을 %1$@번 사용했습니다 — 그중 %2$@번이 Pro 기능을 사용했습니다.";
|
||||
"You've used CommandTabFree %@ times." = "CommandTabFree을 %@번 사용했습니다.";
|
||||
"Your 14-day Pro trial just ended" = "14일 Pro 체험이 방금 종료되었습니다";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "피드백은 공개 GitHub 이슈로 제출됩니다.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "라이선스가 이 버전을 포함하지 않습니다. Lifetime Pro로 업그레이드하세요.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Horizontale veegbeweging met 4 vingers";
|
||||
"4-finger Vertical Swipe" = "Verticale veegbeweging met 4 vingers";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "Een debugprofiel (versies, instellingen, hardware) is bijgevoegd om het probleem te diagnosticeren.";
|
||||
"A new version of AltTab is available" = "Er is een nieuwe versie van AltTab beschikbaar";
|
||||
"A new version of CommandTabFree is available" = "Er is een nieuwe versie van CommandTabFree beschikbaar";
|
||||
"About %@" = "Over %@";
|
||||
"Accessibility" = "Toegankelijkheid";
|
||||
"Activate" = "Activeren";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Alle Spaces";
|
||||
"Allowed" = "Toegestaan";
|
||||
"Alphabetical Order" = "Alfabetische volgorde";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab is de vorige keer gecrasht. Door een crashrapport te versturen help je het probleem op te lossen";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab is een gratis, open-source vensterwisselaar voor macOS. Pro-functies zijn beschikbaar met een gratis proefperiode van 14 dagen.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab werkt zonder toegang voor Schermopname. %@ worden niet weergegeven.";
|
||||
"AltTab needs some permissions" = "AltTab heeft enkele rechten nodig";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro voegt 4 functies toe aan de gratis wisselaar.";
|
||||
"AltTab Pro is still available" = "AltTab Pro is nog steeds beschikbaar";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab toont met een statussymbool of het venster momenteel geminimaliseerd of in volledig scherm is.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree is de vorige keer gecrasht. Door een crashrapport te versturen help je het probleem op te lossen";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree is een gratis, open-source vensterwisselaar voor macOS. Pro-functies zijn beschikbaar met een gratis proefperiode van 14 dagen.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree werkt zonder toegang voor Schermopname. %@ worden niet weergegeven.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree heeft enkele rechten nodig";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro voegt 4 functies toe aan de gratis wisselaar.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro is nog steeds beschikbaar";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree toont met een statussymbool of het venster momenteel geminimaliseerd of in volledig scherm is.";
|
||||
"Always" = "Altijd";
|
||||
"Always send crash reports" = "Altijd crashrapporten versturen";
|
||||
"and press" = "en druk op";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Venster in/uit volledig scherm";
|
||||
"General" = "Algemeen";
|
||||
"Gesture" = "Gebaar";
|
||||
"Get more from AltTab with Pro" = "Haal meer uit AltTab met Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Haal meer uit CommandTabFree met Pro";
|
||||
"Get Pro" = "Pro nemen";
|
||||
"Go back" = "Terug";
|
||||
"Grant permission" = "Toegang verlenen";
|
||||
"Group apps" = "Apps groeperen";
|
||||
"Group tabs" = "Tabbladen groeperen";
|
||||
"Help improve AltTab" = "Help AltTab verbeteren";
|
||||
"Help improve CommandTabFree" = "Help CommandTabFree verbeteren";
|
||||
"Hide" = "Verberg";
|
||||
"Hide colored circles on mouse hover" = "Gekleurde cirkels verbergen bij aanwijzen met muis";
|
||||
"Hide Space number labels" = "Nummerlabels van Spaces verbergen";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Groot";
|
||||
"Later" = "Later";
|
||||
"License doesn't cover this version" = "Licentie geldt niet voor deze versie";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "De licentie blijft geldig en bruikbaar om AltTab op elk apparaat te activeren";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "De licentie blijft geldig en bruikbaar om CommandTabFree op elk apparaat te activeren";
|
||||
"Light" = "Licht";
|
||||
"Lock search" = "Zoekopdracht vergrendelen";
|
||||
"Machine %@" = "Apparaat %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserveert ⌘⌥⎋, ⌘⌥⇧⎋ en ⌘⌥⇧⌃⎋ voor Geforceerd stoppen en deze kunnen niet worden vrijgegeven. AltTab kan ze niet gebruiken.\n\nJe wijziging zou er een toewijzen aan: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserveert ⌘⌥⎋, ⌘⌥⇧⎋ en ⌘⌥⇧⌃⎋ voor Geforceerd stoppen en deze kunnen niet worden vrijgegeven. CommandTabFree kan ze niet gebruiken.\n\nJe wijziging zou er een toewijzen aan: %@.";
|
||||
"Manage activations, view receipts, etc" = "Activeringen beheren, bonnen bekijken, enz.";
|
||||
"Maybe later" = "Misschien later";
|
||||
"Medium" = "Middelgroot";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Overschreven in Sneltoets:";
|
||||
"Paste your license key:" = "Plak je licentiesleutel:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Kies een apparaat om te deactiveren zodat je hier kunt activeren.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Sleep AltTab handmatig naar de map Programma's.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Sleep CommandTabFree handmatig naar de map Programma's.";
|
||||
"Please fill in the form" = "Vul het formulier in";
|
||||
"Preview selected window" = "Voorvertoning van geselecteerd venster";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Scherm met menubalk";
|
||||
"Screen including mouse" = "Scherm met muis";
|
||||
"Screen Recording" = "Schermopname";
|
||||
"Screen showing AltTab" = "Scherm dat AltTab toont";
|
||||
"Screen showing CommandTabFree" = "Scherm dat CommandTabFree toont";
|
||||
"Search" = "Zoeken";
|
||||
"Search is a Pro feature." = "Zoeken is een Pro-functie.";
|
||||
"Search windows by typing" = "Vensters zoeken door te typen";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Venstertitel";
|
||||
"Window title contains" = "Venstertitel bevat";
|
||||
"You can’t undo this action." = "Je kunt deze actie niet ongedaan maken.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Je hebt 14 dagen om alle Pro-functies te proberen. Daarna treden Pro-functies terug en blijft de kern-vensterwisselaar precies werken zoals voorheen.\n\nAltTab blijft gratis en open-source. Pro is een optionele eenmalige aankoop die de verdere ontwikkeling financiert.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Je hebt 14 dagen om alle Pro-functies te proberen. Daarna treden Pro-functies terug en blijft de kern-vensterwisselaar precies werken zoals voorheen.\n\nCommandTabFree blijft gratis en open-source. Pro is een optionele eenmalige aankoop die de verdere ontwikkeling financiert.";
|
||||
"You may need to disable some conflicting system gestures" = "Mogelijk moet je enkele conflicterende systeemgebaren uitschakelen";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Je bent op dag 4 van 14 — probeer deze voordat je proefperiode eindigt:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "Je gebruikt v%1$@. v%2$@ is beschikbaar. De bug die je ziet is mogelijk al verholpen — werk eerst bij en controleer voordat je rapporteert.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Je hebt %@ gebruikt.\nDeze gaan op dag 15 terug naar de standaardwaarden.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Je hebt %d Pro-functies gebruikt.\nDeze gaan op dag 15 terug naar de standaardwaarden.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Je hebt AltTab %1$@ keer gebruikt — %2$@ daarvan met Pro-functies.";
|
||||
"You've used AltTab %@ times." = "Je hebt AltTab %@ keer gebruikt.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Je hebt CommandTabFree %1$@ keer gebruikt — %2$@ daarvan met Pro-functies.";
|
||||
"You've used CommandTabFree %@ times." = "Je hebt CommandTabFree %@ keer gebruikt.";
|
||||
"Your 14-day Pro trial just ended" = "Je 14-daagse Pro-proefperiode is zojuist afgelopen";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Je feedback wordt ingediend als openbare GitHub-issue.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Je licentie geldt niet voor deze versie. Upgrade naar Lifetime Pro.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Przeciągnięcie 4 palcami w poziomie";
|
||||
"4-finger Vertical Swipe" = "Przeciągnięcie 4 palcami w pionie";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "Załączono profil diagnostyczny (wersje, ustawienia, sprzęt), aby pomóc w diagnozie problemu.";
|
||||
"A new version of AltTab is available" = "Dostępna jest nowa wersja AltTab";
|
||||
"A new version of CommandTabFree is available" = "Dostępna jest nowa wersja CommandTabFree";
|
||||
"About %@" = "%@ — informacje";
|
||||
"Accessibility" = "Dostępność";
|
||||
"Activate" = "Aktywuj";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Wszystkie Przestrzenie";
|
||||
"Allowed" = "Dozwolone";
|
||||
"Alphabetical Order" = "Kolejność alfabetyczna";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab uległ awarii podczas ostatniego użycia. Wysłanie raportu awarii pomoże naprawić problem";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab to darmowy, otwartoźródłowy przełącznik okien dla macOS. Funkcje Pro są dostępne w 14-dniowym bezpłatnym okresie próbnym.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab działa bez uprawnień do Nagrywania ekranu. %@ nie będą wyświetlane.";
|
||||
"AltTab needs some permissions" = "AltTab wymaga uprawnień";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro dodaje 4 funkcje wykraczające poza darmowy przełącznik.";
|
||||
"AltTab Pro is still available" = "AltTab Pro jest nadal dostępny";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab pokaże ikoną stanu, czy okno jest obecnie zminimalizowane lub na pełnym ekranie.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree uległ awarii podczas ostatniego użycia. Wysłanie raportu awarii pomoże naprawić problem";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree to darmowy, otwartoźródłowy przełącznik okien dla macOS. Funkcje Pro są dostępne w 14-dniowym bezpłatnym okresie próbnym.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree działa bez uprawnień do Nagrywania ekranu. %@ nie będą wyświetlane.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree wymaga uprawnień";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro dodaje 4 funkcje wykraczające poza darmowy przełącznik.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro jest nadal dostępny";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree pokaże ikoną stanu, czy okno jest obecnie zminimalizowane lub na pełnym ekranie.";
|
||||
"Always" = "Zawsze";
|
||||
"Always send crash reports" = "Zawsze wysyłaj raporty awarii";
|
||||
"and press" = "i naciśnij";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Pełny ekran / wyjście z pełnego ekranu";
|
||||
"General" = "Ogólne";
|
||||
"Gesture" = "Gest";
|
||||
"Get more from AltTab with Pro" = "Wykorzystaj więcej z AltTab dzięki Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Wykorzystaj więcej z CommandTabFree dzięki Pro";
|
||||
"Get Pro" = "Kup Pro";
|
||||
"Go back" = "Wróć";
|
||||
"Grant permission" = "Przyznaj uprawnienie";
|
||||
"Group apps" = "Grupuj aplikacje";
|
||||
"Group tabs" = "Grupuj karty";
|
||||
"Help improve AltTab" = "Pomóż ulepszyć AltTab";
|
||||
"Help improve CommandTabFree" = "Pomóż ulepszyć CommandTabFree";
|
||||
"Hide" = "Ukryj";
|
||||
"Hide colored circles on mouse hover" = "Ukryj kolorowe kółka przy najechaniu kursorem";
|
||||
"Hide Space number labels" = "Ukryj etykiety numerów Przestrzeni";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Duży";
|
||||
"Later" = "Później";
|
||||
"License doesn't cover this version" = "Licencja nie obejmuje tej wersji";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "Licencja pozostanie ważna i będzie można jej użyć do aktywacji AltTab na dowolnym komputerze";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "Licencja pozostanie ważna i będzie można jej użyć do aktywacji CommandTabFree na dowolnym komputerze";
|
||||
"Light" = "Jasny";
|
||||
"Lock search" = "Zablokuj wyszukiwanie";
|
||||
"Machine %@" = "Komputer %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS rezerwuje ⌘⌥⎋, ⌘⌥⇧⎋ i ⌘⌥⇧⌃⎋ dla Wymuś koniec i nie można ich odpiąć. AltTab nie może ich użyć.\n\nTwoja zmiana przypisałaby jeden z nich do: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS rezerwuje ⌘⌥⎋, ⌘⌥⇧⎋ i ⌘⌥⇧⌃⎋ dla Wymuś koniec i nie można ich odpiąć. CommandTabFree nie może ich użyć.\n\nTwoja zmiana przypisałaby jeden z nich do: %@.";
|
||||
"Manage activations, view receipts, etc" = "Zarządzaj aktywacjami, przeglądaj rachunki itp.";
|
||||
"Maybe later" = "Może później";
|
||||
"Medium" = "Średni";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Zastąpione w skrócie:";
|
||||
"Paste your license key:" = "Wklej swój klucz licencyjny:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Wybierz komputer do dezaktywacji, aby aktywować tutaj.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Przeciągnij AltTab do folderu Aplikacje ręcznie.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Przeciągnij CommandTabFree do folderu Aplikacje ręcznie.";
|
||||
"Please fill in the form" = "Wypełnij formularz";
|
||||
"Preview selected window" = "Podgląd zaznaczonego okna";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Ekran z paskiem menu";
|
||||
"Screen including mouse" = "Ekran z kursorem";
|
||||
"Screen Recording" = "Nagrywanie ekranu";
|
||||
"Screen showing AltTab" = "Ekran z AltTab";
|
||||
"Screen showing CommandTabFree" = "Ekran z CommandTabFree";
|
||||
"Search" = "Wyszukiwanie";
|
||||
"Search is a Pro feature." = "Wyszukiwanie to funkcja Pro.";
|
||||
"Search windows by typing" = "Wyszukuj okna, wpisując tekst";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Tytuł okna";
|
||||
"Window title contains" = "Tytuł okna zawiera";
|
||||
"You can’t undo this action." = "Nie można cofnąć tej akcji.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Masz 14 dni, aby wypróbować wszystkie funkcje Pro. Potem funkcje Pro się wycofają, a podstawowy przełącznik okien będzie nadal działał dokładnie tak jak wcześniej.\n\nAltTab pozostaje darmowy i otwartoźródłowy. Pro to opcjonalny jednorazowy zakup, który finansuje dalszy rozwój.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Masz 14 dni, aby wypróbować wszystkie funkcje Pro. Potem funkcje Pro się wycofają, a podstawowy przełącznik okien będzie nadal działał dokładnie tak jak wcześniej.\n\nCommandTabFree pozostaje darmowy i otwartoźródłowy. Pro to opcjonalny jednorazowy zakup, który finansuje dalszy rozwój.";
|
||||
"You may need to disable some conflicting system gestures" = "Możesz musieć wyłączyć niektóre sprzeczne gesty systemowe";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Jesteś w 4. dniu z 14 — wypróbuj je, zanim skończy się okres próbny:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "Używasz wersji v%1$@. Dostępna jest v%2$@. Błąd, który widzisz, może być już naprawiony — uaktualnij najpierw i sprawdź przed zgłoszeniem.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Używasz %@.\nWrócą one do ustawień domyślnych 15. dnia.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Używasz %d funkcji Pro.\nWrócą one do ustawień domyślnych 15. dnia.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Używałeś AltTab %1$@ razy — z czego %2$@ użyło funkcji Pro.";
|
||||
"You've used AltTab %@ times." = "Używałeś AltTab %@ razy.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Używałeś CommandTabFree %1$@ razy — z czego %2$@ użyło funkcji Pro.";
|
||||
"You've used CommandTabFree %@ times." = "Używałeś CommandTabFree %@ razy.";
|
||||
"Your 14-day Pro trial just ended" = "Twój 14-dniowy okres próbny Pro właśnie się zakończył";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Twoja opinia zostanie wysłana jako publiczne zgłoszenie na GitHub.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Twoja licencja nie obejmuje tej wersji. Przejdź na Lifetime Pro.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Deslizar horizontal com 4 dedos";
|
||||
"4-finger Vertical Swipe" = "Deslizar vertical com 4 dedos";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "Um perfil de depuração (versões, ajustes, hardware) é anexado para ajudar a diagnosticar o problema.";
|
||||
"A new version of AltTab is available" = "Uma nova versão do AltTab está disponível";
|
||||
"A new version of CommandTabFree is available" = "Uma nova versão do CommandTabFree está disponível";
|
||||
"About %@" = "Sobre o %@";
|
||||
"Accessibility" = "Acessibilidade";
|
||||
"Activate" = "Ativar";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Todos os Espaços";
|
||||
"Allowed" = "Permitido";
|
||||
"Alphabetical Order" = "Ordem alfabética";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "O AltTab travou na última vez em que você o usou. Enviar um relatório de falha ajudará a corrigir o problema";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "O AltTab é um seletor de janelas gratuito e de código aberto para o macOS. Os recursos Pro estão disponíveis com uma avaliação gratuita de 14 dias.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "O AltTab está em execução sem permissão de Gravação de Tela. %@ não serão exibidas.";
|
||||
"AltTab needs some permissions" = "O AltTab precisa de algumas permissões";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "O AltTab Pro adiciona 4 recursos além do seletor gratuito.";
|
||||
"AltTab Pro is still available" = "O AltTab Pro continua disponível";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "O AltTab mostrará com um ícone de status se a janela está minimizada ou em tela cheia.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "O CommandTabFree travou na última vez em que você o usou. Enviar um relatório de falha ajudará a corrigir o problema";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "O CommandTabFree é um seletor de janelas gratuito e de código aberto para o macOS. Os recursos Pro estão disponíveis com uma avaliação gratuita de 14 dias.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "O CommandTabFree está em execução sem permissão de Gravação de Tela. %@ não serão exibidas.";
|
||||
"CommandTabFree needs some permissions" = "O CommandTabFree precisa de algumas permissões";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "O CommandTabFree Pro adiciona 4 recursos além do seletor gratuito.";
|
||||
"CommandTabFree Pro is still available" = "O CommandTabFree Pro continua disponível";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "O CommandTabFree mostrará com um ícone de status se a janela está minimizada ou em tela cheia.";
|
||||
"Always" = "Sempre";
|
||||
"Always send crash reports" = "Sempre enviar relatórios de falha";
|
||||
"and press" = "e pressione";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Entrar/sair de tela cheia";
|
||||
"General" = "Geral";
|
||||
"Gesture" = "Gesto";
|
||||
"Get more from AltTab with Pro" = "Aproveite mais o AltTab com o Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Aproveite mais o CommandTabFree com o Pro";
|
||||
"Get Pro" = "Obter o Pro";
|
||||
"Go back" = "Voltar";
|
||||
"Grant permission" = "Conceder permissão";
|
||||
"Group apps" = "Agrupar apps";
|
||||
"Group tabs" = "Agrupar abas";
|
||||
"Help improve AltTab" = "Ajude a melhorar o AltTab";
|
||||
"Help improve CommandTabFree" = "Ajude a melhorar o CommandTabFree";
|
||||
"Hide" = "Ocultar";
|
||||
"Hide colored circles on mouse hover" = "Ocultar círculos coloridos ao passar o mouse";
|
||||
"Hide Space number labels" = "Ocultar rótulos de número dos Espaços";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Grande";
|
||||
"Later" = "Mais tarde";
|
||||
"License doesn't cover this version" = "A licença não cobre esta versão";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "A licença permanecerá válida e poderá ser usada para ativar o AltTab em qualquer máquina";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "A licença permanecerá válida e poderá ser usada para ativar o CommandTabFree em qualquer máquina";
|
||||
"Light" = "Clara";
|
||||
"Lock search" = "Bloquear busca";
|
||||
"Machine %@" = "Máquina %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "O macOS reserva ⌘⌥⎋, ⌘⌥⇧⎋ e ⌘⌥⇧⌃⎋ para Forçar Encerrar e eles não podem ser desvinculados. O AltTab não pode usá-los.\n\nSua alteração atribuiria um deles a: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "O macOS reserva ⌘⌥⎋, ⌘⌥⇧⎋ e ⌘⌥⇧⌃⎋ para Forçar Encerrar e eles não podem ser desvinculados. O CommandTabFree não pode usá-los.\n\nSua alteração atribuiria um deles a: %@.";
|
||||
"Manage activations, view receipts, etc" = "Gerenciar ativações, ver recibos etc.";
|
||||
"Maybe later" = "Talvez mais tarde";
|
||||
"Medium" = "Médio";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Substituído em Atalho:";
|
||||
"Paste your license key:" = "Cole sua chave de licença:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Escolha uma máquina para desativar e poder ativar aqui.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Arraste o AltTab para a pasta Aplicativos manualmente.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Arraste o CommandTabFree para a pasta Aplicativos manualmente.";
|
||||
"Please fill in the form" = "Preencha o formulário";
|
||||
"Preview selected window" = "Pré-visualizar janela selecionada";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Tela que contém a barra de menus";
|
||||
"Screen including mouse" = "Tela que contém o mouse";
|
||||
"Screen Recording" = "Gravação de Tela";
|
||||
"Screen showing AltTab" = "Tela exibindo o AltTab";
|
||||
"Screen showing CommandTabFree" = "Tela exibindo o CommandTabFree";
|
||||
"Search" = "Buscar";
|
||||
"Search is a Pro feature." = "A busca é um recurso Pro.";
|
||||
"Search windows by typing" = "Buscar janelas digitando";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Título da Janela";
|
||||
"Window title contains" = "Título da janela contém";
|
||||
"You can’t undo this action." = "Você não pode desfazer esta ação.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Você tem 14 dias para experimentar todos os recursos Pro. Depois disso, os recursos Pro recuam e o seletor de janelas principal continuará funcionando exatamente como antes.\n\nO AltTab continua gratuito e de código aberto. O Pro é uma compra única opcional que financia o desenvolvimento contínuo.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Você tem 14 dias para experimentar todos os recursos Pro. Depois disso, os recursos Pro recuam e o seletor de janelas principal continuará funcionando exatamente como antes.\n\nO CommandTabFree continua gratuito e de código aberto. O Pro é uma compra única opcional que financia o desenvolvimento contínuo.";
|
||||
"You may need to disable some conflicting system gestures" = "Talvez seja necessário desativar alguns gestos do sistema em conflito";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Você está no dia 4 de 14 — experimente estes antes que sua avaliação termine:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "Você está usando a v%1$@. A v%2$@ está disponível. O bug que você está vendo pode já ter sido corrigido — atualize primeiro e verifique antes de relatar.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Você tem usado %@.\nEsses recursos voltarão aos padrões no dia 15.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Você tem usado %d recursos Pro.\nEles voltarão aos padrões no dia 15.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Você usou o AltTab %1$@ vezes — %2$@ delas usaram recursos Pro.";
|
||||
"You've used AltTab %@ times." = "Você usou o AltTab %@ vezes.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Você usou o CommandTabFree %1$@ vezes — %2$@ delas usaram recursos Pro.";
|
||||
"You've used CommandTabFree %@ times." = "Você usou o CommandTabFree %@ vezes.";
|
||||
"Your 14-day Pro trial just ended" = "Sua avaliação Pro de 14 dias acabou de terminar";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Seu feedback será enviado como uma issue pública no GitHub.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Sua licença não cobre esta versão. Faça upgrade para o Pro Lifetime.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Горизонтальный свайп четырьмя пальцами";
|
||||
"4-finger Vertical Swipe" = "Вертикальный свайп четырьмя пальцами";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "К сообщению приложен отладочный профиль (версии, настройки, оборудование) для диагностики проблемы.";
|
||||
"A new version of AltTab is available" = "Доступна новая версия AltTab";
|
||||
"A new version of CommandTabFree is available" = "Доступна новая версия CommandTabFree";
|
||||
"About %@" = "О программе %@";
|
||||
"Accessibility" = "Универсальный доступ";
|
||||
"Activate" = "Активировать";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Все пространства";
|
||||
"Allowed" = "Разрешено";
|
||||
"Alphabetical Order" = "По алфавиту";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "В прошлый раз AltTab завершился со сбоем. Отправка отчёта о сбое поможет устранить проблему";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab — это бесплатный переключатель окон с открытым исходным кодом для macOS. Функции Pro доступны с бесплатным 14-дневным пробным периодом.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab работает без разрешения на запись экрана. %@ не будут отображаться.";
|
||||
"AltTab needs some permissions" = "AltTab требуется несколько разрешений";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro добавляет 4 функции к бесплатному переключателю.";
|
||||
"AltTab Pro is still available" = "AltTab Pro по-прежнему доступен";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab покажет значком статуса, если окно сейчас свёрнуто или в полноэкранном режиме.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "В прошлый раз CommandTabFree завершился со сбоем. Отправка отчёта о сбое поможет устранить проблему";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree — это бесплатный переключатель окон с открытым исходным кодом для macOS. Функции Pro доступны с бесплатным 14-дневным пробным периодом.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree работает без разрешения на запись экрана. %@ не будут отображаться.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree требуется несколько разрешений";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro добавляет 4 функции к бесплатному переключателю.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro по-прежнему доступен";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree покажет значком статуса, если окно сейчас свёрнуто или в полноэкранном режиме.";
|
||||
"Always" = "Всегда";
|
||||
"Always send crash reports" = "Всегда отправлять отчёты о сбоях";
|
||||
"and press" = "и нажмите";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Развернуть/свернуть окно на весь экран";
|
||||
"General" = "Основные";
|
||||
"Gesture" = "Жест";
|
||||
"Get more from AltTab with Pro" = "Получите больше от AltTab с Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Получите больше от CommandTabFree с Pro";
|
||||
"Get Pro" = "Получить Pro";
|
||||
"Go back" = "Назад";
|
||||
"Grant permission" = "Предоставить разрешение";
|
||||
"Group apps" = "Группировать приложения";
|
||||
"Group tabs" = "Группировать вкладки";
|
||||
"Help improve AltTab" = "Помочь улучшить AltTab";
|
||||
"Help improve CommandTabFree" = "Помочь улучшить CommandTabFree";
|
||||
"Hide" = "Скрыть";
|
||||
"Hide colored circles on mouse hover" = "Скрывать цветные кружки при наведении курсора";
|
||||
"Hide Space number labels" = "Скрывать номера пространств";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Большой";
|
||||
"Later" = "Позже";
|
||||
"License doesn't cover this version" = "Лицензия не распространяется на эту версию";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "Лицензия останется действительной и пригодной для активации AltTab на любом компьютере";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "Лицензия останется действительной и пригодной для активации CommandTabFree на любом компьютере";
|
||||
"Light" = "Светлая";
|
||||
"Lock search" = "Зафиксировать поиск";
|
||||
"Machine %@" = "Компьютер %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS резервирует ⌘⌥⎋, ⌘⌥⇧⎋ и ⌘⌥⇧⌃⎋ для «Завершить принудительно», и их нельзя отвязать. AltTab не может их использовать.\n\nВаше изменение назначит одно из них на: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS резервирует ⌘⌥⎋, ⌘⌥⇧⎋ и ⌘⌥⇧⌃⎋ для «Завершить принудительно», и их нельзя отвязать. CommandTabFree не может их использовать.\n\nВаше изменение назначит одно из них на: %@.";
|
||||
"Manage activations, view receipts, etc" = "Управление активациями, просмотр чеков и т. д.";
|
||||
"Maybe later" = "Возможно, позже";
|
||||
"Medium" = "Средний";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Переопределено в сочетании клавиш:";
|
||||
"Paste your license key:" = "Вставьте лицензионный ключ:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Выберите компьютер для деактивации, чтобы активировать здесь.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Перетащите AltTab в папку «Программы» вручную.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Перетащите CommandTabFree в папку «Программы» вручную.";
|
||||
"Please fill in the form" = "Заполните форму";
|
||||
"Preview selected window" = "Предпросмотр выбранного окна";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Экран со строкой меню";
|
||||
"Screen including mouse" = "Экран с курсором мыши";
|
||||
"Screen Recording" = "Запись экрана";
|
||||
"Screen showing AltTab" = "Экран с AltTab";
|
||||
"Screen showing CommandTabFree" = "Экран с CommandTabFree";
|
||||
"Search" = "Поиск";
|
||||
"Search is a Pro feature." = "Поиск — функция Pro.";
|
||||
"Search windows by typing" = "Поиск окон по тексту";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Заголовок окна";
|
||||
"Window title contains" = "Заголовок окна содержит";
|
||||
"You can’t undo this action." = "Это действие нельзя отменить.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "У вас есть 14 дней, чтобы попробовать все функции Pro. После этого функции Pro отключатся, а основной переключатель окон продолжит работать в точности как прежде.\n\nAltTab остаётся бесплатным и с открытым исходным кодом. Pro — это необязательная разовая покупка, которая финансирует дальнейшую разработку.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "У вас есть 14 дней, чтобы попробовать все функции Pro. После этого функции Pro отключатся, а основной переключатель окон продолжит работать в точности как прежде.\n\nCommandTabFree остаётся бесплатным и с открытым исходным кодом. Pro — это необязательная разовая покупка, которая финансирует дальнейшую разработку.";
|
||||
"You may need to disable some conflicting system gestures" = "Возможно, потребуется отключить некоторые конфликтующие системные жесты";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Сегодня 4-й день из 14 — попробуйте это до окончания пробного периода:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "У вас установлена v%1$@. Доступна v%2$@. Ошибка, с которой вы столкнулись, возможно, уже исправлена — сначала обновитесь и проверьте, прежде чем сообщать о ней.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Вы использовали %@.\nНа 15-й день они вернутся к значениям по умолчанию.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Вы использовали %d функций Pro.\nНа 15-й день они вернутся к значениям по умолчанию.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Вы использовали AltTab %1$@ раз — из них %2$@ с функциями Pro.";
|
||||
"You've used AltTab %@ times." = "Вы использовали AltTab %@ раз.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Вы использовали CommandTabFree %1$@ раз — из них %2$@ с функциями Pro.";
|
||||
"You've used CommandTabFree %@ times." = "Вы использовали CommandTabFree %@ раз.";
|
||||
"Your 14-day Pro trial just ended" = "Ваш 14-дневный пробный период Pro только что закончился";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Ваш отзыв будет отправлен как публичный issue на GitHub.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Ваша лицензия не распространяется на эту версию. Перейдите на Lifetime Pro.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Horisontellt svep med 4 fingrar";
|
||||
"4-finger Vertical Swipe" = "Vertikalt svep med 4 fingrar";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "En felsökningsprofil (versioner, inställningar, hårdvara) bifogas för att hjälpa till att diagnostisera problemet.";
|
||||
"A new version of AltTab is available" = "En ny version av AltTab är tillgänglig";
|
||||
"A new version of CommandTabFree is available" = "En ny version av CommandTabFree är tillgänglig";
|
||||
"About %@" = "Om %@";
|
||||
"Accessibility" = "Hjälpmedel";
|
||||
"Activate" = "Aktivera";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Alla skrivbord";
|
||||
"Allowed" = "Tillåten";
|
||||
"Alphabetical Order" = "Alfabetisk ordning";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab kraschade förra gången du använde den. Att skicka en kraschrapport hjälper till att åtgärda problemet";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab är en gratis fönsterväxlare med öppen källkod för macOS. Pro-funktioner är tillgängliga med en 14-dagars gratis provperiod.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab körs utan behörighet för Skärminspelning. %@ visas inte.";
|
||||
"AltTab needs some permissions" = "AltTab behöver vissa behörigheter";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro lägger till 4 funktioner utöver den kostnadsfria växlaren.";
|
||||
"AltTab Pro is still available" = "AltTab Pro är fortfarande tillgängligt";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab visar med en statusikon om fönstret är minimerat eller i helskärm.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree kraschade förra gången du använde den. Att skicka en kraschrapport hjälper till att åtgärda problemet";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree är en gratis fönsterväxlare med öppen källkod för macOS. Pro-funktioner är tillgängliga med en 14-dagars gratis provperiod.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree körs utan behörighet för Skärminspelning. %@ visas inte.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree behöver vissa behörigheter";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro lägger till 4 funktioner utöver den kostnadsfria växlaren.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro är fortfarande tillgängligt";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree visar med en statusikon om fönstret är minimerat eller i helskärm.";
|
||||
"Always" = "Alltid";
|
||||
"Always send crash reports" = "Skicka alltid kraschrapporter";
|
||||
"and press" = "och tryck";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Helskärm/Avsluta helskärm för fönster";
|
||||
"General" = "Allmänt";
|
||||
"Gesture" = "Gest";
|
||||
"Get more from AltTab with Pro" = "Få ut mer av AltTab med Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Få ut mer av CommandTabFree med Pro";
|
||||
"Get Pro" = "Skaffa Pro";
|
||||
"Go back" = "Tillbaka";
|
||||
"Grant permission" = "Bevilja behörighet";
|
||||
"Group apps" = "Gruppera appar";
|
||||
"Group tabs" = "Gruppera flikar";
|
||||
"Help improve AltTab" = "Hjälp till att förbättra AltTab";
|
||||
"Help improve CommandTabFree" = "Hjälp till att förbättra CommandTabFree";
|
||||
"Hide" = "Dölj";
|
||||
"Hide colored circles on mouse hover" = "Dölj färgade cirklar när muspekaren är över";
|
||||
"Hide Space number labels" = "Dölj nummeretiketter för skrivbord";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Stor";
|
||||
"Later" = "Senare";
|
||||
"License doesn't cover this version" = "Licensen täcker inte den här versionen";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "Licensen förblir giltig och kan användas för att aktivera AltTab på vilken dator som helst";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "Licensen förblir giltig och kan användas för att aktivera CommandTabFree på vilken dator som helst";
|
||||
"Light" = "Ljus";
|
||||
"Lock search" = "Lås sökning";
|
||||
"Machine %@" = "Dator %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserverar ⌘⌥⎋, ⌘⌥⇧⎋ och ⌘⌥⇧⌃⎋ för Tvinga att avsluta och de kan inte frigöras. AltTab kan inte använda dem.\n\nDin ändring skulle tilldela ett av dessa till: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS reserverar ⌘⌥⎋, ⌘⌥⇧⎋ och ⌘⌥⇧⌃⎋ för Tvinga att avsluta och de kan inte frigöras. CommandTabFree kan inte använda dem.\n\nDin ändring skulle tilldela ett av dessa till: %@.";
|
||||
"Manage activations, view receipts, etc" = "Hantera aktiveringar, visa kvitton osv.";
|
||||
"Maybe later" = "Kanske senare";
|
||||
"Medium" = "Mellan";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Åsidosatt i Kortkommando:";
|
||||
"Paste your license key:" = "Klistra in din licensnyckel:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Välj en dator att inaktivera så att du kan aktivera här.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Dra AltTab till mappen Program manuellt.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Dra CommandTabFree till mappen Program manuellt.";
|
||||
"Please fill in the form" = "Fyll i formuläret";
|
||||
"Preview selected window" = "Förhandsvisa valt fönster";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Skärm som innehåller menyraden";
|
||||
"Screen including mouse" = "Skärm som innehåller musen";
|
||||
"Screen Recording" = "Skärminspelning";
|
||||
"Screen showing AltTab" = "Skärm som visar AltTab";
|
||||
"Screen showing CommandTabFree" = "Skärm som visar CommandTabFree";
|
||||
"Search" = "Sök";
|
||||
"Search is a Pro feature." = "Sök är en Pro-funktion.";
|
||||
"Search windows by typing" = "Sök fönster genom att skriva";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Fönstertitel";
|
||||
"Window title contains" = "Fönstertitel innehåller";
|
||||
"You can’t undo this action." = "Den här åtgärden kan inte ångras.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Du har 14 dagar på dig att pröva alla Pro-funktioner. Därefter kliver Pro-funktionerna åt sidan och själva fönsterväxlaren fortsätter fungera exakt som tidigare.\n\nAltTab förblir gratis och med öppen källkod. Pro är ett valfritt engångsköp som finansierar fortsatt utveckling.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Du har 14 dagar på dig att pröva alla Pro-funktioner. Därefter kliver Pro-funktionerna åt sidan och själva fönsterväxlaren fortsätter fungera exakt som tidigare.\n\nCommandTabFree förblir gratis och med öppen källkod. Pro är ett valfritt engångsköp som finansierar fortsatt utveckling.";
|
||||
"You may need to disable some conflicting system gestures" = "Du kan behöva inaktivera vissa motstridiga systemgester";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Du är på dag 4 av 14 — pröva dessa innan din provperiod tar slut:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "Du kör v%1$@. v%2$@ är tillgänglig. Felet du ser kan redan vara åtgärdat — uppdatera först och kontrollera innan du rapporterar.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Du har använt %@.\nDessa återgår till standardvärden på dag 15.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Du har använt %d Pro-funktioner.\nDe återgår till standardvärden på dag 15.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Du har använt AltTab %1$@ gånger — %2$@ av dem använde Pro-funktioner.";
|
||||
"You've used AltTab %@ times." = "Du har använt AltTab %@ gånger.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Du har använt CommandTabFree %1$@ gånger — %2$@ av dem använde Pro-funktioner.";
|
||||
"You've used CommandTabFree %@ times." = "Du har använt CommandTabFree %@ gånger.";
|
||||
"Your 14-day Pro trial just ended" = "Din 14-dagars Pro-provperiod har just tagit slut";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Din feedback skickas in som ett offentligt GitHub-ärende.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Din licens täcker inte den här versionen. Uppgradera till Lifetime Pro.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "ปัด 4 นิ้วในแนวนอน";
|
||||
"4-finger Vertical Swipe" = "ปัด 4 นิ้วในแนวตั้ง";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "แนบโปรไฟล์การแก้ไขข้อบกพร่อง (เวอร์ชัน การตั้งค่า ฮาร์ดแวร์) เพื่อช่วยวินิจฉัยปัญหา";
|
||||
"A new version of AltTab is available" = "มี AltTab เวอร์ชันใหม่ให้ใช้งาน";
|
||||
"A new version of CommandTabFree is available" = "มี CommandTabFree เวอร์ชันใหม่ให้ใช้งาน";
|
||||
"About %@" = "เกี่ยวกับ %@";
|
||||
"Accessibility" = "การช่วยการเข้าถึง";
|
||||
"Activate" = "เปิดใช้งาน";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "พื้นที่ทำงานทั้งหมด";
|
||||
"Allowed" = "อนุญาต";
|
||||
"Alphabetical Order" = "เรียงตามตัวอักษร";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab ขัดข้องในการใช้งานครั้งล่าสุด การส่งรายงานการขัดข้องจะช่วยแก้ไขปัญหา";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab เป็นโปรแกรมสลับหน้าต่างฟรีและโอเพนซอร์สสำหรับ macOS คุณสมบัติ Pro มีให้ทดลองใช้งานฟรี 14 วัน";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab กำลังทำงานโดยไม่มีสิทธิ์การบันทึกหน้าจอ %@จะไม่แสดง";
|
||||
"AltTab needs some permissions" = "AltTab ต้องการสิทธิ์บางอย่าง";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro เพิ่มคุณสมบัติ 4 อย่างนอกเหนือจากตัวสลับฟรี";
|
||||
"AltTab Pro is still available" = "AltTab Pro ยังคงพร้อมใช้งาน";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab จะแสดงไอคอนสถานะหากหน้าต่างถูกย่อหรืออยู่ในโหมดเต็มหน้าจอ";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree ขัดข้องในการใช้งานครั้งล่าสุด การส่งรายงานการขัดข้องจะช่วยแก้ไขปัญหา";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree เป็นโปรแกรมสลับหน้าต่างฟรีและโอเพนซอร์สสำหรับ macOS คุณสมบัติ Pro มีให้ทดลองใช้งานฟรี 14 วัน";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree กำลังทำงานโดยไม่มีสิทธิ์การบันทึกหน้าจอ %@จะไม่แสดง";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree ต้องการสิทธิ์บางอย่าง";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro เพิ่มคุณสมบัติ 4 อย่างนอกเหนือจากตัวสลับฟรี";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro ยังคงพร้อมใช้งาน";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree จะแสดงไอคอนสถานะหากหน้าต่างถูกย่อหรืออยู่ในโหมดเต็มหน้าจอ";
|
||||
"Always" = "เสมอ";
|
||||
"Always send crash reports" = "ส่งรายงานการขัดข้องเสมอ";
|
||||
"and press" = "แล้วกด";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "เต็มหน้าจอ/ออกจากเต็มหน้าจอ";
|
||||
"General" = "ทั่วไป";
|
||||
"Gesture" = "ท่าทาง";
|
||||
"Get more from AltTab with Pro" = "ใช้ AltTab ได้มากขึ้นด้วย Pro";
|
||||
"Get more from CommandTabFree with Pro" = "ใช้ CommandTabFree ได้มากขึ้นด้วย Pro";
|
||||
"Get Pro" = "รับ Pro";
|
||||
"Go back" = "ย้อนกลับ";
|
||||
"Grant permission" = "ให้สิทธิ์";
|
||||
"Group apps" = "จัดกลุ่มแอป";
|
||||
"Group tabs" = "จัดกลุ่มแท็บ";
|
||||
"Help improve AltTab" = "ช่วยปรับปรุง AltTab";
|
||||
"Help improve CommandTabFree" = "ช่วยปรับปรุง CommandTabFree";
|
||||
"Hide" = "ซ่อน";
|
||||
"Hide colored circles on mouse hover" = "ซ่อนวงกลมสีเมื่อชี้เมาส์";
|
||||
"Hide Space number labels" = "ซ่อนป้ายหมายเลขพื้นที่ทำงาน";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "ใหญ่";
|
||||
"Later" = "ภายหลัง";
|
||||
"License doesn't cover this version" = "สิทธิ์ใช้งานไม่ครอบคลุมเวอร์ชันนี้";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "สิทธิ์ใช้งานจะยังคงใช้งานได้และสามารถเปิดใช้งาน AltTab บนเครื่องใดก็ได้";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "สิทธิ์ใช้งานจะยังคงใช้งานได้และสามารถเปิดใช้งาน CommandTabFree บนเครื่องใดก็ได้";
|
||||
"Light" = "สว่าง";
|
||||
"Lock search" = "ล็อกการค้นหา";
|
||||
"Machine %@" = "เครื่อง %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS สงวน ⌘⌥⎋, ⌘⌥⇧⎋ และ ⌘⌥⇧⌃⎋ ไว้สำหรับการบังคับออก และไม่สามารถยกเลิกการกำหนดได้ AltTab ไม่สามารถใช้ปุ่มเหล่านี้\n\nการเปลี่ยนแปลงของคุณจะกำหนดปุ่มหนึ่งในเหล่านี้ให้กับ: %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS สงวน ⌘⌥⎋, ⌘⌥⇧⎋ และ ⌘⌥⇧⌃⎋ ไว้สำหรับการบังคับออก และไม่สามารถยกเลิกการกำหนดได้ CommandTabFree ไม่สามารถใช้ปุ่มเหล่านี้\n\nการเปลี่ยนแปลงของคุณจะกำหนดปุ่มหนึ่งในเหล่านี้ให้กับ: %@";
|
||||
"Manage activations, view receipts, etc" = "จัดการการเปิดใช้งาน ดูใบเสร็จ ฯลฯ";
|
||||
"Maybe later" = "อาจจะภายหลัง";
|
||||
"Medium" = "ปานกลาง";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "ถูกแทนที่ในทางลัด:";
|
||||
"Paste your license key:" = "วางคีย์สิทธิ์ใช้งานของคุณ:";
|
||||
"Pick a machine to deactivate so you can activate here." = "เลือกเครื่องที่จะปิดใช้งานเพื่อให้คุณสามารถเปิดใช้งานที่นี่ได้";
|
||||
"Please drag AltTab into the Applications folder manually." = "โปรดลาก AltTab ไปยังโฟลเดอร์ Applications ด้วยตนเอง";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "โปรดลาก CommandTabFree ไปยังโฟลเดอร์ Applications ด้วยตนเอง";
|
||||
"Please fill in the form" = "โปรดกรอกแบบฟอร์ม";
|
||||
"Preview selected window" = "แสดงตัวอย่างหน้าต่างที่เลือก";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "หน้าจอที่มีแถบเมนู";
|
||||
"Screen including mouse" = "หน้าจอที่มีเมาส์";
|
||||
"Screen Recording" = "การบันทึกหน้าจอ";
|
||||
"Screen showing AltTab" = "หน้าจอที่แสดง AltTab";
|
||||
"Screen showing CommandTabFree" = "หน้าจอที่แสดง CommandTabFree";
|
||||
"Search" = "ค้นหา";
|
||||
"Search is a Pro feature." = "การค้นหาเป็นคุณสมบัติ Pro";
|
||||
"Search windows by typing" = "ค้นหาหน้าต่างโดยการพิมพ์";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "ชื่อหน้าต่าง";
|
||||
"Window title contains" = "ชื่อหน้าต่างมี";
|
||||
"You can’t undo this action." = "คุณไม่สามารถเลิกทำการกระทำนี้ได้";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "คุณมีเวลา 14 วันในการทดลองใช้คุณสมบัติ Pro ทั้งหมด หลังจากนั้น คุณสมบัติ Pro จะถอยกลับและตัวสลับหน้าต่างหลักจะยังคงทำงานเหมือนเดิมทุกประการ\n\nAltTab ยังคงฟรีและโอเพนซอร์ส Pro เป็นการซื้อแบบครั้งเดียวที่ไม่บังคับซึ่งสนับสนุนการพัฒนาอย่างต่อเนื่อง";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "คุณมีเวลา 14 วันในการทดลองใช้คุณสมบัติ Pro ทั้งหมด หลังจากนั้น คุณสมบัติ Pro จะถอยกลับและตัวสลับหน้าต่างหลักจะยังคงทำงานเหมือนเดิมทุกประการ\n\nCommandTabFree ยังคงฟรีและโอเพนซอร์ส Pro เป็นการซื้อแบบครั้งเดียวที่ไม่บังคับซึ่งสนับสนุนการพัฒนาอย่างต่อเนื่อง";
|
||||
"You may need to disable some conflicting system gestures" = "คุณอาจต้องปิดท่าทางของระบบบางอย่างที่ขัดแย้งกัน";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "คุณอยู่ในวันที่ 4 จาก 14 — ลองใช้สิ่งเหล่านี้ก่อนการทดลองสิ้นสุด:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "คุณใช้งานเวอร์ชัน %1$@ อยู่ มีเวอร์ชัน %2$@ ให้ใช้งาน ข้อบกพร่องที่คุณเห็นอาจได้รับการแก้ไขแล้ว — โปรดอัปเดตก่อนและตรวจสอบก่อนรายงาน";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "คุณกำลังใช้ %@\nสิ่งเหล่านี้จะกลับไปเป็นค่าเริ่มต้นในวันที่ 15";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "คุณกำลังใช้คุณสมบัติ Pro %d รายการ\nสิ่งเหล่านี้จะกลับไปเป็นค่าเริ่มต้นในวันที่ 15";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "คุณใช้ AltTab ไปแล้ว %1$@ ครั้ง — %2$@ ครั้งในนั้นใช้คุณสมบัติ Pro";
|
||||
"You've used AltTab %@ times." = "คุณใช้ AltTab ไปแล้ว %@ ครั้ง";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "คุณใช้ CommandTabFree ไปแล้ว %1$@ ครั้ง — %2$@ ครั้งในนั้นใช้คุณสมบัติ Pro";
|
||||
"You've used CommandTabFree %@ times." = "คุณใช้ CommandTabFree ไปแล้ว %@ ครั้ง";
|
||||
"Your 14-day Pro trial just ended" = "การทดลองใช้ Pro 14 วันของคุณเพิ่งสิ้นสุดลง";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "คำติชมของคุณจะถูกส่งเป็นปัญหาสาธารณะบน GitHub";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "สิทธิ์ใช้งานของคุณไม่ครอบคลุมเวอร์ชันนี้ อัปเกรดเป็น Lifetime Pro";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "4 parmakla yatay kaydırma";
|
||||
"4-finger Vertical Swipe" = "4 parmakla dikey kaydırma";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "Sorunun tanılanmasına yardımcı olmak için bir hata ayıklama profili (sürümler, ayarlar, donanım) eklendi.";
|
||||
"A new version of AltTab is available" = "AltTab’ın yeni bir sürümü mevcut";
|
||||
"A new version of CommandTabFree is available" = "CommandTabFree’ın yeni bir sürümü mevcut";
|
||||
"About %@" = "%@ hakkında";
|
||||
"Accessibility" = "Erişilebilirlik";
|
||||
"Activate" = "Etkinleştir";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Tüm Alanlar";
|
||||
"Allowed" = "İzin verildi";
|
||||
"Alphabetical Order" = "Alfabetik sıra";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab en son kullandığınızda çöktü. Bir çökme raporu göndermek sorunun düzeltilmesine yardımcı olacaktır";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab, macOS için ücretsiz ve açık kaynaklı bir pencere değiştiricidir. Pro özellikleri 14 günlük ücretsiz deneme ile kullanılabilir.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab, Ekran Kaydı izinleri olmadan çalışıyor. %@ gösterilmeyecek.";
|
||||
"AltTab needs some permissions" = "AltTab’ın bazı izinlere ihtiyacı var";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro, ücretsiz değiştiriciye ek olarak 4 özellik ekler.";
|
||||
"AltTab Pro is still available" = "AltTab Pro hâlâ mevcut";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab, pencerenin şu anda küçültülmüş veya tam ekran olduğunu bir durum simgesiyle gösterir.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree en son kullandığınızda çöktü. Bir çökme raporu göndermek sorunun düzeltilmesine yardımcı olacaktır";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree, macOS için ücretsiz ve açık kaynaklı bir pencere değiştiricidir. Pro özellikleri 14 günlük ücretsiz deneme ile kullanılabilir.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree, Ekran Kaydı izinleri olmadan çalışıyor. %@ gösterilmeyecek.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree’ın bazı izinlere ihtiyacı var";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro, ücretsiz değiştiriciye ek olarak 4 özellik ekler.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro hâlâ mevcut";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree, pencerenin şu anda küçültülmüş veya tam ekran olduğunu bir durum simgesiyle gösterir.";
|
||||
"Always" = "Her zaman";
|
||||
"Always send crash reports" = "Çökme raporlarını her zaman gönder";
|
||||
"and press" = "ve basın";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Pencereyi tam ekran yap/tam ekrandan çıkar";
|
||||
"General" = "Genel";
|
||||
"Gesture" = "Hareket";
|
||||
"Get more from AltTab with Pro" = "Pro ile AltTab’dan daha fazlasını elde edin";
|
||||
"Get more from CommandTabFree with Pro" = "Pro ile CommandTabFree’dan daha fazlasını elde edin";
|
||||
"Get Pro" = "Pro’yu Edinin";
|
||||
"Go back" = "Geri dön";
|
||||
"Grant permission" = "İzin ver";
|
||||
"Group apps" = "Uygulamaları grupla";
|
||||
"Group tabs" = "Sekmeleri grupla";
|
||||
"Help improve AltTab" = "AltTab’ın geliştirilmesine yardımcı olun";
|
||||
"Help improve CommandTabFree" = "CommandTabFree’ın geliştirilmesine yardımcı olun";
|
||||
"Hide" = "Gizle";
|
||||
"Hide colored circles on mouse hover" = "Fare üzerine geldiğinde renkli daireleri gizle";
|
||||
"Hide Space number labels" = "Alan numarası etiketlerini gizle";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Büyük";
|
||||
"Later" = "Daha sonra";
|
||||
"License doesn't cover this version" = "Lisans bu sürümü kapsamıyor";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "Lisans geçerli kalmaya devam edecek ve herhangi bir makinede AltTab’ı etkinleştirmek için kullanılabilecek";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "Lisans geçerli kalmaya devam edecek ve herhangi bir makinede CommandTabFree’ı etkinleştirmek için kullanılabilecek";
|
||||
"Light" = "Açık";
|
||||
"Lock search" = "Aramayı kilitle";
|
||||
"Machine %@" = "Makine %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS, ⌘⌥⎋, ⌘⌥⇧⎋ ve ⌘⌥⇧⌃⎋ tuşlarını Çıkmaya Zorla için ayırmıştır ve bunların bağlantısı kaldırılamaz. AltTab bunları kullanamaz.\n\nYaptığınız değişiklik bunlardan birini şuna atayacaktır: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS, ⌘⌥⎋, ⌘⌥⇧⎋ ve ⌘⌥⇧⌃⎋ tuşlarını Çıkmaya Zorla için ayırmıştır ve bunların bağlantısı kaldırılamaz. CommandTabFree bunları kullanamaz.\n\nYaptığınız değişiklik bunlardan birini şuna atayacaktır: %@.";
|
||||
"Manage activations, view receipts, etc" = "Etkinleştirmeleri yönetin, makbuzları görüntüleyin vb.";
|
||||
"Maybe later" = "Belki daha sonra";
|
||||
"Medium" = "Orta";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Şu Kısayolda geçersiz kılındı:";
|
||||
"Paste your license key:" = "Lisans anahtarınızı yapıştırın:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Burada etkinleştirebilmek için devre dışı bırakılacak bir makine seçin.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Lütfen AltTab’ı el ile Uygulamalar klasörüne sürükleyin.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Lütfen CommandTabFree’ı el ile Uygulamalar klasörüne sürükleyin.";
|
||||
"Please fill in the form" = "Lütfen formu doldurun";
|
||||
"Preview selected window" = "Seçili pencereyi önizle";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Menü çubuğunu içeren ekran";
|
||||
"Screen including mouse" = "Fareyi içeren ekran";
|
||||
"Screen Recording" = "Ekran Kaydı";
|
||||
"Screen showing AltTab" = "AltTab’ı gösteren ekran";
|
||||
"Screen showing CommandTabFree" = "CommandTabFree’ı gösteren ekran";
|
||||
"Search" = "Ara";
|
||||
"Search is a Pro feature." = "Arama bir Pro özelliğidir.";
|
||||
"Search windows by typing" = "Yazarak pencerelerde ara";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Pencere Başlığı";
|
||||
"Window title contains" = "Pencere başlığı şunu içeriyor";
|
||||
"You can’t undo this action." = "Bu eylemi geri alamazsınız.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Tüm Pro özelliklerini denemek için 14 gününüz var. Bundan sonra Pro özellikleri geri çekilecek ve temel pencere değiştirici tam olarak eskisi gibi çalışmaya devam edecek.\n\nAltTab ücretsiz ve açık kaynaklı kalmaya devam ediyor. Pro, sürekli geliştirmeyi finanse eden isteğe bağlı, tek seferlik bir satın alımdır.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Tüm Pro özelliklerini denemek için 14 gününüz var. Bundan sonra Pro özellikleri geri çekilecek ve temel pencere değiştirici tam olarak eskisi gibi çalışmaya devam edecek.\n\nCommandTabFree ücretsiz ve açık kaynaklı kalmaya devam ediyor. Pro, sürekli geliştirmeyi finanse eden isteğe bağlı, tek seferlik bir satın alımdır.";
|
||||
"You may need to disable some conflicting system gestures" = "Çakışan bazı sistem hareketlerini devre dışı bırakmanız gerekebilir";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "14 günün 4. günündesiniz — deneme süreniz bitmeden bunları deneyin:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "v%1$@ sürümünü çalıştırıyorsunuz. v%2$@ mevcut. Gördüğünüz hata zaten düzeltilmiş olabilir — bildirmeden önce lütfen güncelleyip denetleyin.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "%@ kullanıyordunuz.\n15. günde bunlar varsayılanlara geri dönecek.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "%d Pro özelliği kullanıyordunuz.\n15. günde varsayılanlara geri dönecekler.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "AltTab’ı %1$@ kez kullandınız — bunlardan %2$@ kez Pro özellikleri kullanıldı.";
|
||||
"You've used AltTab %@ times." = "AltTab’ı %@ kez kullandınız.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "CommandTabFree’ı %1$@ kez kullandınız — bunlardan %2$@ kez Pro özellikleri kullanıldı.";
|
||||
"You've used CommandTabFree %@ times." = "CommandTabFree’ı %@ kez kullandınız.";
|
||||
"Your 14-day Pro trial just ended" = "14 günlük Pro denemeniz az önce sona erdi";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Geri bildiriminiz herkese açık bir GitHub konusu olarak gönderilecek.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Lisansınız bu sürümü kapsamıyor. Lifetime Pro’ya yükseltin.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "Vuốt ngang 4 ngón tay";
|
||||
"4-finger Vertical Swipe" = "Vuốt dọc 4 ngón tay";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "Hồ sơ gỡ lỗi (phiên bản, cài đặt, phần cứng) được đính kèm để giúp chẩn đoán sự cố.";
|
||||
"A new version of AltTab is available" = "Đã có phiên bản mới của AltTab";
|
||||
"A new version of CommandTabFree is available" = "Đã có phiên bản mới của CommandTabFree";
|
||||
"About %@" = "Giới thiệu về %@";
|
||||
"Accessibility" = "Trợ năng";
|
||||
"Activate" = "Kích hoạt";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "Tất cả Không gian";
|
||||
"Allowed" = "Cho phép";
|
||||
"Alphabetical Order" = "Thứ tự bảng chữ cái";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab đã gặp sự cố ở lần dùng trước. Gửi báo cáo sự cố sẽ giúp khắc phục vấn đề";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab là trình chuyển đổi cửa sổ miễn phí, mã nguồn mở cho macOS. Các tính năng Pro có sẵn với bản dùng thử miễn phí 14 ngày.";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab đang chạy mà không có quyền Ghi màn hình. %@ sẽ không hiển thị.";
|
||||
"AltTab needs some permissions" = "AltTab cần một số quyền";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro bổ sung 4 tính năng ngoài trình chuyển đổi miễn phí.";
|
||||
"AltTab Pro is still available" = "AltTab Pro vẫn còn khả dụng";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab sẽ hiển thị biểu tượng trạng thái nếu cửa sổ đang được thu nhỏ hoặc ở chế độ toàn màn hình.";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree đã gặp sự cố ở lần dùng trước. Gửi báo cáo sự cố sẽ giúp khắc phục vấn đề";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree là trình chuyển đổi cửa sổ miễn phí, mã nguồn mở cho macOS. Các tính năng Pro có sẵn với bản dùng thử miễn phí 14 ngày.";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree đang chạy mà không có quyền Ghi màn hình. %@ sẽ không hiển thị.";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree cần một số quyền";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro bổ sung 4 tính năng ngoài trình chuyển đổi miễn phí.";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro vẫn còn khả dụng";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree sẽ hiển thị biểu tượng trạng thái nếu cửa sổ đang được thu nhỏ hoặc ở chế độ toàn màn hình.";
|
||||
"Always" = "Luôn luôn";
|
||||
"Always send crash reports" = "Luôn gửi báo cáo sự cố";
|
||||
"and press" = "và nhấn";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "Bật/Tắt toàn màn hình cửa sổ";
|
||||
"General" = "Tổng quát";
|
||||
"Gesture" = "Cử chỉ";
|
||||
"Get more from AltTab with Pro" = "Tận dụng tối đa AltTab với Pro";
|
||||
"Get more from CommandTabFree with Pro" = "Tận dụng tối đa CommandTabFree với Pro";
|
||||
"Get Pro" = "Mua Pro";
|
||||
"Go back" = "Quay lại";
|
||||
"Grant permission" = "Cấp quyền";
|
||||
"Group apps" = "Nhóm ứng dụng";
|
||||
"Group tabs" = "Nhóm tab";
|
||||
"Help improve AltTab" = "Giúp cải thiện AltTab";
|
||||
"Help improve CommandTabFree" = "Giúp cải thiện CommandTabFree";
|
||||
"Hide" = "Ẩn";
|
||||
"Hide colored circles on mouse hover" = "Ẩn các vòng tròn màu khi di chuột";
|
||||
"Hide Space number labels" = "Ẩn nhãn số Không gian";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "Lớn";
|
||||
"Later" = "Để sau";
|
||||
"License doesn't cover this version" = "Giấy phép không áp dụng cho phiên bản này";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "Giấy phép sẽ vẫn hợp lệ và có thể dùng để kích hoạt AltTab trên bất kỳ máy nào";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "Giấy phép sẽ vẫn hợp lệ và có thể dùng để kích hoạt CommandTabFree trên bất kỳ máy nào";
|
||||
"Light" = "Sáng";
|
||||
"Lock search" = "Khóa tìm kiếm";
|
||||
"Machine %@" = "Máy %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS dành riêng ⌘⌥⎋, ⌘⌥⇧⎋, và ⌘⌥⇧⌃⎋ cho Buộc thoát và không thể gỡ bỏ. AltTab không thể sử dụng chúng.\n\nThay đổi của bạn sẽ gán một trong số đó cho: %@.";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS dành riêng ⌘⌥⎋, ⌘⌥⇧⎋, và ⌘⌥⇧⌃⎋ cho Buộc thoát và không thể gỡ bỏ. CommandTabFree không thể sử dụng chúng.\n\nThay đổi của bạn sẽ gán một trong số đó cho: %@.";
|
||||
"Manage activations, view receipts, etc" = "Quản lý kích hoạt, xem biên lai, v.v.";
|
||||
"Maybe later" = "Có thể để sau";
|
||||
"Medium" = "Trung bình";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "Bị ghi đè trong Phím tắt:";
|
||||
"Paste your license key:" = "Dán khóa giấy phép của bạn:";
|
||||
"Pick a machine to deactivate so you can activate here." = "Chọn một máy để hủy kích hoạt để bạn có thể kích hoạt tại đây.";
|
||||
"Please drag AltTab into the Applications folder manually." = "Vui lòng kéo AltTab vào thư mục Ứng dụng theo cách thủ công.";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "Vui lòng kéo CommandTabFree vào thư mục Ứng dụng theo cách thủ công.";
|
||||
"Please fill in the form" = "Vui lòng điền vào biểu mẫu";
|
||||
"Preview selected window" = "Xem trước cửa sổ đã chọn";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "Màn hình có thanh menu";
|
||||
"Screen including mouse" = "Màn hình có con trỏ chuột";
|
||||
"Screen Recording" = "Ghi màn hình";
|
||||
"Screen showing AltTab" = "Màn hình hiển thị AltTab";
|
||||
"Screen showing CommandTabFree" = "Màn hình hiển thị CommandTabFree";
|
||||
"Search" = "Tìm kiếm";
|
||||
"Search is a Pro feature." = "Tìm kiếm là tính năng Pro.";
|
||||
"Search windows by typing" = "Tìm cửa sổ bằng cách nhập";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "Tiêu đề cửa sổ";
|
||||
"Window title contains" = "Tiêu đề cửa sổ chứa";
|
||||
"You can’t undo this action." = "Bạn không thể hoàn tác hành động này.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Bạn có 14 ngày để dùng thử tất cả các tính năng Pro. Sau đó, các tính năng Pro sẽ tạm dừng và trình chuyển đổi cửa sổ cốt lõi sẽ tiếp tục hoạt động đúng như trước.\n\nAltTab vẫn miễn phí và mã nguồn mở. Pro là gói mua một lần tùy chọn để tài trợ cho việc phát triển liên tục.";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "Bạn có 14 ngày để dùng thử tất cả các tính năng Pro. Sau đó, các tính năng Pro sẽ tạm dừng và trình chuyển đổi cửa sổ cốt lõi sẽ tiếp tục hoạt động đúng như trước.\n\nCommandTabFree vẫn miễn phí và mã nguồn mở. Pro là gói mua một lần tùy chọn để tài trợ cho việc phát triển liên tục.";
|
||||
"You may need to disable some conflicting system gestures" = "Bạn có thể cần tắt một số cử chỉ hệ thống xung đột";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "Bạn đang ở ngày 4 trên 14 — hãy thử các tính năng này trước khi bản dùng thử kết thúc:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "Bạn đang dùng v%1$@. v%2$@ đã có sẵn. Lỗi bạn đang gặp có thể đã được sửa — vui lòng cập nhật trước và kiểm tra lại trước khi báo cáo.";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "Bạn đã sử dụng %@.\nCác mục này sẽ trở về mặc định vào Ngày thứ 15.";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "Bạn đã sử dụng %d tính năng Pro.\nChúng sẽ trở về mặc định vào Ngày thứ 15.";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "Bạn đã dùng AltTab %1$@ lần — %2$@ trong số đó sử dụng các tính năng Pro.";
|
||||
"You've used AltTab %@ times." = "Bạn đã dùng AltTab %@ lần.";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "Bạn đã dùng CommandTabFree %1$@ lần — %2$@ trong số đó sử dụng các tính năng Pro.";
|
||||
"You've used CommandTabFree %@ times." = "Bạn đã dùng CommandTabFree %@ lần.";
|
||||
"Your 14-day Pro trial just ended" = "Bản dùng thử Pro 14 ngày của bạn vừa kết thúc";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "Phản hồi của bạn sẽ được gửi dưới dạng GitHub Issue công khai.";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "Giấy phép của bạn không áp dụng cho phiên bản này. Hãy nâng cấp lên Lifetime Pro.";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "四指水平滑动";
|
||||
"4-finger Vertical Swipe" = "四指垂直滑动";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "已附上调试信息(版本、设置、硬件)以协助诊断问题。";
|
||||
"A new version of AltTab is available" = "AltTab 有新版本可用";
|
||||
"A new version of CommandTabFree is available" = "CommandTabFree 有新版本可用";
|
||||
"About %@" = "关于 %@";
|
||||
"Accessibility" = "辅助功能";
|
||||
"Activate" = "激活";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "所有桌面";
|
||||
"Allowed" = "允许";
|
||||
"Alphabetical Order" = "按字母顺序";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab 在您上次使用时崩溃了。发送崩溃报告有助于修复问题";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab 是一款免费、开源的 macOS 窗口切换器。Pro 功能提供 14 天免费试用。";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab 正在运行但未获得屏幕录制权限。%@将无法显示。";
|
||||
"AltTab needs some permissions" = "AltTab 需要一些权限";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro 在免费切换器之外新增 4 项功能。";
|
||||
"AltTab Pro is still available" = "AltTab Pro 仍然可用";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab 将通过状态图标显示窗口当前是否已最小化或全屏。";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree 在您上次使用时崩溃了。发送崩溃报告有助于修复问题";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree 是一款免费、开源的 macOS 窗口切换器。Pro 功能提供 14 天免费试用。";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree 正在运行但未获得屏幕录制权限。%@将无法显示。";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree 需要一些权限";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro 在免费切换器之外新增 4 项功能。";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro 仍然可用";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree 将通过状态图标显示窗口当前是否已最小化或全屏。";
|
||||
"Always" = "始终";
|
||||
"Always send crash reports" = "始终发送崩溃报告";
|
||||
"and press" = "然后按";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "全屏/退出全屏窗口";
|
||||
"General" = "通用";
|
||||
"Gesture" = "手势";
|
||||
"Get more from AltTab with Pro" = "通过 Pro 获取 AltTab 更多功能";
|
||||
"Get more from CommandTabFree with Pro" = "通过 Pro 获取 CommandTabFree 更多功能";
|
||||
"Get Pro" = "获取 Pro";
|
||||
"Go back" = "返回";
|
||||
"Grant permission" = "授予权限";
|
||||
"Group apps" = "应用分组";
|
||||
"Group tabs" = "标签分组";
|
||||
"Help improve AltTab" = "帮助改进 AltTab";
|
||||
"Help improve CommandTabFree" = "帮助改进 CommandTabFree";
|
||||
"Hide" = "隐藏";
|
||||
"Hide colored circles on mouse hover" = "鼠标悬停时隐藏彩色圆圈";
|
||||
"Hide Space number labels" = "隐藏桌面编号标签";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "大";
|
||||
"Later" = "稍后";
|
||||
"License doesn't cover this version" = "许可证不包含此版本";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "许可证仍然有效,可在任何设备上激活 AltTab";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "许可证仍然有效,可在任何设备上激活 CommandTabFree";
|
||||
"Light" = "浅色";
|
||||
"Lock search" = "锁定搜索";
|
||||
"Machine %@" = "设备 %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS 将 ⌘⌥⎋、⌘⌥⇧⎋ 和 ⌘⌥⇧⌃⎋ 保留用于强制退出,无法解除绑定。AltTab 无法使用它们。\n\n您的更改会将其中之一分配给:%@。";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS 将 ⌘⌥⎋、⌘⌥⇧⎋ 和 ⌘⌥⇧⌃⎋ 保留用于强制退出,无法解除绑定。CommandTabFree 无法使用它们。\n\n您的更改会将其中之一分配给:%@。";
|
||||
"Manage activations, view receipts, etc" = "管理激活、查看收据等";
|
||||
"Maybe later" = "稍后再说";
|
||||
"Medium" = "中";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "在快捷键中被覆盖:";
|
||||
"Paste your license key:" = "粘贴您的许可证密钥:";
|
||||
"Pick a machine to deactivate so you can activate here." = "请选择一台设备停用,以便在此处激活。";
|
||||
"Please drag AltTab into the Applications folder manually." = "请手动将 AltTab 拖动到应用程序文件夹。";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "请手动将 CommandTabFree 拖动到应用程序文件夹。";
|
||||
"Please fill in the form" = "请填写表单";
|
||||
"Preview selected window" = "预览所选窗口";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "包含菜单栏的屏幕";
|
||||
"Screen including mouse" = "包含鼠标的屏幕";
|
||||
"Screen Recording" = "屏幕录制";
|
||||
"Screen showing AltTab" = "显示 AltTab 的屏幕";
|
||||
"Screen showing CommandTabFree" = "显示 CommandTabFree 的屏幕";
|
||||
"Search" = "搜索";
|
||||
"Search is a Pro feature." = "搜索是 Pro 功能。";
|
||||
"Search windows by typing" = "通过输入搜索窗口";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "窗口标题";
|
||||
"Window title contains" = "窗口标题包含";
|
||||
"You can’t undo this action." = "此操作无法撤销。";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "您有 14 天时间试用所有 Pro 功能。之后,Pro 功能将退出,核心窗口切换器将一如既往地工作。\n\nAltTab 保持免费和开源。Pro 是可选的一次性购买,用于资助持续开发。";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "您有 14 天时间试用所有 Pro 功能。之后,Pro 功能将退出,核心窗口切换器将一如既往地工作。\n\nCommandTabFree 保持免费和开源。Pro 是可选的一次性购买,用于资助持续开发。";
|
||||
"You may need to disable some conflicting system gestures" = "您可能需要禁用一些冲突的系统手势";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "您正处于 14 天中的第 4 天 — 在试用结束前试试这些:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "您当前运行的是 v%1$@。v%2$@ 已可用。您遇到的错误可能已被修复 — 请先更新并检查,再提交报告。";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "您一直在使用 %@。\n这些将在第 15 天恢复为默认值。";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "您一直在使用 %d 项 Pro 功能。\n它们将在第 15 天恢复为默认值。";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "您已使用 AltTab %1$@ 次 — 其中 %2$@ 次使用了 Pro 功能。";
|
||||
"You've used AltTab %@ times." = "您已使用 AltTab %@ 次。";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "您已使用 CommandTabFree %1$@ 次 — 其中 %2$@ 次使用了 Pro 功能。";
|
||||
"You've used CommandTabFree %@ times." = "您已使用 CommandTabFree %@ 次。";
|
||||
"Your 14-day Pro trial just ended" = "您的 14 天 Pro 试用刚刚结束";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "您的反馈将作为公开的 GitHub issue 提交。";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "您的许可证不包含此版本。请升级到 Lifetime Pro。";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "四指水平滑動";
|
||||
"4-finger Vertical Swipe" = "四指垂直滑動";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "已附上除錯資料(版本、設定、硬件)以協助診斷問題。";
|
||||
"A new version of AltTab is available" = "AltTab 有新版本可用";
|
||||
"A new version of CommandTabFree is available" = "CommandTabFree 有新版本可用";
|
||||
"About %@" = "關於 %@";
|
||||
"Accessibility" = "輔助使用";
|
||||
"Activate" = "啟用";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "所有空間";
|
||||
"Allowed" = "允許";
|
||||
"Alphabetical Order" = "依字母順序";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab 在您上次使用時當機。傳送當機報告有助於修復此問題";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab 是一款免費、開放原始碼的 macOS 視窗切換器。Pro 功能提供 14 天免費試用。";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab 正在執行,但未取得螢幕錄製權限。將不會顯示%@。";
|
||||
"AltTab needs some permissions" = "AltTab 需要一些權限";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro 在免費版切換器之上加入 4 項功能。";
|
||||
"AltTab Pro is still available" = "AltTab Pro 仍然可用";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab 會以狀態圖示顯示視窗目前是否縮到最小或全螢幕。";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree 在您上次使用時當機。傳送當機報告有助於修復此問題";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree 是一款免費、開放原始碼的 macOS 視窗切換器。Pro 功能提供 14 天免費試用。";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree 正在執行,但未取得螢幕錄製權限。將不會顯示%@。";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree 需要一些權限";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro 在免費版切換器之上加入 4 項功能。";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro 仍然可用";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree 會以狀態圖示顯示視窗目前是否縮到最小或全螢幕。";
|
||||
"Always" = "永遠";
|
||||
"Always send crash reports" = "永遠傳送當機報告";
|
||||
"and press" = "並按下";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "視窗全螢幕/取消全螢幕";
|
||||
"General" = "一般";
|
||||
"Gesture" = "手勢";
|
||||
"Get more from AltTab with Pro" = "透過 Pro 從 AltTab 獲得更多";
|
||||
"Get more from CommandTabFree with Pro" = "透過 Pro 從 CommandTabFree 獲得更多";
|
||||
"Get Pro" = "取得 Pro";
|
||||
"Go back" = "返回";
|
||||
"Grant permission" = "授予權限";
|
||||
"Group apps" = "群組 App";
|
||||
"Group tabs" = "群組分頁";
|
||||
"Help improve AltTab" = "協助改善 AltTab";
|
||||
"Help improve CommandTabFree" = "協助改善 CommandTabFree";
|
||||
"Hide" = "隱藏";
|
||||
"Hide colored circles on mouse hover" = "滑鼠懸停時隱藏彩色圓圈";
|
||||
"Hide Space number labels" = "隱藏空間編號標籤";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "大";
|
||||
"Later" = "稍後";
|
||||
"License doesn't cover this version" = "授權不涵蓋此版本";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "授權仍然有效,可在任何機器上啟用 AltTab";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "授權仍然有效,可在任何機器上啟用 CommandTabFree";
|
||||
"Light" = "淺色";
|
||||
"Lock search" = "鎖定搜尋";
|
||||
"Machine %@" = "機器 %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS 保留 ⌘⌥⎋、⌘⌥⇧⎋ 及 ⌘⌥⇧⌃⎋ 用於強制結束,無法解除綁定。AltTab 無法使用這些組合鍵。\n\n您的變更會將其中之一指派給:%@。";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS 保留 ⌘⌥⎋、⌘⌥⇧⎋ 及 ⌘⌥⇧⌃⎋ 用於強制結束,無法解除綁定。CommandTabFree 無法使用這些組合鍵。\n\n您的變更會將其中之一指派給:%@。";
|
||||
"Manage activations, view receipts, etc" = "管理啟用、查看收據等";
|
||||
"Maybe later" = "稍後再說";
|
||||
"Medium" = "中";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "在快捷鍵中覆寫:";
|
||||
"Paste your license key:" = "貼上您的授權金鑰:";
|
||||
"Pick a machine to deactivate so you can activate here." = "請選一部機器停用,以便在此啟用。";
|
||||
"Please drag AltTab into the Applications folder manually." = "請手動將 AltTab 拖至「應用程式」檔案夾。";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "請手動將 CommandTabFree 拖至「應用程式」檔案夾。";
|
||||
"Please fill in the form" = "請填寫表格";
|
||||
"Preview selected window" = "預覽選取的視窗";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "包含選單列的螢幕";
|
||||
"Screen including mouse" = "包含滑鼠的螢幕";
|
||||
"Screen Recording" = "螢幕錄製";
|
||||
"Screen showing AltTab" = "顯示 AltTab 的螢幕";
|
||||
"Screen showing CommandTabFree" = "顯示 CommandTabFree 的螢幕";
|
||||
"Search" = "搜尋";
|
||||
"Search is a Pro feature." = "搜尋是 Pro 功能。";
|
||||
"Search windows by typing" = "輸入文字搜尋視窗";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "視窗標題";
|
||||
"Window title contains" = "視窗標題包含";
|
||||
"You can’t undo this action." = "您無法復原此動作。";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "您有 14 天時間試用所有 Pro 功能。之後,Pro 功能會退回,而核心視窗切換器將會繼續如常運作。\n\nAltTab 保持免費且開放原始碼。Pro 是可選的一次性購買,用於資助持續開發。";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "您有 14 天時間試用所有 Pro 功能。之後,Pro 功能會退回,而核心視窗切換器將會繼續如常運作。\n\nCommandTabFree 保持免費且開放原始碼。Pro 是可選的一次性購買,用於資助持續開發。";
|
||||
"You may need to disable some conflicting system gestures" = "您可能需要停用一些有衝突的系統手勢";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "您正處於 14 天的第 4 天 — 在試用結束前試試這些:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "您正在使用 v%1$@。v%2$@ 已可用。您遇到的問題可能已經修復 — 請先更新並檢查後再回報。";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "您一直在使用 %@。\n這些將會在第 15 天切換回預設值。";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "您一直在使用 %d 項 Pro 功能。\n這些將會在第 15 天切換回預設值。";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "您已使用 AltTab %1$@ 次 — 其中 %2$@ 次使用了 Pro 功能。";
|
||||
"You've used AltTab %@ times." = "您已使用 AltTab %@ 次。";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "您已使用 CommandTabFree %1$@ 次 — 其中 %2$@ 次使用了 Pro 功能。";
|
||||
"You've used CommandTabFree %@ times." = "您已使用 CommandTabFree %@ 次。";
|
||||
"Your 14-day Pro trial just ended" = "您的 14 天 Pro 試用剛剛結束";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "您的意見將會以公開的 GitHub issue 形式提交。";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "您的授權不涵蓋此版本。請升級至 Lifetime Pro。";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"4-finger Horizontal Swipe" = "四指水平滑動";
|
||||
"4-finger Vertical Swipe" = "四指垂直滑動";
|
||||
"A debug profile (versions, settings, hardware) is attached to help diagnose the issue." = "已附上除錯資訊(版本、設定、硬體)以協助診斷問題。";
|
||||
"A new version of AltTab is available" = "AltTab 有新版本可供使用";
|
||||
"A new version of CommandTabFree is available" = "CommandTabFree 有新版本可供使用";
|
||||
"About %@" = "關於 %@";
|
||||
"Accessibility" = "輔助使用";
|
||||
"Activate" = "啟用";
|
||||
@@ -26,14 +26,14 @@
|
||||
"All Spaces" = "所有 Spaces";
|
||||
"Allowed" = "允許";
|
||||
"Alphabetical Order" = "字母順序";
|
||||
"AltTab crashed last time you used it. Sending a crash report will help get the issue fixed" = "AltTab 在您上次使用時當機。傳送當機報告有助於修正問題";
|
||||
"AltTab is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "AltTab 是一款免費、開放原始碼的 macOS 視窗切換器。Pro 功能提供 14 天免費試用。";
|
||||
"AltTab is running without Screen Recording permissions. %@ won’t show." = "AltTab 正在沒有「螢幕錄影」權限的情況下執行,將無法顯示%@。";
|
||||
"AltTab needs some permissions" = "AltTab 需要部分權限";
|
||||
"AltTab Pro" = "AltTab Pro";
|
||||
"AltTab Pro adds 4 features beyond the free switcher." = "AltTab Pro 在免費版切換器之上新增了 4 項功能。";
|
||||
"AltTab Pro is still available" = "AltTab Pro 仍可供使用";
|
||||
"AltTab will show if the window is currently minimized or fullscreen with a status icon." = "AltTab 會以狀態圖示顯示視窗目前是否為最小化或全螢幕。";
|
||||
"CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed" = "CommandTabFree 在您上次使用時當機。傳送當機報告有助於修正問題";
|
||||
"CommandTabFree is a free, open-source window switcher for macOS. Pro features are available with a 14-day free trial." = "CommandTabFree 是一款免費、開放原始碼的 macOS 視窗切換器。Pro 功能提供 14 天免費試用。";
|
||||
"CommandTabFree is running without Screen Recording permissions. %@ won’t show." = "CommandTabFree 正在沒有「螢幕錄影」權限的情況下執行,將無法顯示%@。";
|
||||
"CommandTabFree needs some permissions" = "CommandTabFree 需要部分權限";
|
||||
"CommandTabFree Pro" = "CommandTabFree Pro";
|
||||
"CommandTabFree Pro adds 4 features beyond the free switcher." = "CommandTabFree Pro 在免費版切換器之上新增了 4 項功能。";
|
||||
"CommandTabFree Pro is still available" = "CommandTabFree Pro 仍可供使用";
|
||||
"CommandTabFree will show if the window is currently minimized or fullscreen with a status icon." = "CommandTabFree 會以狀態圖示顯示視窗目前是否為最小化或全螢幕。";
|
||||
"Always" = "永遠";
|
||||
"Always send crash reports" = "永遠傳送當機報告";
|
||||
"and press" = "並按下";
|
||||
@@ -112,13 +112,13 @@
|
||||
"Fullscreen/Defullscreen window" = "進入/退出全螢幕視窗";
|
||||
"General" = "一般";
|
||||
"Gesture" = "手勢";
|
||||
"Get more from AltTab with Pro" = "透過 Pro 取得 AltTab 的更多功能";
|
||||
"Get more from CommandTabFree with Pro" = "透過 Pro 取得 CommandTabFree 的更多功能";
|
||||
"Get Pro" = "取得 Pro";
|
||||
"Go back" = "返回";
|
||||
"Grant permission" = "授予權限";
|
||||
"Group apps" = "將 App 分組";
|
||||
"Group tabs" = "將分頁分組";
|
||||
"Help improve AltTab" = "協助改善 AltTab";
|
||||
"Help improve CommandTabFree" = "協助改善 CommandTabFree";
|
||||
"Hide" = "隱藏";
|
||||
"Hide colored circles on mouse hover" = "隱藏滑鼠停留時的彩色圓點";
|
||||
"Hide Space number labels" = "隱藏 Space 編號標籤";
|
||||
@@ -138,11 +138,11 @@
|
||||
"Large" = "大";
|
||||
"Later" = "稍後";
|
||||
"License doesn't cover this version" = "授權不涵蓋此版本";
|
||||
"License will remain valid and usable to activate AltTab on any machine" = "授權仍然有效,可在任何裝置上啟用 AltTab";
|
||||
"License will remain valid and usable to activate CommandTabFree on any machine" = "授權仍然有效,可在任何裝置上啟用 CommandTabFree";
|
||||
"Light" = "淺色";
|
||||
"Lock search" = "鎖定搜尋";
|
||||
"Machine %@" = "裝置 %@";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@." = "macOS 將 ⌘⌥⎋、⌘⌥⇧⎋ 和 ⌘⌥⇧⌃⎋ 保留作「強制結束」之用,且無法解除指派。AltTab 無法使用這些組合鍵。\n\n您的變更會將其中之一指派給:%@。";
|
||||
"macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@." = "macOS 將 ⌘⌥⎋、⌘⌥⇧⎋ 和 ⌘⌥⇧⌃⎋ 保留作「強制結束」之用,且無法解除指派。CommandTabFree 無法使用這些組合鍵。\n\n您的變更會將其中之一指派給:%@。";
|
||||
"Manage activations, view receipts, etc" = "管理啟用、查看收據等";
|
||||
"Maybe later" = "稍後再說";
|
||||
"Medium" = "中";
|
||||
@@ -174,7 +174,7 @@
|
||||
"Overridden in Shortcut:" = "已在快速鍵中覆寫:";
|
||||
"Paste your license key:" = "貼上您的授權金鑰:";
|
||||
"Pick a machine to deactivate so you can activate here." = "選擇要停用的裝置,以便在此啟用。";
|
||||
"Please drag AltTab into the Applications folder manually." = "請手動將 AltTab 拖移到「應用程式」資料夾。";
|
||||
"Please drag CommandTabFree into the Applications folder manually." = "請手動將 CommandTabFree 拖移到「應用程式」資料夾。";
|
||||
"Please fill in the form" = "請填寫表單";
|
||||
"Preview selected window" = "預覽所選視窗";
|
||||
"Pro" = "Pro";
|
||||
@@ -199,7 +199,7 @@
|
||||
"Screen including menu bar" = "包含選單列的螢幕";
|
||||
"Screen including mouse" = "包含滑鼠的螢幕";
|
||||
"Screen Recording" = "螢幕錄影";
|
||||
"Screen showing AltTab" = "顯示 AltTab 的螢幕";
|
||||
"Screen showing CommandTabFree" = "顯示 CommandTabFree 的螢幕";
|
||||
"Search" = "搜尋";
|
||||
"Search is a Pro feature." = "搜尋是 Pro 功能。";
|
||||
"Search windows by typing" = "輸入文字以搜尋視窗";
|
||||
@@ -304,14 +304,14 @@
|
||||
"Window Title" = "視窗標題";
|
||||
"Window title contains" = "視窗標題包含";
|
||||
"You can’t undo this action." = "此動作無法復原。";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nAltTab stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "您有 14 天可以試用所有 Pro 功能。在那之後,Pro 功能會退場,核心視窗切換器將如同以往繼續運作。\n\nAltTab 維持免費且開放原始碼。Pro 是選用的一次性購買,用以資助持續開發。";
|
||||
"You have 14 days to try all Pro features. After that, Pro features will step back and the core window switcher will keep working exactly as before.\n\nCommandTabFree stays free and open-source. Pro is an optional one-time purchase that funds continued development." = "您有 14 天可以試用所有 Pro 功能。在那之後,Pro 功能會退場,核心視窗切換器將如同以往繼續運作。\n\nCommandTabFree 維持免費且開放原始碼。Pro 是選用的一次性購買,用以資助持續開發。";
|
||||
"You may need to disable some conflicting system gestures" = "您可能需要停用某些衝突的系統手勢";
|
||||
"You're on day 4 of 14 — try these before your trial ends:" = "您正處於第 4 天/共 14 天 — 請在試用結束前試試這些功能:";
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting." = "您正在執行 v%1$@。v%2$@ 已推出。您遇到的問題可能已修正 — 請先更新並確認後再回報。";
|
||||
"You've been using %@.\nThese will switch back to defaults on Day 15." = "您一直在使用 %@。\n這些將在第 15 天還原為預設值。";
|
||||
"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15." = "您一直在使用 %d 項 Pro 功能。\n這些將在第 15 天還原為預設值。";
|
||||
"You've used AltTab %@ times — %@ of those used Pro features." = "您已使用 AltTab %1$@ 次 — 其中 %2$@ 次使用了 Pro 功能。";
|
||||
"You've used AltTab %@ times." = "您已使用 AltTab %@ 次。";
|
||||
"You've used CommandTabFree %@ times — %@ of those used Pro features." = "您已使用 CommandTabFree %1$@ 次 — 其中 %2$@ 次使用了 Pro 功能。";
|
||||
"You've used CommandTabFree %@ times." = "您已使用 CommandTabFree %@ 次。";
|
||||
"Your 14-day Pro trial just ended" = "您的 14 天 Pro 試用剛結束";
|
||||
"Your feedback will be submitted as a public GitHub issue." = "您的意見回饋將以公開的 GitHub issue 形式提交。";
|
||||
"Your license doesn't cover this version. Upgrade to Lifetime Pro." = "您的授權不涵蓋此版本。請升級到 Lifetime Pro。";
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
set -ex
|
||||
|
||||
set -o pipefail && xcodebuild -project alt-tab-macos.xcodeproj -scheme Release -derivedDataPath DerivedData | scripts/xcbeautify
|
||||
set -o pipefail && xcodebuild -project alt-tab-macos.xcodeproj -scheme Release -derivedDataPath DerivedData "$@" | scripts/xcbeautify
|
||||
file "$BUILD_DIR/$XCODE_BUILD_PATH/$APP_NAME.app/Contents/MacOS/$APP_NAME"
|
||||
@@ -7,4 +7,4 @@ certificateFile="codesign"
|
||||
# Recreate the certificate from the secure environment variable
|
||||
echo "$APPLE_P12_CERTIFICATE" | base64 --decode > $certificateFile.p12
|
||||
|
||||
scripts/codesign/import_certificate_into_new_keychain.sh "$certificateFile" ""
|
||||
scripts/codesign/import_certificate_into_new_keychain.sh "$certificateFile" "${APPLE_P12_PASSWORD:-}"
|
||||
@@ -2,9 +2,21 @@
|
||||
|
||||
set -exu
|
||||
|
||||
# Inject CI values via the same xcconfig override mechanism used locally.
|
||||
# Xcode substitutes $(VAR) refs into Info.plist at build time.
|
||||
cat > config/local.xcconfig <<EOF
|
||||
# alt-tab-free [identity]: see docs/PLAN-maintained-fork.md §3.1 CI caveat.
|
||||
#
|
||||
# Upstream did `cat > config/local.xcconfig` (a TRUNCATING overwrite) writing BOTH
|
||||
# CURRENT_PROJECT_VERSION and APPCENTER_SECRET, which would clobber the fork's COMMITTED
|
||||
# identity block (PRODUCT_NAME / bundle id / DOMAIN / signing) on every CI run.
|
||||
#
|
||||
# Fork fix:
|
||||
# - APPEND only (>>), never truncate — preserve the committed identity block.
|
||||
# - Write ONLY CURRENT_PROJECT_VERSION. It is NOT in the committed block, so a single
|
||||
# appended line is collision-free (xcconfig is last-wins).
|
||||
# - DROP the APPCENTER_SECRET line entirely: telemetry is off (Edit 3, §2) and the
|
||||
# committed block already sets APPCENTER_SECRET= — re-appending it would create a
|
||||
# duplicate key.
|
||||
#
|
||||
# Xcode substitutes $(CURRENT_PROJECT_VERSION) into Info.plist at build time.
|
||||
cat >> config/local.xcconfig <<EOF
|
||||
CURRENT_PROJECT_VERSION = $(cat "$VERSION_FILE")
|
||||
APPCENTER_SECRET = $APPCENTER_SECRET
|
||||
EOF
|
||||
@@ -1,21 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# alt-tab-free: fork-owned rewrite of upstream's update_appcast.sh (PLAN §3.3/§4.0/§4.3).
|
||||
#
|
||||
# Upstream appended the signed Sparkle <item> into the IN-TREE, committed appcast.xml. That is wrong
|
||||
# for the fork three ways: (1) every upstream + fork release edits it -> guaranteed merge conflicts;
|
||||
# (2) the fork would serve entries signed with lwouis's key its own SUPublicEDKey can't verify; and
|
||||
# (3) SAFETY-CRITICAL — upstream's enclosure URL points at lwouis's releases, so Sparkle auto-update
|
||||
# would pull the UPSTREAM PAYWALLED binary as the "update". This rewrite:
|
||||
# - points the enclosure at THIS FORK's releases (the single most safety-critical edit, PLAN §4.0);
|
||||
# - points releaseNotesLink at the fork's release tag (was https://alt-tab.app/changelog-bare);
|
||||
# - emits the feed OUT OF TREE (gh-pages branch) instead of editing the in-tree appcast.xml; and
|
||||
# - injects a GPL §6(d) corresponding-source pointer into the appcast <description> so users who
|
||||
# receive object code ONLY via auto-update still get a source pointer (PLAN §5(1)).
|
||||
#
|
||||
# This file is in the §6.2 "keep ours" recurring-conflict set.
|
||||
|
||||
set -exu
|
||||
|
||||
# GITHUB_REPOSITORY is "owner/repo" of THIS fork in GitHub Actions; deriving the URLs from it keeps
|
||||
# this script fork-agnostic (no hardcoded owner). OWNER-OVERRIDABLE-BEFORE-PUBLISH: if you run this
|
||||
# outside Actions, export GITHUB_REPOSITORY=<owner>/<repo> first.
|
||||
fork_repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY (owner/repo of the fork) must be set}"
|
||||
# OWNER-OVERRIDABLE-BEFORE-PUBLISH: the host that serves the Sparkle feed (Endpoints.appcastUrl is
|
||||
# "https://$DOMAIN/appcast.xml" via the DOMAIN cascade). The feed is published to the gh-pages branch
|
||||
# of this repo by default; point $DOMAIN at that gh-pages site (or CNAME it). See PLAN §3.3.
|
||||
feed_branch="${APPCAST_FEED_BRANCH:-gh-pages}"
|
||||
feed_path="${APPCAST_FEED_PATH:-appcast.xml}"
|
||||
|
||||
version="$(cat "$VERSION_FILE")"
|
||||
date="$(date +'%a, %d %b %Y %H:%M:%S %z')"
|
||||
minimumSystemVersion="$(awk -F ' = ' '/MACOSX_DEPLOYMENT_TARGET/ { print $2; }' < config/base.xcconfig)"
|
||||
zipName="$APP_NAME-$version.zip"
|
||||
edSignatureAndLength=$(vendor/Sparkle/bin/sign_update -s $SPARKLE_ED_PRIVATE_KEY "$XCODE_BUILD_PATH/$zipName")
|
||||
# alt-tab-free [depaywall]: the `-s <private-key>` flag is DEPRECATED and, per `sign_update --help`,
|
||||
# "no longer supported for newly generated keys" — and the fork's EdDSA keypair was newly generated
|
||||
# (EXECUTION-STATUS §3.3, 2026-06-15), so `-s` would REJECT it. Pipe the secret to the documented
|
||||
# `--ed-key-file -` (read key from stdin) form instead. Do NOT add `-p`: the default output is the
|
||||
# `sparkle:edSignature="…" length="…"` attribute pair the <enclosure> needs verbatim, whereas `-p`
|
||||
# prints only the bare signature and would break the enclosure. Reading from stdin also keeps the key
|
||||
# off the argv (process listing), unlike the old `-s $KEY` form.
|
||||
edSignatureAndLength=$(printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | vendor/Sparkle/bin/sign_update --ed-key-file - "$XCODE_BUILD_PATH/$zipName")
|
||||
|
||||
# GPL §6(d) corresponding-source pointer for object code conveyed via Sparkle auto-update.
|
||||
correspondingSource="https://github.com/$fork_repo/archive/refs/tags/v$version.tar.gz"
|
||||
|
||||
# alt-tab-free [depaywall]: enclosure -> THIS FORK's release asset (NOT lwouis's); releaseNotesLink
|
||||
# -> the fork's release tag; <description> carries the GPL §6(d) source pointer.
|
||||
echo "
|
||||
<item>
|
||||
<title>Version $version</title>
|
||||
<pubDate>$date</pubDate>
|
||||
<description>Corresponding source (GPL-3.0): $correspondingSource</description>
|
||||
<sparkle:minimumSystemVersion>$minimumSystemVersion</sparkle:minimumSystemVersion>
|
||||
<sparkle:releaseNotesLink>https://alt-tab.app/changelog-bare</sparkle:releaseNotesLink>
|
||||
<sparkle:releaseNotesLink>https://github.com/$fork_repo/releases/tag/v$version</sparkle:releaseNotesLink>
|
||||
<enclosure
|
||||
url=\"https://github.com/lwouis/alt-tab-macos/releases/download/v$version/$zipName\"
|
||||
url=\"https://github.com/$fork_repo/releases/download/v$version/$zipName\"
|
||||
sparkle:version=\"$version\"
|
||||
sparkle:shortVersionString=\"$version\"
|
||||
$edSignatureAndLength
|
||||
@@ -23,4 +61,37 @@ echo "
|
||||
</item>
|
||||
" > ITEM.txt
|
||||
|
||||
sed -i '' -e "/<\/language>/r ITEM.txt" appcast.xml
|
||||
# alt-tab-free [depaywall]: publish OUT OF TREE — fetch the gh-pages copy of the feed, append the new
|
||||
# <item>, and push it back. The in-tree appcast.xml is an empty skeleton kept only until the owner
|
||||
# removes it from the tree (see ownerActionsNeeded / PLAN §3.3). Never edit the in-tree feed here.
|
||||
work_dir="$(mktemp -d)"
|
||||
git clone --depth 1 --branch "$feed_branch" \
|
||||
"https://x-access-token:${GITHUB_TOKEN}@github.com/$fork_repo.git" "$work_dir/feed" 2>/dev/null || {
|
||||
# gh-pages does not exist yet: seed an empty Sparkle feed skeleton.
|
||||
git clone --depth 1 "https://x-access-token:${GITHUB_TOKEN}@github.com/$fork_repo.git" "$work_dir/feed"
|
||||
git -C "$work_dir/feed" checkout --orphan "$feed_branch"
|
||||
git -C "$work_dir/feed" rm -rf . >/dev/null 2>&1 || true
|
||||
cat > "$work_dir/feed/$feed_path" <<'SKELETON'
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>alt-tab-free</title>
|
||||
<language>en</language>
|
||||
</channel>
|
||||
</rss>
|
||||
SKELETON
|
||||
}
|
||||
|
||||
# Append the new <item> just before </channel> (the channel close, not </language>, so the skeleton
|
||||
# need not carry a <language> line for the append to work).
|
||||
awk -v item_file="$PWD/ITEM.txt" '
|
||||
/<\/channel>/ && !done { while ((getline line < item_file) > 0) print line; done=1 }
|
||||
{ print }
|
||||
' "$work_dir/feed/$feed_path" > "$work_dir/feed/$feed_path.new"
|
||||
mv "$work_dir/feed/$feed_path.new" "$work_dir/feed/$feed_path"
|
||||
|
||||
git -C "$work_dir/feed" config user.name "github-actions[bot]"
|
||||
git -C "$work_dir/feed" config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git -C "$work_dir/feed" add "$feed_path"
|
||||
git -C "$work_dir/feed" commit -m "chore(appcast): publish $APP_NAME v$version"
|
||||
git -C "$work_dir/feed" push origin "$feed_branch"
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -exu
|
||||
|
||||
github_api_request() {
|
||||
local url="$1"
|
||||
curl -s \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/lwouis/alt-tab-macos$url"
|
||||
}
|
||||
|
||||
unicode_sort() {
|
||||
python3 -c 'import sys, unicodedata; print("".join(sorted((line for line in sys.stdin if line.strip()), key=lambda s: unicodedata.normalize("NFKD", s.casefold()))), end="")'
|
||||
}
|
||||
|
||||
github_contributors() {
|
||||
github_api_request "/contributors" |
|
||||
jq -r '.[]|("[" + .login + "](" + .html_url + ")")' |
|
||||
sed -e '/semantic-release-bot/d' |
|
||||
unicode_sort |
|
||||
awk '{printf "%s%s", sep, $0; sep=", "} END{print ""}'
|
||||
}
|
||||
|
||||
# Rewrite only the "Developed the app" section of docs/contributors.md, preserving the
|
||||
# frozen "Localized the app" section below it.
|
||||
update_developer_contributors() {
|
||||
local file="docs/contributors.md"
|
||||
{
|
||||
echo "## [Developed the app](https://github.com/lwouis/alt-tab-macos/graphs/contributors)"
|
||||
echo
|
||||
github_contributors
|
||||
echo
|
||||
sed -n '/## Localized the app/,$p' "$file"
|
||||
} > "$file.tmp" && mv "$file.tmp" "$file"
|
||||
}
|
||||
|
||||
get_total_downloads() {
|
||||
local downloads=0
|
||||
local page=1
|
||||
while true; do
|
||||
local response
|
||||
response=$(github_api_request "/releases?per_page=100&page=$page")
|
||||
# Safely sum all download counts, even if assets are empty
|
||||
local count
|
||||
count=$(echo "$response" | jq '[.[]?.assets[]?.download_count // 0] | add // 0')
|
||||
downloads=$((downloads + count))
|
||||
# Stop if no more releases
|
||||
if [[ $(echo "$response" | jq 'length') -lt 1 ]]; then
|
||||
break
|
||||
fi
|
||||
page=$((page + 1))
|
||||
done
|
||||
echo "$downloads"
|
||||
}
|
||||
|
||||
get_stars() {
|
||||
github_api_request "" | jq '.stargazers_count'
|
||||
}
|
||||
|
||||
format_number() {
|
||||
local NUM=$1
|
||||
if (( NUM >= 1000000 )); then
|
||||
printf "%.1fM" "$(echo "$NUM/1000000" | bc -l)"
|
||||
elif (( NUM >= 1000 )); then
|
||||
printf "%.0fK" "$(echo "$NUM/1000" | bc -l)"
|
||||
else
|
||||
printf "%d" "$NUM"
|
||||
fi
|
||||
}
|
||||
|
||||
downloads=$(format_number "$(get_total_downloads)")
|
||||
stars=$(format_number "$(get_stars)")
|
||||
|
||||
# Stats are baked into docs/readme/main.svg (one consolidated SVG that holds
|
||||
# the hero, stats, CTAs, and screenshot). Each stat text element is preceded by
|
||||
# an XML comment marker — anchor sed on the marker and replace whatever value
|
||||
# follows up to the next `<` (i.e. the closing `</text>`).
|
||||
sed -i "" -E "s|(<!--downloads-->)[^<]*|\1${downloads}|" "docs/readme/main.svg"
|
||||
sed -i "" -E "s|(<!--stars-->)[^<]*|\1${stars}|" "docs/readme/main.svg"
|
||||
|
||||
update_developer_contributors
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -exu
|
||||
|
||||
gh api repos/lwouis/alt-tab-website/dispatches -f event_type=update-website
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -exu
|
||||
|
||||
ownerName="alt-tab-macos"
|
||||
appName="alt-tab-macos"
|
||||
url="https://api.appcenter.ms/v0.1/apps/$ownerName/$appName"
|
||||
version="$(cat "$VERSION_FILE")"
|
||||
|
||||
# Upload a single .dSYM bundle to AppCenter (zip, request slot, PUT to blob, mark committed).
|
||||
# Called once per dSYM produced by the Release build:
|
||||
# - AltTab.app.dSYM host binary + everything statically linked (AppCenter, AppCenterCrashes, ShortcutRecorder)
|
||||
# - Sparkle.framework.dSYM Sparkle is the only .dynamic SPM product, so it has its own dSYM
|
||||
# CrashReporter.xcframework ships without dSYMs (PLCrashReporter is binary-only); AppCenter has
|
||||
# server-side symbols for it.
|
||||
function upload_dsym() {
|
||||
local symbolFile="$1"
|
||||
ditto -c -k --keepParent "$symbolFile" "$symbolFile.zip"
|
||||
|
||||
local c1 symbol_upload_id upload_url
|
||||
c1="$(curl -X POST "$url/symbol_uploads" \
|
||||
-H "X-API-Token: $APPCENTER_TOKEN" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{ \"symbol_type\": \"Apple\", \"version\": \"$version\" }")"
|
||||
symbol_upload_id="$(jq -r '.symbol_upload_id' <<<"$c1")"
|
||||
upload_url="$(jq -r '.upload_url' <<<"$c1")"
|
||||
|
||||
curl -X PUT "$upload_url" \
|
||||
-H "x-ms-blob-type: BlockBlob" \
|
||||
--upload-file "$symbolFile.zip"
|
||||
|
||||
curl -X PATCH "$url/symbol_uploads/$symbol_upload_id" \
|
||||
-H "X-API-Token: $APPCENTER_TOKEN" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "status": "committed" }'
|
||||
}
|
||||
|
||||
cd "$XCODE_BUILD_PATH"
|
||||
upload_dsym "$APP_NAME.app.dSYM"
|
||||
upload_dsym "Sparkle.framework.dSYM"
|
||||
+2
-1
@@ -13,7 +13,7 @@ class App: AppCenterApplication {
|
||||
static let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String
|
||||
static let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String
|
||||
static let licence = Bundle.main.object(forInfoDictionaryKey: "NSHumanReadableCopyright") as! String
|
||||
static let repository = "https://github.com/lwouis/alt-tab-macos"
|
||||
static let repository = "https://github.com/KofTwentyTwo/CommandTabFree"
|
||||
static let appIconReps = CGImage.allNamed("app.icns")
|
||||
|
||||
static func appIcon(for size: NSSize) -> CGImage {
|
||||
@@ -471,6 +471,7 @@ extension App: NSApplicationDelegate {
|
||||
}
|
||||
|
||||
private func handleCustomUrl(_ url: URL) {
|
||||
guard false else { return } // depaywall: scheme-gated activate handler disabled so it can never POST the machine fingerprint — see docs/AUDIT.md
|
||||
guard url.host == "activate",
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let licenseKey = components.queryItems?.first(where: { $0.name == "license_key" })?.value,
|
||||
|
||||
+3
-3
@@ -62,7 +62,7 @@ class MainMenu {
|
||||
// MARK: - Menu builders
|
||||
|
||||
private static func appMenuItem() -> NSMenuItem {
|
||||
let menu = NSMenu(title: "AltTab")
|
||||
let menu = NSMenu(title: "CommandTabFree")
|
||||
menu.addItem(item("Preferences…", "orderFrontPreferencesPanel:", ","))
|
||||
menu.addItem(.separator())
|
||||
let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "")
|
||||
@@ -73,7 +73,7 @@ class MainMenu {
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(item("Show All", "unhideAllApplications:"))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(item("Quit AltTab", "terminate:", "q"))
|
||||
menu.addItem(item("Quit CommandTabFree", "terminate:", "q"))
|
||||
return menuBarItem(menu)
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ class MainMenu {
|
||||
|
||||
private static func helpMenuItem() -> NSMenuItem {
|
||||
let menu = NSMenu(title: "Help")
|
||||
menu.addItem(item("AltTab Help", "showHelp:", "?"))
|
||||
menu.addItem(item("CommandTabFree Help", "showHelp:", "?"))
|
||||
NSApp.helpMenu = menu
|
||||
return menuBarItem(menu)
|
||||
}
|
||||
|
||||
+1
-1
@@ -440,6 +440,6 @@ class PermissionCallout: StackView {
|
||||
case .both: subject = NSLocalizedString("Thumbnails and window previews", comment: "Menubar callout subject")
|
||||
case .none: return ""
|
||||
}
|
||||
return String(format: NSLocalizedString("AltTab is running without Screen Recording permissions. %@ won’t show.", comment: "Menubar callout. %@ is one or more feature names, e.g. Thumbnails"), subject)
|
||||
return String(format: NSLocalizedString("CommandTabFree is running without Screen Recording permissions. %@ won’t show.", comment: "Menubar callout. %@ is one or more feature names, e.g. Thumbnails"), subject)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ class CliClient {
|
||||
print("Couldn't execute command. Is it correct?")
|
||||
exit(1)
|
||||
} catch {
|
||||
print("AltTab.app needs to be running for CLI commands to work")
|
||||
print("CommandTabFree.app needs to be running for CLI commands to work")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ class CustomRecorderControl: RecorderControl {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = NSLocalizedString("Conflicting shortcut", comment: "")
|
||||
alert.informativeText = String(format: NSLocalizedString("macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@.", comment: ""), label)
|
||||
alert.informativeText = String(format: NSLocalizedString("macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. CommandTabFree cannot use them.\n\nYour change would assign one of these to: %@.", comment: ""), label)
|
||||
alert.addButton(withTitle: NSLocalizedString("Unassign existing shortcut and continue", comment: ""))
|
||||
let cancelButton = alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
|
||||
cancelButton.keyEquivalent = "\u{1b}"
|
||||
|
||||
@@ -184,7 +184,7 @@ enum ScreensToShowPreference: CaseIterable, MacroPreference {
|
||||
var localizedString: LocalizedString {
|
||||
switch self {
|
||||
case .all: return NSLocalizedString("All screens", comment: "")
|
||||
case .showingAltTab: return NSLocalizedString("Screen showing AltTab", comment: "")
|
||||
case .showingAltTab: return NSLocalizedString("Screen showing CommandTabFree", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,61 +5,60 @@ class AboutTab {
|
||||
makeContentView()
|
||||
}
|
||||
|
||||
static func makeContentView(_ fitToContent: Bool = true, _ showFeedbackButton: Bool = true, _ centerHero: Bool = false) -> NSView {
|
||||
static func makeContentView(columnWidth: CGFloat = 280, _ fitToContent: Bool = true) -> NSView {
|
||||
let appIcon = LightImageView()
|
||||
appIcon.translatesAutoresizingMaskIntoConstraints = false
|
||||
let appIconSize = NSSize(width: 128, height: 128)
|
||||
appIcon.updateContents(.cgImage(App.appIcon(for: appIconSize)), appIconSize)
|
||||
appIcon.fit(128, 128)
|
||||
let appText = StackView([
|
||||
let heroText = StackView([
|
||||
BoldLabel(App.name),
|
||||
NSTextField(wrappingLabelWithString: NSLocalizedString("Version", comment: "") + " " + App.version),
|
||||
NSTextField(wrappingLabelWithString: App.licence),
|
||||
HyperlinkLabel(NSLocalizedString("Website", comment: ""), Endpoints.website),
|
||||
HyperlinkLabel(NSLocalizedString("Source code", comment: ""), App.repository),
|
||||
], .vertical)
|
||||
appText.spacing = GridView.interPadding / 2
|
||||
let rowToSeparate = 3
|
||||
appText.views[rowToSeparate].topAnchor.constraint(equalTo: appText.views[rowToSeparate - 1].bottomAnchor, constant: GridView.interPadding).isActive = true
|
||||
let appInfo = NSStackView(views: [appIcon, appText])
|
||||
appIcon.translatesAutoresizingMaskIntoConstraints = false
|
||||
appInfo.spacing = GridView.interPadding
|
||||
appInfo.alignment = .centerY
|
||||
let supportProject = makeSupportProjectButton()
|
||||
let rows = [[appInfo], [supportProject]]
|
||||
let grid = GridView(rows, 0)
|
||||
if centerHero {
|
||||
grid.cell(atColumnIndex: 0, rowIndex: 0).xPlacement = .center
|
||||
}
|
||||
let supportProjectCell = grid.cell(atColumnIndex: 0, rowIndex: showFeedbackButton ? 2 : 1)
|
||||
supportProjectCell.xPlacement = .center
|
||||
heroText.spacing = GridView.interPadding / 2
|
||||
let hero = NSStackView(views: [appIcon, heroText])
|
||||
hero.spacing = GridView.interPadding
|
||||
hero.alignment = .centerY
|
||||
hero.translatesAutoresizingMaskIntoConstraints = false
|
||||
let body = makeBodyTextView(columnWidth)
|
||||
let content = NSStackView(views: [hero, body])
|
||||
content.orientation = .vertical
|
||||
content.alignment = .leading
|
||||
content.spacing = GridView.interPadding * 2
|
||||
content.translatesAutoresizingMaskIntoConstraints = false
|
||||
if fitToContent {
|
||||
grid.fit()
|
||||
content.fit()
|
||||
}
|
||||
return grid
|
||||
return content
|
||||
}
|
||||
|
||||
static func makeSupportProjectButton() -> NSButton {
|
||||
let button = makeButtonWithIcon(NSLocalizedString("Support this project", comment: ""), App.supportProjectAction, "heart.fill", .red, App.self)
|
||||
styleSupportProjectButton(button)
|
||||
return button
|
||||
}
|
||||
|
||||
private static func styleSupportProjectButton(_ button: NSButton) {
|
||||
button.bezelStyle = .rounded
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
|
||||
private static func makeButtonWithIcon(_ title: String, _ selector: Selector, _ symbolName: String?, _ color: NSColor? = nil, _ target: AnyObject? = nil) -> NSButton {
|
||||
let button = NSButton(title: title, target: target, action: selector)
|
||||
if #available(macOS 26.0, *), let symbolName {
|
||||
button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
|
||||
button.imagePosition = .imageLeading
|
||||
if let color {
|
||||
button.image = button.image?.withSymbolConfiguration(.init(paletteColors: [color]))
|
||||
}
|
||||
}
|
||||
return button
|
||||
// The KofTwentyTwo identity + upstream-attribution body. Rendered as markdown with clickable
|
||||
// links through the same path as the Usage and Acknowledgments sections, so wrapping and layout
|
||||
// stay robust (sizing to columnWidth) instead of relying on hand-built stretch constraints.
|
||||
private static func makeBodyTextView(_ columnWidth: CGFloat) -> NSTextView {
|
||||
let markdown = [
|
||||
"## " + NSLocalizedString("A KofTwentyTwo project", comment: ""),
|
||||
"[" + NSLocalizedString("Source code", comment: "") + "](\(App.repository)) · [KofTwentyTwo](https://github.com/KofTwentyTwo)",
|
||||
"",
|
||||
"## " + NSLocalizedString("Based on AltTab by Louis Pontoise", comment: "Attribution to the upstream project AltTab; do not translate the product names"),
|
||||
String(format: NSLocalizedString("%@ is a free, de-paywalled fork. All upstream work and credit belong to Louis Pontoise and AltTab’s contributors.", comment: "%@ is the app name"), App.name),
|
||||
"",
|
||||
"[AltTab](https://alt-tab.app) · [" + NSLocalizedString("Original source", comment: "") + "](https://github.com/lwouis/alt-tab-macos) · [lwouis.com](https://lwouis.com)",
|
||||
].joined(separator: "\n")
|
||||
let textView = NSTextView()
|
||||
textView.textContainer!.widthTracksTextView = true
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
textView.drawsBackground = false
|
||||
textView.isSelectable = true
|
||||
textView.isEditable = false
|
||||
textView.enabledTextCheckingTypes = 0
|
||||
textView.frame.size.width = columnWidth
|
||||
textView.textStorage!.setAttributedString(Markdown.toAttributedString(markdown))
|
||||
textView.layoutManager!.ensureLayout(for: textView.textContainer!)
|
||||
let used = textView.layoutManager!.usedRect(for: textView.textContainer!)
|
||||
textView.fit(used.width, used.height)
|
||||
return textView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +106,8 @@ class AboutWindow: NSPanel {
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 30
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
let aboutView = AboutTab.makeContentView(false, false, true)
|
||||
let columnWidth = frame.width - 2 * Self.contentPadding
|
||||
let aboutView = AboutTab.makeContentView(columnWidth: columnWidth, false)
|
||||
usageTextView = Self.makeUsageTextView(columnWidth)
|
||||
let acknowledgmentsView = AcknowledgmentsTab.makeContentView(columnWidth: columnWidth, shouldFit: false, verticallyStacked: true)
|
||||
acknowledgmentsView.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -150,7 +149,7 @@ class AboutWindow: NSPanel {
|
||||
let weekCount = UsageStats.count("triggers", since: now.addingTimeInterval(-7 * 24 * 3600))
|
||||
let monthCount = UsageStats.count("triggers", since: now.addingTimeInterval(-30 * 24 * 3600))
|
||||
let yearCount = UsageStats.count("triggers", since: now.addingTimeInterval(-365 * 24 * 3600))
|
||||
let markdown = "## \(NSLocalizedString("Usage", comment: ""))\n\nYou have used AltTab:\n\u{2022} **\(weekCount)** times in the past week\n\u{2022} **\(monthCount)** times in the past month\n\u{2022} **\(yearCount)** times in the past year"
|
||||
let markdown = "## \(NSLocalizedString("Usage", comment: ""))\n\nYou have used \(App.name):\n\u{2022} **\(weekCount)** times in the past week\n\u{2022} **\(monthCount)** times in the past month\n\u{2022} **\(yearCount)** times in the past year"
|
||||
usageTextView.textStorage!.setAttributedString(Markdown.toAttributedString(markdown))
|
||||
usageTextView.layoutManager!.ensureLayout(for: usageTextView.textContainer!)
|
||||
let usedRect = usageTextView.layoutManager!.usedRect(for: usageTextView.textContainer!)
|
||||
|
||||
@@ -154,7 +154,7 @@ class ShowHideIllustratedView {
|
||||
// duplicating `NSLocalizedString` calls. Each is the single source of truth for that row's
|
||||
// localized text; `setupItems` below uses these constants when building the rows.
|
||||
static let hideStatusIconsLabel = NSLocalizedString("Hide status icons", comment: "")
|
||||
static let hideStatusIconsSubtitle = NSLocalizedString("AltTab will show if the window is currently minimized or fullscreen with a status icon.", comment: "")
|
||||
static let hideStatusIconsSubtitle = NSLocalizedString("CommandTabFree will show if the window is currently minimized or fullscreen with a status icon.", comment: "")
|
||||
static let hideSpaceNumberLabelsLabel = NSLocalizedString("Hide Space number labels", comment: "")
|
||||
static let hideColoredCirclesLabel = NSLocalizedString("Hide colored circles on mouse hover", comment: "")
|
||||
|
||||
|
||||
@@ -3,20 +3,22 @@ import Foundation
|
||||
/// Writes a `license` cookie on the alt-tab.app domain so Sparkle's appcast request can be tailored per tier.
|
||||
/// Called from App.swift's `onStateChanged` hook after every LicenseManager state transition.
|
||||
func syncLicenseCookie(state: LicenseState) {
|
||||
guard let host = URL(string: Endpoints.website)?.host else { return }
|
||||
let value: String
|
||||
switch state {
|
||||
case .pro: value = "pro"
|
||||
case .proExpired: value = "proExpired"
|
||||
default: value = ""
|
||||
if false { // depaywall: never write a license cookie to the upstream domain — see docs/AUDIT.md
|
||||
guard let host = URL(string: Endpoints.website)?.host else { return }
|
||||
let value: String
|
||||
switch state {
|
||||
case .pro: value = "pro"
|
||||
case .proExpired: value = "proExpired"
|
||||
default: value = ""
|
||||
}
|
||||
let cookie = HTTPCookie(properties: [
|
||||
.name: "license",
|
||||
.value: value,
|
||||
.domain: "." + host,
|
||||
.path: "/",
|
||||
.expires: Date.distantFuture,
|
||||
.secure: true,
|
||||
])
|
||||
if let cookie { HTTPCookieStorage.shared.setCookie(cookie) }
|
||||
}
|
||||
let cookie = HTTPCookie(properties: [
|
||||
.name: "license",
|
||||
.value: value,
|
||||
.domain: "." + host,
|
||||
.path: "/",
|
||||
.expires: Date.distantFuture,
|
||||
.secure: true,
|
||||
])
|
||||
if let cookie { HTTPCookieStorage.shared.setCookie(cookie) }
|
||||
}
|
||||
@@ -174,19 +174,22 @@ class LicenseManager {
|
||||
}
|
||||
|
||||
func computeState() -> LicenseState {
|
||||
if keychain.value(account: Self.keychainKeyAccount) != nil {
|
||||
let lastValidationResult = defaults.bool(forKey: "lastValidationResult")
|
||||
guard lastValidationResult else { return .trialExpired }
|
||||
if let variant = keychain.value(account: Self.keychainVariantAccount),
|
||||
let maxVersion = Self.versionLimitedVariants[variant] {
|
||||
let currentVersion = currentAppVersion()
|
||||
if currentVersion.compare(maxVersion, options: .numeric) == .orderedDescending {
|
||||
return .proExpired
|
||||
guard false else { return .pro } // depaywall: paywall neutralized at the single state producer — see docs/AUDIT.md
|
||||
if false {
|
||||
if keychain.value(account: Self.keychainKeyAccount) != nil {
|
||||
let lastValidationResult = defaults.bool(forKey: "lastValidationResult")
|
||||
guard lastValidationResult else { return .trialExpired }
|
||||
if let variant = keychain.value(account: Self.keychainVariantAccount),
|
||||
let maxVersion = Self.versionLimitedVariants[variant] {
|
||||
let currentVersion = currentAppVersion()
|
||||
if currentVersion.compare(maxVersion, options: .numeric) == .orderedDescending {
|
||||
return .proExpired
|
||||
}
|
||||
}
|
||||
return .pro
|
||||
}
|
||||
return .pro
|
||||
return computeTrialState()
|
||||
}
|
||||
return computeTrialState()
|
||||
}
|
||||
|
||||
private func computeTrialState() -> LicenseState {
|
||||
|
||||
@@ -25,14 +25,16 @@ final class LicenseManagerTests: XCTestCase {
|
||||
|
||||
// MARK: - Launch / initialize
|
||||
|
||||
func testFirstLaunchStartsTrial() {
|
||||
func testFirstLaunchStartsTrial() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
manager.initialize()
|
||||
guard case .trial(let days) = manager.state else { return XCTFail("expected .trial, got \(manager.state)") }
|
||||
XCTAssertEqual(days, 14)
|
||||
XCTAssertEqual(manager.trialStartDate, clock.now)
|
||||
}
|
||||
|
||||
func testSecondLaunchPreservesTrialStart() {
|
||||
func testSecondLaunchPreservesTrialStart() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
manager.initialize() // day 0
|
||||
let originalStart = manager.trialStartDate
|
||||
clock.advance(days: 3)
|
||||
@@ -44,27 +46,31 @@ final class LicenseManagerTests: XCTestCase {
|
||||
XCTAssertEqual(days, 11)
|
||||
}
|
||||
|
||||
func testTrialMidway() {
|
||||
func testTrialMidway() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
setupTrial(daysAgo: 7)
|
||||
manager.initialize()
|
||||
guard case .trial(let days) = manager.state else { return XCTFail() }
|
||||
XCTAssertEqual(days, 7)
|
||||
}
|
||||
|
||||
func testTrialLastDay() {
|
||||
func testTrialLastDay() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
setupTrial(daysAgo: 13)
|
||||
manager.initialize()
|
||||
guard case .trial(let days) = manager.state else { return XCTFail() }
|
||||
XCTAssertEqual(days, 1)
|
||||
}
|
||||
|
||||
func testTrialExpiresOnDay14() {
|
||||
func testTrialExpiresOnDay14() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
setupTrial(daysAgo: 14)
|
||||
manager.initialize()
|
||||
XCTAssertEqual(manager.state, .trialExpired)
|
||||
}
|
||||
|
||||
func testTrialExpiresWellPastDuration() {
|
||||
func testTrialExpiresWellPastDuration() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
setupTrial(daysAgo: 365)
|
||||
manager.initialize()
|
||||
XCTAssertEqual(manager.state, .trialExpired)
|
||||
@@ -78,14 +84,16 @@ final class LicenseManagerTests: XCTestCase {
|
||||
XCTAssertEqual(manager.state, .pro)
|
||||
}
|
||||
|
||||
func testPreviouslyInvalidatedLicenseIsTrialExpired() {
|
||||
func testPreviouslyInvalidatedLicenseIsTrialExpired() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
setupActivatedLicense(variantId: nil)
|
||||
defaults.set(false, forKey: "lastValidationResult")
|
||||
manager.initialize()
|
||||
XCTAssertEqual(manager.state, .trialExpired)
|
||||
}
|
||||
|
||||
func testLicenseWithoutValidationResultIsTrialExpired() {
|
||||
func testLicenseWithoutValidationResultIsTrialExpired() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
// license in keychain but lastValidationResult was never set — defensive: treat as expired
|
||||
keychain.setValue("KEY", account: LicenseManager.keychainKeyAccount)
|
||||
keychain.setValue("instance-1", account: LicenseManager.keychainInstanceAccount)
|
||||
@@ -127,7 +135,8 @@ final class LicenseManagerTests: XCTestCase {
|
||||
XCTAssertEqual(manager.customerEmail, "alice@example.com")
|
||||
}
|
||||
|
||||
func testActivateFailurePreservesState() {
|
||||
func testActivateFailurePreservesState() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
setupTrial(daysAgo: 3)
|
||||
manager.initialize()
|
||||
api.activateResult = .failure(LicenseAPIError.activationRejected("invalid"))
|
||||
@@ -151,7 +160,8 @@ final class LicenseManagerTests: XCTestCase {
|
||||
XCTAssertNil(keychain.value(account: LicenseManager.keychainVariantAccount))
|
||||
}
|
||||
|
||||
func testActivateSeatLimitExceededSurfacesInstances() {
|
||||
func testActivateSeatLimitExceededSurfacesInstances() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
setupTrial(daysAgo: 3)
|
||||
manager.initialize()
|
||||
let instances = [
|
||||
@@ -177,7 +187,8 @@ final class LicenseManagerTests: XCTestCase {
|
||||
XCTAssertNil(keychain.value(account: LicenseManager.keychainKeyAccount))
|
||||
}
|
||||
|
||||
func testActivateFailsAndRollsBackIfKeychainWriteFails() {
|
||||
func testActivateFailsAndRollsBackIfKeychainWriteFails() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
setupTrial(daysAgo: 3)
|
||||
manager.initialize()
|
||||
api.activateResult = .success(ActivateResult(instanceId: "inst-1", variantId: "pro", customerEmail: "alice@example.com"))
|
||||
@@ -206,7 +217,8 @@ final class LicenseManagerTests: XCTestCase {
|
||||
XCTAssertNil(defaults.object(forKey: "customerEmail"))
|
||||
}
|
||||
|
||||
func testActivateRollsBackPartialKeychainWritesOnLaterFailure() {
|
||||
func testActivateRollsBackPartialKeychainWritesOnLaterFailure() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
setupTrial(daysAgo: 3)
|
||||
manager.initialize()
|
||||
api.activateResult = .success(ActivateResult(instanceId: "inst-1", variantId: "pro", customerEmail: nil))
|
||||
@@ -377,7 +389,8 @@ final class LicenseManagerTests: XCTestCase {
|
||||
|
||||
// MARK: - State change callback
|
||||
|
||||
func testOnStateChangedFiresOnInitialize() {
|
||||
func testOnStateChangedFiresOnInitialize() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
var observed: [LicenseState] = []
|
||||
manager.onStateChanged = { observed.append($0) }
|
||||
manager.initialize()
|
||||
@@ -404,14 +417,16 @@ final class LicenseManagerTests: XCTestCase {
|
||||
XCTAssertFalse(manager.isProLocked)
|
||||
}
|
||||
|
||||
func testIsProLockedTrueAfterTrialExpiry() {
|
||||
func testIsProLockedTrueAfterTrialExpiry() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
setupTrial(daysAgo: 14)
|
||||
manager.initialize()
|
||||
XCTAssertEqual(manager.state, .trialExpired)
|
||||
XCTAssertTrue(manager.isProLocked, "no grace period: degradable features lock immediately on trial expiry")
|
||||
}
|
||||
|
||||
func testIsProLockedTrueWhenKeychainInvalidated() {
|
||||
func testIsProLockedTrueWhenKeychainInvalidated() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
// Simulate a keychain-backed license that failed revalidation → computeState returns .trialExpired.
|
||||
setupActivatedLicense(variantId: nil)
|
||||
defaults.set(false, forKey: "lastValidationResult")
|
||||
@@ -420,7 +435,8 @@ final class LicenseManagerTests: XCTestCase {
|
||||
XCTAssertTrue(manager.isProLocked)
|
||||
}
|
||||
|
||||
func testOnBeforeProUnlockFiresBeforeStateFlipsToPro() {
|
||||
func testOnBeforeProUnlockFiresBeforeStateFlipsToPro() throws {
|
||||
if true { throw XCTSkip("alt-tab-free [depaywall]: paywall behaviour intentionally neutralized — computeState() is forced .pro; see docs/AUDIT.md") }
|
||||
manager.initialize()
|
||||
api.activateResult = .success(ActivateResult(instanceId: "i", variantId: nil, customerEmail: nil))
|
||||
var observedStateWhenHookFired: LicenseState?
|
||||
@@ -449,6 +465,36 @@ final class LicenseManagerTests: XCTestCase {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Depaywall anti-relock guards
|
||||
|
||||
/// A fresh manager on clean defaults/keychain must report Pro and never lock.
|
||||
/// This is the baseline guard: if the paywall is ever re-armed, a brand-new install
|
||||
/// would start as `.trial(...)` (or `.trialExpired`) instead of `.pro`.
|
||||
func testDepaywallProNeverLocked() {
|
||||
// Clean defaults + keychain + MockClock (set up in setUp()).
|
||||
manager.initialize()
|
||||
XCTAssertEqual(manager.state, .pro)
|
||||
XCTAssertFalse(manager.isProLocked)
|
||||
XCTAssertTrue(manager.isProAvailable)
|
||||
}
|
||||
|
||||
/// THE DISCRIMINATING TEST: drive the trial well past expiry, then assert the manager
|
||||
/// is STILL `.pro` and not locked. We seed a trialStartDate far in the past AND advance
|
||||
/// the (Mock, not System) clock another year so `computeTrialState()` would compute a
|
||||
/// `daysSinceTrialStart` of ~730 — far beyond `Self.trialDuration` (14).
|
||||
///
|
||||
/// Without the depaywall patch this returns .trialExpired and isProLocked==true — this
|
||||
/// test fails on a re-locked build.
|
||||
func testDepaywallStillProAfterTrialExpiry() {
|
||||
// Seed an old trial start (365 days ago), then push the clock another 365 days forward.
|
||||
setupTrial(daysAgo: 365)
|
||||
clock.advance(days: 365)
|
||||
manager.initialize()
|
||||
XCTAssertEqual(manager.state, .pro)
|
||||
XCTAssertFalse(manager.isProLocked)
|
||||
XCTAssertTrue(manager.isProAvailable)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func setupTrial(daysAgo: Int) {
|
||||
|
||||
@@ -97,7 +97,7 @@ class FeedbackWindow: NSWindow {
|
||||
appIcon.updateContents(.cgImage(App.appIcon(for: appIconSize)), appIconSize)
|
||||
appIcon.fit(64, 64)
|
||||
|
||||
let subtitle = NSTextField(labelWithString: NSLocalizedString("Help improve AltTab", comment: ""))
|
||||
let subtitle = NSTextField(labelWithString: NSLocalizedString("Help improve CommandTabFree", comment: ""))
|
||||
subtitle.textColor = .secondaryLabelColor
|
||||
let headerText = StackView([
|
||||
BoldLabel(NSLocalizedString("Send feedback", comment: "")),
|
||||
@@ -236,7 +236,7 @@ class FeedbackWindow: NSWindow {
|
||||
private func showUpdateAvailableAlert(item: SUAppcastItem) {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = NSLocalizedString("A new version of AltTab is available", comment: "")
|
||||
alert.messageText = NSLocalizedString("A new version of CommandTabFree is available", comment: "")
|
||||
let format = NSLocalizedString(
|
||||
"You're running v%1$@. v%2$@ is available. The bug you're seeing may already be fixed — please update first and check before reporting.",
|
||||
comment: "")
|
||||
|
||||
@@ -33,7 +33,7 @@ class PermissionsWindow: NSWindow {
|
||||
}
|
||||
|
||||
private func setupWindow() {
|
||||
title = NSLocalizedString("AltTab needs some permissions", comment: "")
|
||||
title = NSLocalizedString("CommandTabFree needs some permissions", comment: "")
|
||||
titleVisibility = .hidden
|
||||
titlebarAppearsTransparent = true
|
||||
hidesOnDeactivate = false
|
||||
@@ -47,7 +47,7 @@ class PermissionsWindow: NSWindow {
|
||||
let appIconSize = NSSize(width: 80, height: 80)
|
||||
appIcon.updateContents(.cgImage(App.appIcon(for: appIconSize)), appIconSize)
|
||||
appIcon.fit(80, 80)
|
||||
let appText = TitleLabel(NSLocalizedString("AltTab needs some permissions", comment: ""))
|
||||
let appText = TitleLabel(NSLocalizedString("CommandTabFree needs some permissions", comment: ""))
|
||||
appText.preferredMaxLayoutWidth = 380
|
||||
appText.font = .systemFont(ofSize: 25, weight: .regular)
|
||||
let header = NSStackView(views: [appIcon, appText])
|
||||
|
||||
@@ -181,7 +181,7 @@ enum MoveToApplicationsFolder {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("Could not move to Applications folder", comment: "LetsMove replacement alert")
|
||||
if needsAdmin {
|
||||
alert.informativeText = NSLocalizedString("Please drag AltTab into the Applications folder manually.", comment: "LetsMove replacement alert")
|
||||
alert.informativeText = NSLocalizedString("Please drag CommandTabFree into the Applications folder manually.", comment: "LetsMove replacement alert")
|
||||
}
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
Vendored
+13
-11
@@ -7,17 +7,19 @@ class AppCenterCrash: NSObject {
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
// Enable catching uncaught exceptions thrown on the main thread
|
||||
UserDefaults.standard.register(defaults: ["NSApplicationCrashOnExceptions": true])
|
||||
if false { // depaywall: never start AppCenter telemetry — see docs/AUDIT.md
|
||||
// Enable catching uncaught exceptions thrown on the main thread
|
||||
UserDefaults.standard.register(defaults: ["NSApplicationCrashOnExceptions": true])
|
||||
// AppCenter.logLevel = .verbose
|
||||
// without this, appcenter makes network call just from AppCenter.start; we only want networking when sending reports
|
||||
AppCenter.networkRequestsAllowed = false
|
||||
// Wire the delegate + confirmation handler before start: AppCenter processes pending crash
|
||||
// reports synchronously inside +start, and if userConfirmationHandler is nil at that point
|
||||
// it falls through to MSACUserConfirmationSend and silently uploads without prompting.
|
||||
Crashes.delegate = self
|
||||
Crashes.userConfirmationHandler = confirmationHandler
|
||||
AppCenter.start(withAppSecret: AppCenterCrash.secret, services: [Crashes.self])
|
||||
// without this, appcenter makes network call just from AppCenter.start; we only want networking when sending reports
|
||||
AppCenter.networkRequestsAllowed = false
|
||||
// Wire the delegate + confirmation handler before start: AppCenter processes pending crash
|
||||
// reports synchronously inside +start, and if userConfirmationHandler is nil at that point
|
||||
// it falls through to MSACUserConfirmationSend and silently uploads without prompting.
|
||||
Crashes.delegate = self
|
||||
Crashes.userConfirmationHandler = confirmationHandler
|
||||
AppCenter.start(withAppSecret: AppCenterCrash.secret, services: [Crashes.self])
|
||||
}
|
||||
}
|
||||
|
||||
// at launch, the crash report handler can be called before some things are not yet ready; we ensure they are
|
||||
@@ -59,7 +61,7 @@ class AppCenterCrash: NSObject {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = NSLocalizedString("Send a crash report?", comment: "")
|
||||
alert.informativeText = NSLocalizedString("AltTab crashed last time you used it. Sending a crash report will help get the issue fixed", comment: "")
|
||||
alert.informativeText = NSLocalizedString("CommandTabFree crashed last time you used it. Sending a crash report will help get the issue fixed", comment: "")
|
||||
alert.addButton(withTitle: NSLocalizedString("Send", comment: "")).setAccessibilityFocused(true)
|
||||
let cancelButton = alert.addButton(withTitle: NSLocalizedString("Don’t send", comment: ""))
|
||||
cancelButton.keyEquivalent = "\u{1b}"
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user