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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions decompile.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,17 @@ func decompile(b []byte) {
printDecompDebug()
}

var writeErr = os.WriteFile(outputPath, []byte(code.String()), 0600)
var finalCode = code.String()
if len(extractedReferences) > 0 {
var refsCode strings.Builder
for _, ref := range extractedReferences {
refsCode.WriteString(makeRefCode(&ref))
}
refsCode.WriteString("\n")
finalCode = refsCode.String() + finalCode
}

var writeErr = os.WriteFile(outputPath, []byte(finalCode), 0600)
handle(writeErr)
}

Expand Down Expand Up @@ -1279,7 +1289,7 @@ func makeRawAction(action *ShortcutAction) string {
return fmt.Sprintf("rawAction(\"%s\")", action.WFWorkflowActionIdentifier)
}

var rawParams = processRawParameters(action.WFWorkflowActionParameters)
var rawParams = processRawParameters(action, action.WFWorkflowActionParameters)
var jb, jsonErr = json.MarshalIndent(rawParams, strings.Repeat("\t", tabLevel), "\t")
handle(jsonErr)

Expand All @@ -1288,22 +1298,72 @@ func makeRawAction(action *ShortcutAction) string {
return fmt.Sprintf("rawAction(%s)", arguments)
}

func processRawParameters(params map[string]any) map[string]any {
func processRawParameters(action *ShortcutAction, params map[string]any) map[string]any {
for key, value := range params {
if key == UUID || key == "CustomOutputName" {
delete(params, key)
continue
}

if reflect.TypeOf(value).Kind() == reflect.Map {
decompilingDictionary = true
params[key] = decompValueObject(value.(map[string]interface{}))
var decompiled = decompValueObject(value.(map[string]interface{}))
decompilingDictionary = false

if decompiled == "" {
params[key] = fmt.Sprintf("${%s}", registerOpaqueReference(action.WFWorkflowActionIdentifier, key, value))
continue
}

params[key] = decompiled
}
}

return params
}

// registerOpaqueReference preserves a raw action parameter value that decompValueObject could not
// represent as Cherri source (e.g. an app-specific structure like a HomeKit scene selection) by
// capturing it as a #ref declaration instead of silently discarding it. Returns the reference
// identifier to use as a `${identifier}` placeholder in the emitted rawAction dict.
func registerOpaqueReference(actionIdentifier string, key string, value any) string {
var namespace = actionIdentifier
if i := strings.LastIndex(namespace, "."); i != -1 {
namespace = namespace[i+1:]
}
var baseIdentifier = fmt.Sprintf("%s_%s", namespace, key)
sanitizeIdentifier(&baseIdentifier)

var identifier = baseIdentifier
for suffix := 2; ; suffix++ {
var collision = false
for _, existing := range extractedReferences {
if existing.identifier != identifier {
continue
}
if reflect.DeepEqual(existing.value, value) {
return identifier
}
collision = true
break
}
if !collision {
break
}
identifier = fmt.Sprintf("%s%d", baseIdentifier, suffix)
}

extractedReferences = append(extractedReferences, extractedReference{
action: actionIdentifier,
identifier: identifier,
value: value,
})

decompWarning(fmt.Sprintf("Could not decompile parameter '%s' of action '%s' to Cherri source — it has been preserved as reference '%s' instead. Verify #ref %s at the top of the file was carried over correctly.", key, actionIdentifier, identifier, identifier))

return identifier
}

func matchAction(action *ShortcutAction) (name string, definition actionDefinition) {
for call, def := range actions {
var identifier = strings.ToLower(call)
Expand Down
8 changes: 7 additions & 1 deletion raw_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,14 @@ func normalizeRawActionParamValue(value any) any {
return value
}
if rawActionVariableValueRegex.MatchString(v) {
var inner = strings.Trim(v, "${@}")
if !strings.Contains(v, "@") {
if ref, found := references[inner]; found {
return ref
}
}
return variableValue(varValue{
value: strings.Trim(v, "${@}"),
value: inner,
})
}
return attachmentValues(v)
Expand Down
66 changes: 65 additions & 1 deletion references.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,69 @@ func extractMediaReference(ref *extractedReference, values map[string]interface{
return
}

// referenceDataMarker tags a base64 string in a reference hash as having come from a plist <data>
// (raw bytes) element, since JSON has no native binary type and would otherwise round-trip it back
// as an indistinguishable <string>. Only relevant to opaque, app-defined structures (e.g. a raw
// action parameter that decompValueObject couldn't otherwise represent); the existing file/media
// reference structs never contain raw byte fields.
const referenceDataMarker = "__cherriData"

// markBinaryData recursively wraps []byte leaves in value so their type survives being
// JSON-marshaled into a reference hash.
func markBinaryData(value any) any {
switch v := value.(type) {
case []byte:
return map[string]any{referenceDataMarker: base64.StdEncoding.EncodeToString(v)}
case map[string]any:
var marked = make(map[string]any, len(v))
for key, item := range v {
marked[key] = markBinaryData(item)
}
return marked
case []any:
var marked = make([]any, len(v))
for i, item := range v {
marked[i] = markBinaryData(item)
}
return marked
default:
return value
}
}

// unmarkBinaryData reverses markBinaryData after a reference hash has been JSON-decoded, turning
// marked base64 strings back into []byte so they serialize as a plist <data> element again.
func unmarkBinaryData(value any) any {
switch v := value.(type) {
case map[string]any:
if len(v) == 1 {
if encoded, found := v[referenceDataMarker]; found {
if s, ok := encoded.(string); ok {
if decoded, decodeErr := base64.StdEncoding.DecodeString(s); decodeErr == nil {
return decoded
}
}
}
}
var unmarked = make(map[string]any, len(v))
for key, item := range v {
unmarked[key] = unmarkBinaryData(item)
}
return unmarked
case []any:
var unmarked = make([]any, len(v))
for i, item := range v {
unmarked[i] = unmarkBinaryData(item)
}
return unmarked
default:
return value
}
}

// makeReferenceHash returns a unique hash for a reference.
func makeReferenceHash(ref *extractedReference) string {
var jsonBytes, marshalErr = json.Marshal(ref.value)
var jsonBytes, marshalErr = json.Marshal(markBinaryData(ref.value))
handle(marshalErr)

return base64.StdEncoding.EncodeToString(jsonBytes)
Expand All @@ -139,6 +199,10 @@ func decodeReferenceHash(hash string) (ref map[string]any, err error) {
return nil, fmt.Errorf("could not unmarshal decoded JSON: %s", unmarshalErr)
}

for key, value := range ref {
ref[key] = unmarkBinaryData(value)
}

return ref, nil
}

Expand Down
8 changes: 8 additions & 0 deletions tests/raw-action-variable-values.cherri
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,11 @@ rawAction("is.workflow.actions.downloadurl", {
"WFHTTPBodyType": "File",
"WFRequestVariable": "${markdown}"
})

// a #ref usable as a rawAction() dict value, same as decompiling a Shortcut whose raw action
// parameter couldn't be represented as Cherri source would produce
#ref exampleOpaqueValue eyJFeGFtcGxlTGFiZWwiOiJleGFtcGxlIiwiRXhhbXBsZVNlcmlhbGl6ZWREYXRhIjp7Il9fY2hlcnJpRGF0YSI6IlkyaGxjbkpwSUc5d1lYRjFaU0J5WldabGNtVnVZMlVnZEdWemRBPT0ifX0=

rawAction("is.workflow.actions.homeaccessory", {
"WFHomeTriggerActionSets": "${exampleOpaqueValue}"
})