Skip to content
Skip to chapter

Chapter 25

User interfaces

Install and release a Workspace add-on

Continue the Drive checklist through development installation, versioned release rehearsal, Marketplace configuration, and OAuth verification preparation.

By the end

Identify the code and configuration an installed user receives, observe a harmless update and restore, and prepare an honest release record without confusing development access with publication approval.

Chapter navigation

The checklist works in your Drive side panel, which proves that your development installation runs the latest saved code. Releasing an add-on to other users separates that mutable head code from versioned deployments, Google Cloud OAuth configuration, and a Google Workspace Marketplace listing.

Continue with the workspace-addons project from the preceding chapter. You can stop after testing the head deployment in your own Drive side panel, or continue through standard Google Cloud project association, Marketplace SDK configuration, and a versioned update rehearsal with a draft tester.

Name the things you are releasing

An Apps Script project contains the editable source and manifest, whose head tracks the most recently saved files. A script version is an immutable snapshot of those files and manifest. A versioned deployment has a stable Deployment ID and points at one numbered version; updating the deployment points that same ID at a different version without changing the Marketplace configuration. Saving edits in the script editor does not advance a versioned deployment; see Create and manage deployments.

Track these six identifiers in your release notes:

ItemWhere to obtain itWhat it identifies
Script IDApps Script Project Settings, under IDsThe script project
Script version numberDeploy > Manage deployments (selected deployment's Version) or Project HistoryAn immutable snapshot in that script project
Deployment IDDeploy > Manage deployments, under Deployment IDA versioned deployment target that can be repointed to another version
Cloud project ID and project numberGoogle Cloud console dashboard, under Project infoThe associated standard Google Cloud project (Apps Script's Change project dialog takes the numeric Project number)
OAuth client IDGoogle Auth platform > Clients in the Cloud consoleThe OAuth client associated with the Cloud project
Marketplace listing URL and statusMarketplace SDK > Store ListingThe Marketplace distribution entry and its publication state

Never place OAuth client secrets, authorization codes, access tokens, or refresh tokens in release notes or source files. Associating a standard Cloud project with Apps Script creates the Apps Script OAuth client automatically, so you do not create a manual web-application OAuth client or client secret in Code.gs; see Configure OAuth for Marketplace apps.

Install the development copy first

Use the same Google account in the Apps Script editor and desktop Drive. Google's developer installation path requires edit access to the script project (and requires additional developer testers to belong to the script owner's domain), so do not share script edit access as an end-user distribution method.

The manifest declares no OAuth scopes:

appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [],
  "addOns": {
    "common": {
      "name": "Handoff checklist",
      "logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/check_circle_black_48dp.png",
      "homepageTrigger": {
        "runFunction": "onHomepage",
        "enabled": true
      }
    },
    "drive": {}
  }
}

Download appsscript.json
  1. Open your Handoff checklist project in Apps Script and note its Script ID in Project Settings.

  2. Choose Deploy > Test deployments > Install, then Done.

  3. Refresh Drive in the same account and open Handoff checklist from the right-side panel.

  4. Click Reload saved. If the account has no saved record yet, check the first two items, enter REQ-001: check access with the next owner., and click Save checklist so the card shows 2 of 3 checked and revision 1. If a saved record already exists, note its current revision number.

Predict, change, observe, restore

In buildChecklistCard_ in Code.gs (the same file from Chapter 24, Build a personal Workspace add-on), change .setTitle('Handoff checklist') on the normal card header to .setTitle('Handoff checklist - B'), leaving the error card, manifest, callbacks, and property key unchanged.

Save Code.gs, refresh Drive, reopen the add-on, and click Reload saved. Because Test deployments runs head, the card header updates to Handoff checklist - B while the stored note, checked items, and revision number stay unchanged.

Restore .setTitle('Handoff checklist') in Code.gs, save, and reload the card in Drive to confirm the original title returns with the same saved revision. See Head and versioned deployments.

Establish release ownership before changing Cloud projects

Apps Script creates a default Google Cloud project automatically, which is sufficient for Test deployments. Creating a versioned add-on deployment or publishing to the Google Workspace Marketplace requires a standard Google Cloud project; see Apps Script Cloud projects and Create and manage deployments.

Separate the operational roles before switching Cloud projects:

  • Script maintainers edit the source and manifest. The account that creates a versioned deployment owns that deployment; transferring the script file does not transfer existing deployment ownership, and deleting the deployment owner's account can break active deployments.

  • Cloud administrators manage the standard Cloud project, billing, API enablement, and IAM roles (Project Browser and OAuth Config Editor are required to link Apps Script to an existing standard project).

  • Marketplace publishers configure the Marketplace SDK using roles/appmetadata.workspaceMarketplaceAppConfigurationAdmin; see Marketplace SDK troubleshooting.

  • Brand and domain owners verify ownership of the public website domain and maintain the support and legal URLs.

  • Workspace administrators control domain installation policies and app allowlists.

To link an existing standard Cloud project, copy its numeric Project number from the Cloud console, open Project Settings > Google Cloud Project > Change project in Apps Script, enter the project number, and click Set project.

Switching from the default Cloud project deletes the default project permanently, requires existing users to reauthorize, and cannot be undone. Moving an already published Marketplace app to a different Cloud project does not migrate its Marketplace listing, users, or reviews; see Use a different standard project.

In the standard Cloud project, open APIs & Services > Library and enable the Google Workspace Marketplace SDK (used for both private domain listings and public listings); see Enable and configure the Marketplace SDK and Configure OAuth for Marketplace apps.

In the Cloud console, open Google Auth platform > Branding to configure the app name, support email, audience, and developer contact email across Branding, Audience, and Data Access; see Configure OAuth for Marketplace apps.

ControlWhat the decision means
OAuth InternalRestricts authorization to accounts within the Cloud project's Google Workspace organization (external accounts receive org_internal).
OAuth ExternalAllows Google Accounts outside the organization to authorize, subject to testing or production status.
OAuth TestingLimits external authorization to up to 100 listed test users and expires Refresh Tokens after seven days.
OAuth In productionRemoves the Testing user list cap and seven-day expiration. Clicking Publish app under Audience changes OAuth status, not Marketplace listing status.

For an External app in Testing mode, add authorized accounts under Audience > Test users > Add users. Public Marketplace listings require External and In production OAuth status, whereas organization-only listings can use Internal; see Manage OAuth app audience and Marketplace app review requirements.

Keep the scope record faithful to the code

Open Data Access and verify that the declared OAuth scopes match both appsscript.json and Marketplace SDK > App Configuration > OAuth Scopes. For this checklist project, the scope list is empty; remove any prefilled scopes in the SDK or OAuth console so all three places match; see Understand OAuth scopes.

When an add-on does request Google data scopes, Data Access classifies each scope as non-sensitive, sensitive, or restricted (note that a .readonly suffix does not automatically make a scope non-sensitive). Public apps requesting sensitive scopes generally require sensitive scope verification, and restricted scopes add permitted-use rules and may require a paid annual security assessment under restricted scope verification.

When a later release adds a new scope, update appsscript.json, OAuth Data Access, the Marketplace SDK scope list, and the privacy policy before releasing the version, and complete any required OAuth verification first; see Update or unpublish a Marketplace listing.

Prepare a truthful publisher identity and policy

Use a consistent app name and publisher identity across appsscript.json, OAuth Branding, and the Marketplace store listing (public listing titles cannot include version numbers, and the sample's Google-hosted icon must be replaced with your own artwork before submission); see Marketplace app review requirements.

Under Branding, provide your public app homepage, privacy policy URL, and terms of service URL on domains listed in Authorized domains, and verify domain ownership in Google Search Console (Google's Apps Script verification rules require a verified website owner to be an editor or owner of the script project). See OAuth brand verification, Verify site ownership, and Apps Script OAuth client verification.

Even when oauthScopes is empty, the privacy policy must accurately describe how the add-on handles user-entered data:

  • It stores the user's checklist note, checked item IDs, and revision number in Apps Script UserProperties scoped to that user and script project.

  • It does not read Drive files, inspect selections, modify file permissions, or transmit checklist data to external servers.

  • Clearing the note and unchecking boxes before clicking Save checklist overwrites the stored text with empty values while retaining the revisioned property; uninstalling the add-on does not erase UserProperties.

See Marketplace program policies and Manage OAuth app branding.

Configure the correct Marketplace integration

Open APIs & Services > Google Workspace Marketplace SDK > App Configuration in the associated standard Cloud project. Choose Private or Public visibility carefully: Google treats that visibility setting as permanent once saved, and consumer Gmail accounts can only create Public listings; see Enable and configure the Marketplace SDK.

Marketplace controlWhat it controls
PrivateRestricts the Marketplace listing to your Google Workspace organization.
PublicAllows users outside your organization to find and install the listing after Marketplace review.
UnlistedHides the listing from Marketplace search and browse results while allowing installation via direct URL.
Individual + Admin InstallAllows individual users and domain administrators to install the app, subject to domain policies.
Admin Only InstallRestricts installation to Google Workspace administrators.
Store Listing Distribution regionsLimits which countries/regions can access the listing.

Enable only the Google Workspace add-on integration:

Apps Script add-on typeMarketplace SDK integration fields
Google Workspace add-on (this project)Select Google Workspace add-on and enter the versioned Deployment ID from Deploy > Manage deployments.
Editor add-on (Docs, Sheets, Slides, Forms)Uses the Script ID and a numeric Version rather than a Workspace add-on Deployment ID.

Do not enable the SDK's separate Drive app integration for a CardService Drive side-panel add-on; see Choose where your app works. Enter your publisher contact details under Developer Information and click Save Draft.

Complete the listing, even for private distribution

Under Store Listing, fill in the language, application name, short and detailed descriptions, category, pricing, 32x32 and 128x128 icons, 220x140 card banner, host screenshots, and Terms of service, Privacy policy, and Support URLs, then save the draft; see Create a Marketplace store listing.

Rehearse a numbered update without publishing it

With a standard Cloud project and a saved Marketplace listing draft, you can rehearse a versioned release using a second account as a Marketplace draft tester while the publisher account manages Code.gs and deployments. Keep the pilot account free of the editor's Test deployments installation so it receives only the versioned Marketplace draft.

Under Store Listing > Draft Tester, add the pilot's email address to Draft Tester Email Addresses and supply a working Draft Tester Opt-Out URL (up to 100 addresses; Gmail addresses for public listings, or same-domain accounts for private listings). If OAuth Testing mode is active, also add the pilot under Google Auth platform > Audience > Test users. See Test a draft store listing.

Establish baseline A

  1. Confirm that Code.gs has the original 'Handoff checklist' title. In Deploy > New deployment, select Add-on, enter a rehearsal description, and click Deploy.

  2. In Manage deployments, record the created version number (version A) and Deployment ID (deployment R); see Apps Script versions.

  3. Enter R's Deployment ID in the Workspace add-on integration of the Marketplace SDK draft and click Save Draft (do not click Publish or Submit for review).

  4. Signed in as the pilot account, open the draft listing preview URL and install the add-on (requires Individual + Admin Install and domain permission).

  5. In the pilot's Drive tab, open the add-on card, verify the title Handoff checklist, and save or reload a checklist record to note the pilot's starting revision number.

Separate a source edit from a release update

In the publisher's script editor, change the card header title in Code.gs to 'Handoff checklist - B' and save. Refresh both accounts' Drive panels: the publisher's Test deployments installation shows Handoff checklist - B (head), while the pilot's draft installation still shows Handoff checklist because deployment R still points at immutable version A.

Next, advance R to a new version: open Deploy > Manage deployments, select deployment R, click Edit, choose New version under Version, enter a description, and click Deploy. Record the new version number (version B). Because R's Deployment ID did not change, the Marketplace SDK draft needs no edit; see Edit a versioned deployment.

Have the pilot refresh Drive, reopen the add-on, and click Reload saved. The pilot's card now displays Handoff checklist - B while preserving the pilot's saved note, checked items, and revision number.

Roll back the deployment, then restore head

In Manage deployments, edit deployment R again, select version A from the Version dropdown instead of New version, and click Deploy. When the pilot refreshes Drive and clicks Reload saved, the card title returns to Handoff checklist with the pilot's saved checklist intact.

Finally, in the publisher's script editor, restore .setTitle('Handoff checklist') in Code.gs and save. You can also compare versions against head at any time in Project History with Highlight changes; see Apps Script versions.

StageDeveloper headR's selected versionPilot's card title
BaselineHandoff checklistAHandoff checklist
Source saved with - BHandoff checklist - BAHandoff checklist
R advanced to version BHandoff checklist - BBHandoff checklist - B
R rolled back to version AHandoff checklist - BAHandoff checklist
Head restoredHandoff checklistAHandoff checklist

Rolling back a deployment switches the code snapshot without rolling back UserProperties. Keeping callback names, schema version, and item IDs compatible allows version A to read records saved under version B.

Prepare and submit the correct evidence

When preparing a production release:

  • OAuth scope verification: if an expanded add-on requests sensitive or restricted scopes, submit the rationale for each scope, verified domain and branding links, and an unlisted YouTube video demonstrating the end-to-end OAuth consent flow (showing the app identity and client_id) and each scope's feature in action; see Sensitive scope verification and Restricted scope verification.

  • Marketplace review: provide complete listing copy, production icons and banners, working support/privacy/terms URLs, and step-by-step reviewer instructions for exercising the Drive card; see Marketplace app review requirements and Marketplace program policies.

In Store Listing, clicking Submit For Review publishes a Private domain listing immediately and submits a Public listing to Google's review queue; see Publish a Marketplace app and Update or unpublish a Marketplace listing. Track OAuth branding status in Branding / Verification Center and listing status in Store Listing separately.

Diagnose the layer that refused access

ObservationInspect before changing anything
The developer add-on does not appear in DriveConfirm you are signed into the same account, have script edit access, installed via Test deployments, kept addOns.drive in appsscript.json, and refreshed Drive.
A user receives org_internalCheck OAuth Audience (Internal vs External) and the Cloud project's owning organization.
SDK reports missing workspacemarketplace.appconfiguration.view or .updateConfirm the selected Cloud project and grant roles/appmetadata.workspaceMarketplaceAppConfigurationAdmin to the publisher account.
The pilot sees the published listing, or cannot install the draftCheck the pilot's signed-in email against Draft Tester Email Addresses, the draft preview URL, OAuth Test users (if in Testing mode), and domain installation policy.
The listing is reachable but installation is unavailableCheck the SDK install mode (Admin Only vs Individual + Admin Install), regional distribution settings, and Workspace Admin console allowlists.
A scope warning or verification block appears after an updateCompare appsscript.json, OAuth Data Access, and Marketplace SDK > App Configuration > OAuth Scopes.
R changed but the card did notConfirm the pilot's installation channel, the Deployment ID in the SDK draft, and R's selected version, then refresh Drive and reopen the card.
A rolled-back release shows newer checklist dataCode versions do not revert UserProperties; HANDOFF_CHECKLIST_V1 retains the latest saved checklist.

In managed Google Workspace domains, administrators control Marketplace installation under Admin console > Apps > Google Workspace Marketplace apps > Apps list > User Install Settings by organizational unit or group; see Administrator controls for Marketplace installation.

Withdraw the test without pretending to erase data

  1. Confirm Code.gs has the original 'Handoff checklist' title and record R's final version mapping.

  2. In your developer account, choose Deploy > Test deployments > Uninstall > Done and refresh Drive; see Test and debug Apps Script Workspace add-ons.

  3. Have the pilot uninstall the draft add-on from Drive or the Marketplace listing, and remove the pilot's address from Draft Tester Email Addresses if no longer needed.

  4. When rehearsal deployment R has no remaining testers, select R in Deploy > Manage deployments and choose Archive deployment; see Create and manage deployments.

Note that unpublishing a live Marketplace listing (Store Listing > Unpublish) withdraws the store listing for new installs without automatically uninstalling the app from existing users or erasing their UserProperties; see Update or unpublish a Marketplace listing.

Next, catch Apps Script type and scope issues in the browser editor with the myFunction extension.

Project files

Complete source files for this chapter’s examples.

Keep a personal handoff checklist in a Drive add-on
Code.gs
const CHECKLIST_KEY = 'HANDOFF_CHECKLIST_V1';
const NOTE_LIMIT = 120;
const RECORD_BYTE_LIMIT = 2048;
const CHECKLIST_STEPS = [
  { id: 'owner', label: 'Confirm the next owner' },
  { id: 'access', label: 'Check requested access' },
  { id: 'next', label: 'Identify the next step' },
];

/**
 * @typedef {{version: number, revision: number, note: string, checked: string[]}} ChecklistState_
 */
/**
 * @typedef {{note: string, checked: string[]}} ChecklistDraft_
 */
/**
 * @typedef {{hostApp: string, parameters: Record<string, string>, formInputs?: Record<string, unknown>}} ChecklistEvent_
 */

class ChecklistError_ extends Error {
  /** @param {string} code */
  constructor(code) {
    super(code);
    this.code = code;
  }
}

// A manifest homepage trigger returns cards, not an ActionResponse or HTML.
/**
 * @param {unknown} e
 * @returns {CardService.Card[]}
 */
function onHomepage(e) {
  try {
    requireDriveEvent_(e, false);
    const state = withUserState_((_properties, current) => current);
    return [buildChecklistCard_(state)];
  } catch (error) {
    return [buildErrorCard_(messageFor_(error))];
  }
}

// Save replaces this user's one checklist, never a Drive file or its sharing.
/**
 * @param {unknown} e
 * @returns {CardService.ActionResponse}
 */
function saveChecklist(e) {
  try {
    const common = requireAction_(e, 'save');
    const revision = readRevision_(common.parameters.revision);
    const draft = readDraft_(common.formInputs);
    const result = withUserState_((properties, current) => {
      // A repeated submission can already match, even after a lost response.
      if (sameChecklist_(current, draft)) {
        return { state: current, changed: false };
      }
      if (revision !== current.revision) {
        throw new ChecklistError_('STALE');
      }
      if (current.revision === Number.MAX_SAFE_INTEGER) {
        throw new ChecklistError_('LIMIT');
      }
      const next = {
        version: 1,
        revision: current.revision + 1,
        note: draft.note,
        checked: draft.checked,
      };
      const serialized = JSON.stringify(next);
      if (utf8Length_(serialized) > RECORD_BYTE_LIMIT) {
        throw new ChecklistError_('LIMIT');
      }
      properties.setProperty(CHECKLIST_KEY, serialized);
      return { state: next, changed: true };
    });
    return replaceCard_(
      result.state,
      result.changed ? 'Checklist saved.' : 'Already current; nothing written.',
      result.changed,
    );
  } catch (error) {
    // Notification only: do not replace the card or erase an unsaved draft.
    // An unexpected failure can follow a completed property write.
    return notify_(messageFor_(error));
  }
}

// Deliberately discard the displayed draft and read the current saved record.
/**
 * @param {unknown} e
 * @returns {CardService.ActionResponse}
 */
function reloadChecklist(e) {
  try {
    requireAction_(e, 'reload');
    const state = withUserState_((_properties, current) => current);
    return replaceCard_(state, 'Saved checklist loaded.', false);
  } catch (error) {
    return notify_(messageFor_(error));
  }
}

/**
 * @param {unknown} e
 * @param {boolean} requireParameters
 * @returns {ChecklistEvent_}
 */
function requireDriveEvent_(e, requireParameters) {
  if (!isObject_(e) || !isObject_(e.commonEventObject)) {
    throw new ChecklistError_('EVENT');
  }
  const common = e.commonEventObject;
  // Google omits commonEventObject.parameters from homepage events; button
  // actions always carry the parameters their own card attached.
  const parameters = common.parameters === undefined && !requireParameters ? {} : common.parameters;
  if (common.hostApp !== 'DRIVE' || !isStringRecord_(parameters) ||
      (common.formInputs !== undefined && !isObject_(common.formInputs))) {
    throw new ChecklistError_('EVENT');
  }
  return {
    hostApp: 'DRIVE',
    parameters: parameters,
    ...(common.formInputs === undefined ? {} : { formInputs: common.formInputs }),
  };
}

/**
 * @param {unknown} e
 * @param {string} operation
 * @returns {ChecklistEvent_}
 */
function requireAction_(e, operation) {
  const common = requireDriveEvent_(e, true);
  const keys = operation === 'save' ? ['operation', 'revision'] : ['operation'];
  if (!exactKeys_(common.parameters, keys) ||
      common.parameters.operation !== operation) {
    throw new ChecklistError_('EVENT');
  }
  return common;
}

/**
 * @param {unknown} text
 * @returns {number}
 */
function readRevision_(text) {
  if (typeof text !== 'string' || !/^(0|[1-9][0-9]{0,15})$/.test(text)) {
    throw new ChecklistError_('EVENT');
  }
  const revision = Number(text);
  if (!Number.isSafeInteger(revision)) {
    throw new ChecklistError_('EVENT');
  }
  return revision;
}

/**
 * @param {unknown} formInputs
 * @returns {ChecklistDraft_}
 */
function readDraft_(formInputs) {
  // Empty widgets can be absent. A present malformed widget is not empty.
  const inputs = formInputs === undefined ? {} : formInputs;
  if (!isObject_(inputs) ||
      Object.keys(inputs).some((key) => key !== 'note' && key !== 'checked')) {
    throw new ChecklistError_('EVENT');
  }
  const notes = stringValues_(inputs, 'note', 1);
  const note = notes.length === 0 ? '' : notes[0];
  if (!validNote_(note)) {
    throw new ChecklistError_('NOTE');
  }
  const checked = normalizeChecked_(stringValues_(inputs, 'checked', 3), 'STEPS');
  return { note, checked };
}

/**
 * @param {Record<string, unknown>} inputs
 * @param {string} name
 * @param {number} maximum
 * @returns {string[]}
 */
function stringValues_(inputs, name, maximum) {
  if (!Object.prototype.hasOwnProperty.call(inputs, name)) return [];
  const widget = inputs[name];
  if (!isObject_(widget) || !isObject_(widget.stringInputs) ||
      !Array.isArray(widget.stringInputs.value)) {
    throw new ChecklistError_('EVENT');
  }
  const values = widget.stringInputs.value;
  if (values.length > maximum || values.some((value) => typeof value !== 'string')) {
    throw new ChecklistError_('EVENT');
  }
  return values;
}

/**
 * @param {unknown} note
 * @returns {boolean}
 */
function validNote_(note) {
  return typeof note === 'string' && note.length <= NOTE_LIMIT &&
    !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(note);
}

/**
 * @param {string[]} values
 * @param {string} errorCode
 * @returns {string[]}
 */
function normalizeChecked_(values, errorCode) {
  if (!Array.isArray(values) || values.length > CHECKLIST_STEPS.length ||
      values.some((value, index) =>
        typeof value !== 'string' ||
        !CHECKLIST_STEPS.some((step) => step.id === value) ||
        values.indexOf(value) !== index)) {
    throw new ChecklistError_(errorCode);
  }
  // Widget selection order must not turn the same choices into a new save.
  return CHECKLIST_STEPS.filter((step) => values.includes(step.id)).map((step) => step.id);
}

/**
 * @template T
 * @param {(properties: PropertiesService.Properties, current: ChecklistState_) => T} work
 * @returns {T}
 */
function withUserState_(work) {
  const lock = LockService.getUserLock();
  if (!lock.tryLock(1000)) throw new ChecklistError_('BUSY');
  try {
    const properties = PropertiesService.getUserProperties();
    return work(properties, readState_(properties));
  } finally {
    lock.releaseLock();
  }
}

/**
 * @param {PropertiesService.Properties} properties
 * @returns {ChecklistState_}
 */
function readState_(properties) {
  const raw = properties.getProperty(CHECKLIST_KEY);
  if (raw === null) {
    // First render does not create a record.
    return { version: 1, revision: 0, note: '', checked: [] };
  }
  if (typeof raw !== 'string' || raw.length > RECORD_BYTE_LIMIT ||
      utf8Length_(raw) > RECORD_BYTE_LIMIT) {
    throw new ChecklistError_('STATE');
  }
  let state;
  try {
    state = JSON.parse(raw);
  } catch (_error) {
    throw new ChecklistError_('STATE');
  }
  if (!exactKeys_(state, ['version', 'revision', 'note', 'checked']) ||
      state.version !== 1 || !Number.isSafeInteger(state.revision) ||
      state.revision < 1 || !validNote_(state.note)) {
    throw new ChecklistError_('STATE');
  }
  return {
    version: 1,
    revision: state.revision,
    note: state.note,
    checked: normalizeChecked_(state.checked, 'STATE'),
  };
}

/**
 * @param {ChecklistState_} state
 * @param {ChecklistDraft_} draft
 * @returns {boolean}
 */
function sameChecklist_(state, draft) {
  return state.note === draft.note &&
    state.checked.length === draft.checked.length &&
    state.checked.every((value, index) => value === draft.checked[index]);
}

/**
 * @param {unknown} value
 * @returns {value is Record<string, unknown>}
 */
function isObject_(value) {
  return value !== null && typeof value === 'object' && !Array.isArray(value);
}

/**
 * @param {unknown} value
 * @returns {value is Record<string, string>}
 */
function isStringRecord_(value) {
  return isObject_(value) && Object.values(value).every((entry) => typeof entry === 'string');
}

/**
 * @param {unknown} value
 * @param {string[]} keys
 * @returns {boolean}
 */
function exactKeys_(value, keys) {
  return isObject_(value) && Object.keys(value).length === keys.length &&
    keys.every((key) => Object.prototype.hasOwnProperty.call(value, key));
}

/**
 * @param {string} text
 * @returns {number}
 */
function utf8Length_(text) {
  return Utilities.newBlob(text).getBytes().length;
}

/**
 * @param {string} functionName
 * @param {Record<string, string>} parameters
 * @returns {CardService.Action}
 */
function cardAction_(functionName, parameters) {
  return CardService.newAction()
    .setFunctionName(functionName)
    .setParameters(parameters)
    .setLoadIndicator(CardService.LoadIndicator.SPINNER)
    .setPersistValues(false);
}

/**
 * CardService's generated declarations omit the shared widget base type for
 * input widgets, although the runtime accepts them in a card section.
 *
 * @param {CardService.SelectionInput | CardService.TextInput} widget
 * @returns {CardService.Widget}
 */
function inputWidget_(widget) {
  return /** @type {CardService.Widget} */ (/** @type {unknown} */ (widget));
}

function reloadButton_() {
  return CardService.newTextButton()
    .setText('Reload saved')
    .setOnClickAction(cardAction_('reloadChecklist', { operation: 'reload' }));
}

/**
 * @param {ChecklistState_} state
 * @returns {CardService.Card}
 */
function buildChecklistCard_(state) {
  const progress = state.revision === 0
    ? 'No saved checklist yet. 0 of 3 checked.'
    : 'Saved progress: ' + state.checked.length + ' of 3 checked. Revision ' + state.revision + '.';
  const choices = CardService.newSelectionInput()
    .setType(CardService.SelectionInputType.CHECK_BOX)
    .setFieldName('checked')
    .setTitle('Completed items');
  for (const step of CHECKLIST_STEPS) {
    choices.addItem(step.label, step.id, state.checked.includes(step.id));
  }
  const note = CardService.newTextInput()
    .setFieldName('note')
    .setTitle('Next-step note')
    .setHint('Optional; one line, up to 120 characters. Use fictional data.')
    .setMultiline(false)
    // A widget value, not interpolated HTML or a formatted TextParagraph.
    .setValue(state.note);
  const save = CardService.newTextButton()
    .setText('Save checklist')
    .setOnClickAction(cardAction_('saveChecklist', {
      operation: 'save',
      revision: String(state.revision),
    }));
  const buttons = CardService.newButtonSet().addButton(save).addButton(reloadButton_());
  const section = CardService.newCardSection()
    .addWidget(CardService.newTextParagraph().setText(progress))
    .addWidget(CardService.newTextParagraph().setText(
      'One personal checklist, not linked to a selected file. No files or permissions are checked.',
    ))
    .addWidget(inputWidget_(choices))
    .addWidget(inputWidget_(note))
    .addWidget(CardService.newTextParagraph().setText(
      'Edits are not saved until Save checklist. Reload saved discards unsaved edits.',
    ))
    .addWidget(buttons);
  return CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader().setTitle('Handoff checklist'))
    .addSection(section)
    .build();
}

/**
 * @param {string} message
 * @returns {CardService.Card}
 */
function buildErrorCard_(message) {
  return CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader().setTitle('Checklist unavailable'))
    .addSection(CardService.newCardSection()
      .addWidget(CardService.newTextParagraph().setText(message))
      .addWidget(CardService.newButtonSet().addButton(reloadButton_())))
    .build();
}

/**
 * @param {ChecklistState_} state
 * @param {string} message
 * @param {boolean} changed
 * @returns {CardService.ActionResponse}
 */
function replaceCard_(state, message, changed) {
  return CardService.newActionResponseBuilder()
    .setNavigation(CardService.newNavigation().updateCard(buildChecklistCard_(state)))
    .setNotification(CardService.newNotification().setText(message))
    .setStateChanged(changed)
    .build();
}

/**
 * @param {string} message
 * @returns {CardService.ActionResponse}
 */
function notify_(message) {
  return CardService.newActionResponseBuilder()
    .setNotification(CardService.newNotification().setText(message))
    .build();
}

/**
 * @param {unknown} error
 * @returns {string}
 */
function messageFor_(error) {
  if (error instanceof ChecklistError_) {
    switch (error.code) {
      case 'EVENT':
        return 'Invalid card input. Reopen the installed add-on in Drive; nothing was written.';
      case 'NOTE':
        return 'Note not saved. Use one line of at most 120 characters without control characters.';
      case 'STEPS':
        return 'Checklist not saved. Choose only the three displayed items, without duplicates.';
      case 'STALE':
        return 'Not saved: the stored checklist changed. Keep your draft elsewhere, then Reload saved.';
      case 'BUSY':
        return 'Another checklist operation is busy. Wait, then try again; this action wrote nothing.';
      case 'STATE':
        return 'Stored checklist is invalid. It was not reset. Preserve it and ask the project owner for help.';
      case 'LIMIT':
        return 'Checklist storage limit reached. Nothing was written; ask the project owner for help.';
    }
  }
  return 'Could not confirm the result. Keep your draft elsewhere, then Reload saved before saving again.';
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [],
  "addOns": {
    "common": {
      "name": "Handoff checklist",
      "logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/check_circle_black_48dp.png",
      "homepageTrigger": {
        "runFunction": "onHomepage",
        "enabled": true
      }
    },
    "drive": {}
  }
}