Skip to content

Commit 27d96f1

Browse files
committed
feat(profile): handle builds without profiles
- add BundleDocumentTypes.plist.in to declare .gfp type separately - remove embedded document types from MacOSXBundleInfo.plist.in - make CMake omit document types for App Store sandbox builds - read and configure document types only for non-sandbox builds - add multi_profile flag and gate profile selection logic - return implicit default profile when profiles are unavailable - update UI to hide profile actions/menus and disable clicks - drain but ignore handed documents when profiles are off - adjust status indicator to omit "manage" hint when inert - add unit tests covering behavior when profiles are absent
1 parent fe13e63 commit 27d96f1

12 files changed

Lines changed: 354 additions & 173 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<!-- Spliced into MacOSXBundleInfo.plist.in at the placeholder named
2+
GPGFRONTEND_BUNDLE_DOCUMENT_TYPES. A file of its own so the App Store
3+
build can leave it out: that build ships without profiles, so claiming
4+
the type would put it on a .gfp file's "Open with" menu and then ignore
5+
the document it was handed. See src/CMakeLists.txt.
6+
7+
Every ${...} here is expanded at configure time, not by the Info.plist
8+
pass, so only variables in scope at that point may be used. -->
9+
<!-- What Launch Services needs to put this application on a .gfp file's
10+
"Open with" menu and to hand a double-clicked one over. Declaring the
11+
type (below) and declaring that we handle it (here) are two separate
12+
statements, and both are required. -->
13+
<key>CFBundleDocumentTypes</key>
14+
<array>
15+
<dict>
16+
<key>CFBundleTypeName</key>
17+
<string>${GPGFRONTEND_APP_DISPLAY_NAME} Profile</string>
18+
<!-- Editor, not Viewer: closing a package session offers to write the
19+
changes back into the same file, so this application does modify
20+
what it is given. It is also what makes Launch Services hand over
21+
a read-write sandbox extension with the document. -->
22+
<key>CFBundleTypeRole</key>
23+
<string>Editor</string>
24+
<!-- Owner, because the type is exported by this bundle. Anything else
25+
would ask the system to prefer a handler that does not define the
26+
format. -->
27+
<key>LSHandlerRank</key>
28+
<string>Owner</string>
29+
<key>LSItemContentTypes</key>
30+
<array>
31+
<string>com.bktus.gpgfrontend.profile</string>
32+
</array>
33+
<key>CFBundleTypeIconFile</key>
34+
<string>gpgfrontend.icns</string>
35+
</dict>
36+
</array>
37+
<key>UTExportedTypeDeclarations</key>
38+
<array>
39+
<dict>
40+
<!-- Deliberately literal rather than templated: this string identifies
41+
the *format*, which a nightly and a release write identically, and
42+
they share one bundle identifier anyway. Two bundles exporting the
43+
same identifier is only harmful when their declarations disagree,
44+
so these must stay byte-identical across flavours. -->
45+
<key>UTTypeIdentifier</key>
46+
<string>com.bktus.gpgfrontend.profile</string>
47+
<key>UTTypeDescription</key>
48+
<string>GpgFrontend Profile</string>
49+
<!-- public.data only. Not public.archive: the file is not an archive at
50+
offset 0 — the gzip stream starts after the magic, the length and
51+
the header — and conforming would have Archive Utility and Quick
52+
Look offer to expand something they cannot read. -->
53+
<key>UTTypeConformsTo</key>
54+
<array>
55+
<string>public.data</string>
56+
</array>
57+
<key>UTTypeIconFile</key>
58+
<string>gpgfrontend.icns</string>
59+
<key>UTTypeTagSpecification</key>
60+
<dict>
61+
<key>public.filename-extension</key>
62+
<array>
63+
<string>gfp</string>
64+
</array>
65+
<key>public.mime-type</key>
66+
<array>
67+
<string>application/x-gpgfrontend-profile</string>
68+
</array>
69+
</dict>
70+
</dict>
71+
</array>

‎resource/plist/MacOSXBundleInfo.plist.in‎

Lines changed: 1 addition & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -36,68 +36,5 @@
3636
<string>${MACOSX_BUNDLE_COPYRIGHT}</string>
3737
<key>LSApplicationCategoryType</key>
3838
<string>public.app-category.utilities</string>
39-
<!-- What Launch Services needs to put this application on a .gfp file's
40-
"Open with" menu and to hand a double-clicked one over. Declaring the
41-
type (below) and declaring that we handle it (here) are two separate
42-
statements, and both are required. -->
43-
<key>CFBundleDocumentTypes</key>
44-
<array>
45-
<dict>
46-
<key>CFBundleTypeName</key>
47-
<string>${MACOSX_BUNDLE_BUNDLE_NAME} Profile</string>
48-
<!-- Editor, not Viewer: closing a package session offers to write the
49-
changes back into the same file, so this application does modify
50-
what it is given. It is also what makes Launch Services hand over
51-
a read-write sandbox extension with the document. -->
52-
<key>CFBundleTypeRole</key>
53-
<string>Editor</string>
54-
<!-- Owner, because the type is exported by this bundle. Anything else
55-
would ask the system to prefer a handler that does not define the
56-
format. -->
57-
<key>LSHandlerRank</key>
58-
<string>Owner</string>
59-
<key>LSItemContentTypes</key>
60-
<array>
61-
<string>com.bktus.gpgfrontend.profile</string>
62-
</array>
63-
<key>CFBundleTypeIconFile</key>
64-
<string>gpgfrontend.icns</string>
65-
</dict>
66-
</array>
67-
<key>UTExportedTypeDeclarations</key>
68-
<array>
69-
<dict>
70-
<!-- Deliberately literal rather than templated: this string identifies
71-
the *format*, which a nightly and a release write identically, and
72-
they share one bundle identifier anyway. Two bundles exporting the
73-
same identifier is only harmful when their declarations disagree,
74-
so these must stay byte-identical across flavours. -->
75-
<key>UTTypeIdentifier</key>
76-
<string>com.bktus.gpgfrontend.profile</string>
77-
<key>UTTypeDescription</key>
78-
<string>GpgFrontend Profile</string>
79-
<!-- public.data only. Not public.archive: the file is not an archive at
80-
offset 0 — the gzip stream starts after the magic, the length and
81-
the header — and conforming would have Archive Utility and Quick
82-
Look offer to expand something they cannot read. -->
83-
<key>UTTypeConformsTo</key>
84-
<array>
85-
<string>public.data</string>
86-
</array>
87-
<key>UTTypeIconFile</key>
88-
<string>gpgfrontend.icns</string>
89-
<key>UTTypeTagSpecification</key>
90-
<dict>
91-
<key>public.filename-extension</key>
92-
<array>
93-
<string>gfp</string>
94-
</array>
95-
<key>public.mime-type</key>
96-
<array>
97-
<string>application/x-gpgfrontend-profile</string>
98-
</array>
99-
</dict>
100-
</dict>
101-
</array>
102-
</dict>
39+
${GPGFRONTEND_BUNDLE_DOCUMENT_TYPES}</dict>
10340
</plist>

‎src/CMakeLists.txt‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,29 @@ if(XCODE_BUILD)
8383
set(CUSTOM_ATTRIBUTE_ENABLE_APP_SANDBOX "Yes")
8484
set(APP_ENTITLEMENTS_FILE "${CMAKE_SOURCE_DIR}/resource/entitlements/Sandbox.entitlements")
8585
set(PROVISIONING_PROFILE_NAME "GpgFrontend_Mac_App_Store_Profile")
86+
87+
# The sandboxed build ships without profiles -- Launch Services will not
88+
# pass arguments on behalf of a sandboxed process, so the second instance
89+
# that opening one depends on can never be told which profile it is for.
90+
# Claiming the document type would still put this application on a .gfp
91+
# file's "Open with" menu, and it would then ignore what it was handed.
92+
set(GPGFRONTEND_BUNDLE_DOCUMENT_TYPES "")
93+
message(STATUS "Bundle Document Types: none (.gfp not claimed)")
8694
else()
8795
message(STATUS "Build Application Without App Sandbox")
8896
set(CUSTOM_ATTRIBUTE_ENABLE_APP_SANDBOX "No")
8997
set(APP_ENTITLEMENTS_FILE "${CMAKE_SOURCE_DIR}/resource/entitlements/Normal.entitlements")
9098
set(PROVISIONING_PROFILE_NAME "GpgFrontend")
99+
100+
# Expanded here rather than left to the Info.plist pass: that pass
101+
# substitutes the template once, so a ${...} arriving through a substitution
102+
# would reach the built plist verbatim.
103+
file(READ
104+
"${CMAKE_SOURCE_DIR}/resource/plist/BundleDocumentTypes.plist.in"
105+
GPGFRONTEND_BUNDLE_DOCUMENT_TYPES)
106+
string(CONFIGURE "${GPGFRONTEND_BUNDLE_DOCUMENT_TYPES}"
107+
GPGFRONTEND_BUNDLE_DOCUMENT_TYPES)
108+
message(STATUS "Bundle Document Types: .gfp claimed")
91109
endif()
92110

93111
set_target_properties(${APP_NAME} PROPERTIES

‎src/GpgFrontendContext.cpp‎

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -199,28 +199,35 @@ void GpgFrontendContext::resolve_profile_selection() {
199199
ProfileSelectionInput in;
200200
in.args = QCoreApplication::arguments();
201201

202-
// Appended rather than given a rung of its own: the resolver already ranks a
203-
// positional package below --profile and takes the first one it finds, so
204-
// putting a handed-over document at the end gives it exactly the standing a
205-
// typed one has — below an explicit profile, above the environment — with no
206-
// second copy of the precedence rules to keep in step.
207-
if (const auto handed = app_->TakePendingProfilePackage();
208-
!handed.isEmpty()) {
209-
in.args << handed;
210-
}
202+
// The macOS App Store build ships without profiles: LaunchServices will not
203+
// pass arguments on behalf of a sandboxed process, so the second instance
204+
// that a switch depends on can never be told which profile it is for.
205+
in.multi_profile = !IsRunningInAppSandbox();
211206

212207
in.env_profile = qEnvironmentVariable("GF_PROFILE");
213208
in.portable_build = IsPortableBuild();
214209
in.portable_root = ResolvePortableDataPath();
215210
in.installed_root =
216211
QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
217212

218-
// A scan rather than an index, so there is nothing here that can disagree
219-
// with the filesystem. Cheap, and it is the only thing that makes naming a
220-
// profile that does not exist an error rather than a request to create one.
221-
const auto base = in.portable_build ? in.portable_root : in.installed_root;
222-
for (const auto& entry : ScanProfilesRoot(base + "/profiles")) {
223-
in.known_ids << entry.id;
213+
if (in.multi_profile) {
214+
// Appended rather than given a rung of its own: the resolver already ranks
215+
// a positional package below --profile and takes the first one it finds, so
216+
// putting a handed-over document at the end gives it exactly the standing a
217+
// typed one has — below an explicit profile, above the environment — with
218+
// no second copy of the precedence rules to keep in step.
219+
if (const auto handed = app_->TakePendingProfilePackage();
220+
!handed.isEmpty()) {
221+
in.args << handed;
222+
}
223+
224+
// A scan rather than an index, so there is nothing here that can disagree
225+
// with the filesystem. Cheap, and it is the only thing that makes naming a
226+
// profile that does not exist an error rather than a request to create one.
227+
const auto base = in.portable_build ? in.portable_root : in.installed_root;
228+
for (const auto& entry : ScanProfilesRoot(base + "/profiles")) {
229+
in.known_ids << entry.id;
230+
}
224231
}
225232

226233
auto result = ResolveProfileSelection(in);

‎src/core/profile/Profile.cpp‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,24 @@ auto ResolvePersist(const QString &id, const ProfileSelectionInput &in,
466466
return r;
467467
}
468468

469+
/// This build's root profile: the bottom of the precedence ladder, and the only
470+
/// answer at all where profiles are not offered.
471+
auto ImplicitDefault(const ProfileSelectionInput &in,
472+
const QString &profiles_root) -> ProfileSelectionResult {
473+
ProfileSelectionResult r;
474+
r.selection.profiles_root = profiles_root;
475+
if (in.portable_build) {
476+
r.selection.kind = ProfileKind::kPORTABLE_ROOT;
477+
r.selection.id = "portable";
478+
r.selection.root = in.portable_root;
479+
} else {
480+
r.selection.kind = ProfileKind::kINSTALLED_ROOT;
481+
r.selection.id = "classic";
482+
r.selection.root = in.installed_root;
483+
}
484+
return r;
485+
}
486+
469487
} // namespace
470488

471489
auto IsValidProfileId(const QString &id) -> bool {
@@ -485,6 +503,13 @@ auto ResolveProfileSelection(const ProfileSelectionInput &in)
485503
const auto base = in.portable_build ? in.portable_root : in.installed_root;
486504
const auto profiles_root = base + "/profiles";
487505

506+
// A build without profiles has exactly one thing to resolve to, so the whole
507+
// ladder below is skipped rather than each rung being guarded. Silently: a
508+
// deep restart hands this process its predecessor's argv, so a `--profile`
509+
// left over from an installation that once had profiles must not turn into a
510+
// startup error the user cannot act on.
511+
if (!in.multi_profile) return ImplicitDefault(in, profiles_root);
512+
488513
// 1. a named profile. Explicit selection outranks the build flavour: on a
489514
// portable build this opens <portable-root>/profiles/<id>, and that profile's
490515
// own policy decides whether it is self-contained — the flavour only chooses
@@ -513,18 +538,7 @@ auto ResolveProfileSelection(const ProfileSelectionInput &in)
513538
// 4. the implicit default: this build's root profile. Nothing is remembered
514539
// between runs on purpose — an instance always starts here, and another
515540
// profile or a package is opened from here into a new window.
516-
ProfileSelectionResult r;
517-
r.selection.profiles_root = profiles_root;
518-
if (in.portable_build) {
519-
r.selection.kind = ProfileKind::kPORTABLE_ROOT;
520-
r.selection.id = "portable";
521-
r.selection.root = in.portable_root;
522-
} else {
523-
r.selection.kind = ProfileKind::kINSTALLED_ROOT;
524-
r.selection.id = "classic";
525-
r.selection.root = in.installed_root;
526-
}
527-
return r;
541+
return ImplicitDefault(in, profiles_root);
528542
}
529543

530544
auto MakeProfile(const ProfileSelection &selection) -> QSharedPointer<Profile> {

‎src/core/profile/Profile.h‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,21 @@ struct GF_CORE_EXPORT ProfileSelectionInput {
520520
* is always authoritative, so there is no "we do not know yet" case.
521521
*/
522522
QStringList known_ids;
523+
524+
/**
525+
* @brief Whether this build may open anything but its implicit default.
526+
*
527+
* False on a build that ships without profiles at all — the macOS App Store
528+
* one, where opening another profile means launching a second instance with
529+
* `--profile`, and LaunchServices refuses to pass arguments on behalf of a
530+
* sandboxed process. The successor would arrive with an empty argv, fall back
531+
* to the default profile, and collide with the lock the window that asked for
532+
* the switch is still holding.
533+
*
534+
* A field rather than a call to IsRunningInAppSandbox() so this stays pure
535+
* and both answers are assertable from one test binary.
536+
*/
537+
bool multi_profile = true;
523538
};
524539

525540
/**

‎src/test/core/GpgCoreTestSettingsLayering.cpp‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,64 @@ TEST(ProfileSelectionTest, TheScannedExtensionIsTheRegisteredOne) {
542542
EXPECT_EQ(r.selection.kind, ProfileKind::kPACKAGED);
543543
}
544544

545+
// ------------------------------------------------ a build without profiles
546+
547+
// The macOS App Store build ships without profiles: opening one means a second
548+
// instance told which profile it is for, and Launch Services will not pass
549+
// arguments on behalf of a sandboxed process. The successor would arrive with
550+
// an empty argv, resolve to the default profile, and collide with the lock the
551+
// window that asked for the switch is still holding. So such a build resolves
552+
// to its implicit default and nothing else can reach it.
553+
554+
TEST(ProfileSelectionTest, WithoutProfilesEveryRungResolvesToTheDefault) {
555+
const QList<ProfileSelectionInput> asked = [] {
556+
QList<ProfileSelectionInput> out;
557+
out.append(MakeInput({"--profile", "work"}));
558+
out.append(MakeInput({"/home/x/work.gfp"}));
559+
out.append(MakeInput());
560+
out.last().env_profile = "ci";
561+
return out;
562+
}();
563+
564+
for (auto in : asked) {
565+
in.multi_profile = false;
566+
const auto r = ResolveProfileSelection(in);
567+
568+
// Silently, not as an error: a deep restart hands the successor its
569+
// predecessor's argv, so a leftover --profile must not become a startup
570+
// failure the user has no way to act on.
571+
EXPECT_TRUE(r.error.isEmpty()) << r.error.toStdString();
572+
EXPECT_EQ(r.selection.kind, ProfileKind::kINSTALLED_ROOT);
573+
EXPECT_EQ(r.selection.id, QString("classic"));
574+
EXPECT_EQ(r.selection.root, QString(kInstalledRoot));
575+
EXPECT_TRUE(r.selection.package_path.isEmpty());
576+
}
577+
}
578+
579+
TEST(ProfileSelectionTest, WithoutProfilesAPortableBuildStillGetsItsOwnRoot) {
580+
// The flavour still decides which root is the implicit one; only the ladder
581+
// above it goes away.
582+
auto in = MakeInput({"--profile", "work"});
583+
in.multi_profile = false;
584+
in.portable_build = true;
585+
586+
const auto r = ResolveProfileSelection(in);
587+
588+
EXPECT_TRUE(r.error.isEmpty());
589+
EXPECT_EQ(r.selection.kind, ProfileKind::kPORTABLE_ROOT);
590+
EXPECT_EQ(r.selection.id, QString("portable"));
591+
EXPECT_EQ(r.selection.root, QString(kPortableRoot));
592+
}
593+
594+
TEST(ProfileSelectionTest, ProfilesAreOfferedUnlessSaidOtherwise) {
595+
// The field defaults to the behaviour every other build has, so a caller that
596+
// has never heard of it cannot accidentally ship without profiles.
597+
EXPECT_TRUE(ProfileSelectionInput{}.multi_profile);
598+
EXPECT_EQ(
599+
ResolveProfileSelection(MakeInput({"--profile", "work"})).selection.id,
600+
QString("work"));
601+
}
602+
545603
TEST(ProfileSelectionTest, NothingIsRememberedBetweenRuns) {
546604
// An instance always starts on its root profile. There is deliberately no
547605
// "reopen what was open last" rung: another profile is opened from the root

0 commit comments

Comments
 (0)