@solana-mobile/dapp-store-cli 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,6 @@ import { debug, showMessage } from "../CliUtils.js";
11
11
 
12
12
  import type { Keypair } from "@solana/web3.js";
13
13
  import type { MetaplexFile } from "@metaplex-foundation/js";
14
- import { isMetaplexFile } from "@metaplex-foundation/js";
15
14
  import { loadPublishDetailsWithChecks } from "../config/PublishDetails.js";
16
15
 
17
16
  export const validateCommand = async ({
@@ -37,7 +36,6 @@ export const validateCommand = async ({
37
36
 
38
37
  try {
39
38
  validatePublisher(publisherJson);
40
- console.info(`Publisher JSON valid!`);
41
39
  } catch (e) {
42
40
  const errorMsg = (e as Error | null)?.message ?? "";
43
41
  showMessage(
@@ -56,7 +54,6 @@ export const validateCommand = async ({
56
54
 
57
55
  try {
58
56
  validateApp(appJson);
59
- console.info(`App JSON valid!`);
60
57
  } catch (e) {
61
58
  const errorMsg = (e as Error | null)?.message ?? "";
62
59
  showMessage(
@@ -87,7 +84,6 @@ export const validateCommand = async ({
87
84
 
88
85
  try {
89
86
  validateRelease(JSON.parse(objStringified));
90
- console.info(`Release JSON valid!`);
91
87
  } catch (e) {
92
88
  const errorMsg = (e as Error | null)?.message ?? "";
93
89
  showMessage(
@@ -71,6 +71,7 @@ const createAppNft = async (
71
71
  }
72
72
  }
73
73
  }
74
+ throw new Error("Unable to mint app NFT");
74
75
  };
75
76
 
76
77
  type CreateAppCommandInput = {
@@ -62,6 +62,7 @@ const createPublisherNft = async (
62
62
  }
63
63
  }
64
64
  }
65
+ throw new Error("Unable to mint publisher NFT");
65
66
  };
66
67
 
67
68
  export const createPublisherCommand = async ({
@@ -96,4 +97,6 @@ export const createPublisherCommand = async ({
96
97
 
97
98
  return { publisherAddress, transactionSignature };
98
99
  }
100
+
101
+ return { publisherAddress: "", transactionSignature: "" };
99
102
  };
@@ -10,12 +10,14 @@ import {
10
10
  PublicKey,
11
11
  sendAndConfirmTransaction,
12
12
  } from "@solana/web3.js";
13
+ import fs from "fs";
14
+ import { createHash } from "crypto";
13
15
  import {
14
16
  Constants,
15
17
  getMetaplexInstance,
16
18
  showMessage
17
19
  } from "../../CliUtils.js";
18
- import { loadPublishDetailsWithChecks, writeToPublishDetails } from "../../config/PublishDetails.js";
20
+ import { PublishDetails, loadPublishDetailsWithChecks, writeToPublishDetails } from "../../config/PublishDetails.js";
19
21
 
20
22
  type CreateReleaseCommandInput = {
21
23
  appMintAddress: string;
@@ -89,6 +91,7 @@ const createReleaseNft = async ({
89
91
  }
90
92
  }
91
93
  }
94
+ throw new Error("Unable to mint release NFT");
92
95
  };
93
96
 
94
97
  export const createReleaseCommand = async ({
@@ -102,28 +105,49 @@ export const createReleaseCommand = async ({
102
105
  }: CreateReleaseCommandInput) => {
103
106
  const connection = new Connection(url);
104
107
 
105
- const { release, app, publisher } = await loadPublishDetailsWithChecks(buildToolsPath);
108
+ const config = await loadPublishDetailsWithChecks(buildToolsPath);
106
109
 
110
+ const apkEntry = config.release.files.find(
111
+ (asset: PublishDetails["release"]["files"][0]) => asset.purpose === "install"
112
+ )!;
113
+ const mediaBuffer = await fs.promises.readFile(apkEntry.uri);
114
+ const hash = createHash("sha256").update(mediaBuffer).digest("base64");
107
115
 
108
- if (app.android_package != release.android_details.android_package) {
109
- throw new Error("App package name and release package name do not match.\nApp release specifies " + app.android_package + " while release specifies " + release.android_details.android_package)
116
+ if (config.lastSubmittedVersionOnChain != null && hash === config.lastSubmittedVersionOnChain.apk_hash) {
117
+ throw new Error(`The last created release used the same apk file.`);
118
+ }
119
+
120
+ if (config.lastSubmittedVersionOnChain != null && config.release.android_details.version_code <= config.lastSubmittedVersionOnChain.version_code) {
121
+ throw new Error(`Each release NFT should have higher version code than previous minted release NFT.\nLast released version code is ${config.lastSubmittedVersionOnChain.version_code}.\nCurrent version code from apk file is ${config.release.android_details.version_code}`);
122
+ }
123
+
124
+ if (config.app.android_package != config.release.android_details.android_package) {
125
+ throw new Error("App package name and release package name do not match.\nApp release specifies " + config.app.android_package + " while release specifies " + config.release.android_details.android_package)
110
126
  }
111
127
 
112
128
  if (!dryRun) {
113
129
  const { releaseAddress, transactionSignature } = await createReleaseNft({
114
- appMintAddress: app.address ?? appMintAddress,
130
+ appMintAddress: config.app.address ?? appMintAddress,
115
131
  connection,
116
132
  publisher: signer,
117
133
  releaseDetails: {
118
- ...release,
134
+ ...config.release,
119
135
  },
120
- appDetails: app,
121
- publisherDetails: publisher,
136
+ appDetails: config.app,
137
+ publisherDetails: config.publisher,
122
138
  storageParams: storageParams,
123
139
  priorityFeeLamports: priorityFeeLamports,
124
140
  });
125
141
 
126
- await writeToPublishDetails({ release: { address: releaseAddress }, });
142
+ await writeToPublishDetails(
143
+ {
144
+ release: { address: releaseAddress },
145
+ lastSubmittedVersionOnChain: {
146
+ address: releaseAddress,
147
+ version_code: config.release.android_details.version_code,
148
+ apk_hash: hash,
149
+ }
150
+ });
127
151
 
128
152
  return { releaseAddress, transactionSignature };
129
153
  }
@@ -4,7 +4,7 @@ import { publishSubmit } from "@solana-mobile/dapp-store-publishing-tools";
4
4
  import nacl from "tweetnacl";
5
5
  import { checkMintedStatus, showMessage } from "../../CliUtils.js";
6
6
  import { Buffer } from "buffer";
7
- import { loadPublishDetailsWithChecks } from "../../config/PublishDetails.js";
7
+ import { loadPublishDetailsWithChecks, writeToPublishDetails } from "../../config/PublishDetails.js";
8
8
 
9
9
  type PublishSubmitCommandInput = {
10
10
  appMintAddress: string;
@@ -49,6 +49,7 @@ export const publishSubmitCommand = async ({
49
49
  app: appDetails,
50
50
  release: releaseDetails,
51
51
  solana_mobile_dapp_publisher_portal: solanaMobileDappPublisherPortalDetails,
52
+ lastUpdatedVersionOnStore: lastUpdatedVersionOnStore,
52
53
  } = await loadPublishDetailsWithChecks();
53
54
 
54
55
  const sign = ((buf: Buffer) =>
@@ -58,6 +59,10 @@ export const publishSubmitCommand = async ({
58
59
  const appAddr = appMintAddress ?? appDetails.address;
59
60
  const releaseAddr = releaseMintAddress ?? releaseDetails.address;
60
61
 
62
+ if (lastUpdatedVersionOnStore != null && releaseAddr === lastUpdatedVersionOnStore.address) {
63
+ throw new Error(`You've already submitted this version for review.`);
64
+ }
65
+
61
66
  await checkMintedStatus(connection, pubAddr, appAddr, releaseAddr);
62
67
 
63
68
  await publishSubmit(
@@ -72,4 +77,9 @@ export const publishSubmitCommand = async ({
72
77
  },
73
78
  dryRun
74
79
  );
80
+
81
+ await writeToPublishDetails(
82
+ {
83
+ lastUpdatedVersionOnStore: { address: releaseAddr }
84
+ });
75
85
  };
@@ -3,7 +3,7 @@ import type { SignWithPublisherKeypair } from "@solana-mobile/dapp-store-publish
3
3
  import { publishUpdate } from "@solana-mobile/dapp-store-publishing-tools";
4
4
  import { checkMintedStatus, showMessage } from "../../CliUtils.js";
5
5
  import nacl from "tweetnacl";
6
- import { loadPublishDetailsWithChecks } from "../../config/PublishDetails.js";
6
+ import { loadPublishDetailsWithChecks, writeToPublishDetails } from "../../config/PublishDetails.js";
7
7
 
8
8
  type PublishUpdateCommandInput = {
9
9
  appMintAddress: string;
@@ -51,6 +51,7 @@ export const publishUpdateCommand = async ({
51
51
  app: appDetails,
52
52
  release: releaseDetails,
53
53
  solana_mobile_dapp_publisher_portal: solanaMobileDappPublisherPortalDetails,
54
+ lastUpdatedVersionOnStore: lastUpdatedVersionOnStore
54
55
  } = await loadPublishDetailsWithChecks();
55
56
 
56
57
  const sign = ((buf: Buffer) =>
@@ -60,6 +61,10 @@ export const publishUpdateCommand = async ({
60
61
  const appAddr = appMintAddress ?? appDetails.address;
61
62
  const releaseAddr = releaseMintAddress ?? releaseDetails.address;
62
63
 
64
+ if (lastUpdatedVersionOnStore != null && releaseAddr === lastUpdatedVersionOnStore.address) {
65
+ throw new Error(`You've already submitted this version for review.`);
66
+ }
67
+
63
68
  await checkMintedStatus(connection, pubAddr, appAddr, releaseAddr);
64
69
 
65
70
  await publishUpdate(
@@ -75,4 +80,8 @@ export const publishUpdateCommand = async ({
75
80
  },
76
81
  dryRun
77
82
  );
83
+ await writeToPublishDetails(
84
+ {
85
+ lastUpdatedVersionOnStore: { address: releaseAddr }
86
+ });
78
87
  };
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  AndroidDetails,
3
3
  App,
4
+ LastSubmittedVersionOnChain,
5
+ LastUpdatedVersionOnStore,
4
6
  Publisher,
5
7
  Release,
6
8
  SolanaMobileDappPublisherPortal
@@ -16,6 +18,7 @@ import { Constants, showMessage } from "../CliUtils.js";
16
18
  import util from "util";
17
19
  import { imageSize } from "image-size";
18
20
  import { exec } from "child_process";
21
+ import getVideoDimensions from "get-video-dimensions";
19
22
 
20
23
  const runImgSize = util.promisify(imageSize);
21
24
  const runExec = util.promisify(exec);
@@ -25,6 +28,8 @@ export interface PublishDetails {
25
28
  app: App;
26
29
  release: Release;
27
30
  solana_mobile_dapp_publisher_portal: SolanaMobileDappPublisherPortal;
31
+ lastSubmittedVersionOnChain: LastSubmittedVersionOnChain
32
+ lastUpdatedVersionOnStore: LastUpdatedVersionOnStore,
28
33
  }
29
34
 
30
35
  const AaptPrefixes = {
@@ -42,6 +47,8 @@ type SaveToConfigArgs = {
42
47
  publisher?: Pick<Publisher, "address">;
43
48
  app?: Pick<App, "address">;
44
49
  release?: Pick<Release, "address">;
50
+ lastSubmittedVersionOnChain?: LastSubmittedVersionOnChain;
51
+ lastUpdatedVersionOnStore?: LastUpdatedVersionOnStore;
45
52
  };
46
53
 
47
54
  const ajv = new Ajv({ strictTuples: false });
@@ -119,9 +126,17 @@ export const loadPublishDetailsWithChecks = async (
119
126
  }
120
127
 
121
128
  config.release.media.forEach((item: PublishDetails["release"]["media"][0]) => {
122
- const imagePath = path.join(process.cwd(), item.uri);
123
- if (!fs.existsSync(imagePath) || !checkImageExtension(imagePath)) {
124
- throw new Error(`Invalid media path or file type: ${item.uri}. Please ensure the file is a jpeg, png, or webp file.`);
129
+ const mediaPath = path.join(process.cwd(), item.uri);
130
+ if (!fs.existsSync(mediaPath)) {
131
+ throw new Error(`File doesnt exist: ${item.uri}.`)
132
+ }
133
+
134
+ if (item.purpose == "screenshot" && !checkImageExtension(mediaPath)) {
135
+ throw new Error(`Please ensure the file ${item.uri} is a jpeg, png, or webp file.`)
136
+ }
137
+
138
+ if (item.purpose == "video" && !checkVideoExtension(mediaPath)) {
139
+ throw new Error(`Please ensure the file ${item.uri} is a mp4.`)
125
140
  }
126
141
  }
127
142
  );
@@ -130,12 +145,26 @@ export const loadPublishDetailsWithChecks = async (
130
145
  (asset: any) => asset.purpose === "screenshot"
131
146
  )
132
147
 
133
- if (screenshots.length < 4) {
134
- showMessage(
135
- "Screenshots requirements changing in version 0.9.0",
136
- `At least 4 screenshots are required for publishing a new release. Found only ${screenshots.length}`,
137
- "warning"
138
- )
148
+ for (const item of screenshots) {
149
+ const mediaPath = path.join(process.cwd(), item.uri);
150
+ if (await checkScreenshotSize(mediaPath)) {
151
+ throw new Error(`Screenshot ${mediaPath} must be at least 1080px in width and height.`);
152
+ }
153
+ }
154
+
155
+ const videos = config.release.media?.filter(
156
+ (asset: any) => asset.purpose === "video"
157
+ )
158
+
159
+ for (const video of videos) {
160
+ const mediaPath = path.join(process.cwd(), video.uri);
161
+ if (await checkVideoSize(mediaPath)) {
162
+ throw new Error(`Video ${mediaPath} must be at least 720px in width and height.`);
163
+ }
164
+ }
165
+
166
+ if (screenshots.length + videos.length < 4) {
167
+ throw new Error(`At least 4 screenshots or videos are required for publishing a new release. Found only ${screenshots.length + videos.length}`)
139
168
  }
140
169
 
141
170
  validateLocalizableResources(config);
@@ -172,6 +201,13 @@ const checkImageExtension = (uri: string): boolean => {
172
201
  );
173
202
  };
174
203
 
204
+ const checkVideoExtension = (uri: string): boolean => {
205
+ const fileExt = path.extname(uri).toLowerCase();
206
+ return (
207
+ fileExt == ".mp4"
208
+ );
209
+ };
210
+
175
211
  /**
176
212
  * We need to pre-check some things in the localized resources before we move forward
177
213
  */
@@ -204,6 +240,19 @@ const checkIconDimensions = async (iconPath: string): Promise<boolean> => {
204
240
  return size?.width != size?.height || (size?.width ?? 0) != 512;
205
241
  };
206
242
 
243
+ const checkScreenshotSize = async (imagePath: string): Promise<boolean> => {
244
+ const size = await runImgSize(imagePath);
245
+
246
+ return (size?.width ?? 0) < 1080 || (size?.height ?? 0) < 1080;
247
+ }
248
+
249
+ const checkVideoSize = async (imagePath: string): Promise<boolean> => {
250
+ const size = await getVideoDimensions(imagePath);
251
+
252
+ return (size?.width ?? 0) < 720 || (size?.height ?? 0) < 720;
253
+ }
254
+
255
+
207
256
  const getAndroidDetails = async (
208
257
  aaptDir: string,
209
258
  apkPath: string
@@ -223,7 +272,7 @@ const getAndroidDetails = async (
223
272
  const minSdk = new RegExp(
224
273
  AaptPrefixes.sdkPrefix + AaptPrefixes.quoteRegex
225
274
  ).exec(stdout);
226
- const permissions = [...stdout.matchAll(/uses-permission: name='(.*)'/g)];
275
+ const permissions = [...stdout.matchAll(/uses-permission: name='(.*)'/g)].flatMap(permission => permission[1]);
227
276
  const locales = new RegExp(
228
277
  AaptPrefixes.localePrefix + AaptPrefixes.quoteNonLazyRegex
229
278
  ).exec(stdout);
@@ -241,6 +290,30 @@ const getAndroidDetails = async (
241
290
  localeArray = ["en-US"].concat(localesSrc.split("' '").slice(1));
242
291
  }
243
292
 
293
+ if (permissions.includes("android.permission.INSTALL_PACKAGES") || permissions.includes("android.permission.DELETE_PACKAGES")) {
294
+ showMessage(
295
+ "App requests system app install/delete permission",
296
+ "Your app requests system install/delete permission which is managed by Solana dApp Store.\nThis app will be not approved for listing on Solana dApp Store.",
297
+ "error"
298
+ );
299
+ }
300
+
301
+ if (permissions.includes("android.permission.REQUEST_INSTALL_PACKAGES") || permissions.includes("android.permission.REQUEST_DELETE_PACKAGES")) {
302
+ showMessage(
303
+ "App requests install or delete permission",
304
+ "App will be subject to additional security reviews for listing on Solana dApp Store and processing time may be beyond regular review time",
305
+ "warning"
306
+ );
307
+ }
308
+
309
+ if (permissions.includes("com.solanamobile.seedvault.ACCESS_SEED_VAULT")) {
310
+ showMessage(
311
+ "App requests Seed Vault permission",
312
+ "If this is not a wallet application, your app maybe rejected from listing on Solana dApp Store.",
313
+ "warning"
314
+ );
315
+ }
316
+
244
317
  if (localeArray.length >= 60) {
245
318
  showMessage(
246
319
  "The bundle apk claims supports for following locales",
@@ -252,13 +325,15 @@ const getAndroidDetails = async (
252
325
  );
253
326
  }
254
327
 
328
+ checkAbis(apkPath);
329
+
255
330
  return {
256
331
  android_package: appPackage?.[1] ?? "",
257
332
  min_sdk: parseInt(minSdk?.[1] ?? "0", 10),
258
333
  version_code: parseInt(versionCode?.[1] ?? "0", 10),
259
334
  version: versionName?.[1] ?? "0",
260
335
  cert_fingerprint: await extractCertFingerprint(aaptDir, apkPath),
261
- permissions: permissions.flatMap(permission => permission[1]),
336
+ permissions: permissions,
262
337
  locales: localeArray
263
338
  };
264
339
  } catch (e) {
@@ -270,6 +345,35 @@ const getAndroidDetails = async (
270
345
  }
271
346
  };
272
347
 
348
+ const checkAbis = async (apkPath: string) => {
349
+ try {
350
+ const { stdout } = await runExec(`zipinfo -s ${apkPath} | grep \.so$`);
351
+ const amV7libs = [...stdout.matchAll(/lib\/armeabi-v7a\/(.*)/g)].flatMap(permission => permission[1]);
352
+ const x86libs = [...stdout.matchAll(/lib\/x86\/(.*)/g)].flatMap(permission => permission[1]);
353
+ const x8664libs = [...stdout.matchAll(/lib\/x86_64\/(.*)/g)].flatMap(permission => permission[1]);
354
+ if (amV7libs.length > 0 || x86libs.length > 0 || x8664libs.length > 0) {
355
+
356
+ const messages = [
357
+ `Solana dApp Store only supports arm64-v8a abi.`,
358
+ `Your apk file contains following unsupported abis`,
359
+ ... amV7libs.length > 0 ? [`\narmeabi-v7a:\n` + amV7libs] : [],
360
+ ... x86libs.length > 0 ? [`\nx86:\n` + x86libs] : [],
361
+ ... x8664libs.length > 0 ? [`\nx86_64:\n` + x8664libs] : [],
362
+ `\n\nAlthough your app works fine on Saga, these library files are unused and increase the size of apk file making the download and update time longer for your app.`,
363
+ `\n\nSee https://developer.android.com/games/optimize/64-bit#build-with-64-bit for how to optimize your app.`,
364
+ ].join('\n')
365
+
366
+ showMessage(
367
+ `Unsupported files found in apk`,
368
+ messages,
369
+ `warning`
370
+ )
371
+ }
372
+ } catch (e) {
373
+ // Ignore this error.
374
+ }
375
+ }
376
+
273
377
  export const extractCertFingerprint = async (aaptDir: string, apkPath: string): Promise<string> => {
274
378
  const { stdout } = await runExec(`${aaptDir}/apksigner verify --print-certs -v "${apkPath}"`);
275
379
 
@@ -283,7 +387,7 @@ export const extractCertFingerprint = async (aaptDir: string, apkPath: string):
283
387
  }
284
388
  }
285
389
 
286
- export const writeToPublishDetails = async ({ publisher, app, release }: SaveToConfigArgs) => {
390
+ export const writeToPublishDetails = async ({ publisher, app, release, lastSubmittedVersionOnChain, lastUpdatedVersionOnStore }: SaveToConfigArgs) => {
287
391
  const currentConfig = await loadPublishDetailsWithChecks();
288
392
 
289
393
  delete currentConfig.publisher.icon;
@@ -302,7 +406,9 @@ export const writeToPublishDetails = async ({ publisher, app, release }: SaveToC
302
406
  ...currentConfig.release,
303
407
  address: release?.address ?? currentConfig.release.address
304
408
  },
305
- solana_mobile_dapp_publisher_portal: currentConfig.solana_mobile_dapp_publisher_portal
409
+ solana_mobile_dapp_publisher_portal: currentConfig.solana_mobile_dapp_publisher_portal,
410
+ lastSubmittedVersionOnChain: lastSubmittedVersionOnChain ?? currentConfig.lastSubmittedVersionOnChain,
411
+ lastUpdatedVersionOnStore: lastUpdatedVersionOnStore ?? currentConfig.lastUpdatedVersionOnStore
306
412
  };
307
413
 
308
414
  fs.writeFileSync(Constants.getConfigFilePath(), dump(newConfig, {
@@ -24,7 +24,15 @@ release:
24
24
  - purpose: icon
25
25
  uri: <<RELATIVE_PATH_TO_RELEASE_ICON>>
26
26
  - purpose: screenshot
27
- uri: <<RELATIVE_PATH_TO_SCREENSHOT>>
27
+ uri: <<RELATIVE_PATH_TO_SCREENSHOT1>>
28
+ - purpose: screenshot
29
+ uri: <<RELATIVE_PATH_TO_SCREENSHOT2>>
30
+ - purpose: screenshot
31
+ uri: <<RELATIVE_PATH_TO_SCREENSHOT3>>
32
+ - purpose: screenshot
33
+ uri: <<RELATIVE_PATH_TO_SCREENSHOT4>>
34
+ - purpose: video
35
+ uri: <<RELATIVE_PATH_TO_VIDEO1>>
28
36
  files:
29
37
  - purpose: install
30
38
  uri: <<RELATIVE_PATH_TO_APK>>