<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>진재명의 블로그</title><description>만들면서 알게 된 것들을 적어 둡니다. iOS와 소프트웨어, 그리고 덜어내는 일에 대한 기술 노트와 짧은 생각. 부산에서.</description><link>https://jaemyeong.com/en/blog/</link><language>en</language><atom:link href="https://blog.jaemyeong.com/en/rss.xml" rel="self" type="application/rss+xml"/><lastBuildDate>Wed, 30 Sep 2026 10:50:00 GMT</lastBuildDate><image><url>https://blog.jaemyeong.com/og/default.png</url><title>진재명의 블로그</title><link>https://jaemyeong.com/en/blog/</link></image><item><title>Skewers at Sutnom Kkochigui in Yeonsan-dong: tasty and good value</title><link>https://jaemyeong.com/en/blog/yeonsan-sutnom-kkochigui-20260429/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/yeonsan-sutnom-kkochigui-20260429/</guid><description>A skewer izakaya for a second round after makchang. I was full, so we ordered only the ten-skewer set. It tasted good and felt worth the price.</description><pubDate>Wed, 30 Sep 2026 10:50:00 GMT</pubDate><content:encoded>&lt;p&gt;After makchang at &lt;a href=&quot;/en/posts/yeonsan-yeonmakchang-20260429/&quot;&gt;Yeonmakchang Yeonsan&lt;/a&gt;, I came here for a second round at about 9 on a Wednesday night. I was already full, so I picked it to eat lightly on skewers. It is an izakaya that serves skewers, and I am told you see it straight ahead at the end of the alley on the Wellness Hospital side.&lt;/p&gt;
&lt;p&gt;We ordered just the assorted skewers, 10 kinds.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/yeonsan-sutnom-kkochigui-20260429/01.jpg&quot; alt=&quot;Assorted skewers lined up on a blue wave-patterned plate, with a seasoned skewer on a small dish&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The ingredients vary. One skewer has tomato in it, and one is wrapped in chives. The seasoned skewer comes separately on a small dish with mayonnaise.&lt;/p&gt;
&lt;p&gt;It tasted good, and it felt worth the price. Around the time I first wrote this visit up on my Naver blog, the menu posted on the map listed the medium assorted skewers at 35,000 won and the large, with 15 kinds, at 45,000 won.&lt;/p&gt;
&lt;p&gt;Next time I plan to start the evening here. I want to order other dishes then, such as okonomiyaki or fish cake soup.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Yeonsan</category><category>skewers</category><category>izakaya</category></item><item><title>Jjaekjjaek Coffee in Gohyeon, Geoje: a warehouse-like room to rest in</title><link>https://jaemyeong.com/en/blog/geoje-jjaekjjaek-coffee-20260502/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/geoje-jjaekjjaek-coffee-20260502/</guid><description>After lunch in Geoje we went to a cafe in Gohyeon that my friend found. The 4,500 won americano was fine, and I liked the room. I spilled some coffee.</description><pubDate>Tue, 29 Sep 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;After lunch in Geoje we thought about where to go next and decided on a cafe. My friend, who was traveling from another city, found the place. It was about 12:30 on a Saturday. There is also a &lt;a href=&quot;/en/posts/geoje-jisimdo-hoetjip-20260502/&quot;&gt;Jisimdo Hoetjip&lt;/a&gt; note from the same day.&lt;/p&gt;
&lt;p&gt;Jjaekjjaek Coffee is in Gohyeon. I am told it is about 5 minutes by car from Gohyeon Bus Terminal, or 15 to 20 minutes on foot.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/geoje-jjaekjjaek-coffee-20260502/02.jpg&quot; alt=&quot;A warehouse-shaped building with the word Nonghyup still on its outer wall&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The outer wall has the word Nonghyup on it. It looked like a Nonghyup warehouse turned into a cafe. I liked how the inside was decorated. I would recommend it to anyone who wants to spend time at ease.&lt;/p&gt;
&lt;p&gt;I ordered an americano. It is 4,500 won. The taste was fine.&lt;/p&gt;
&lt;p&gt;While drinking I spilled a little coffee. Cleaning it up took some effort.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Geoje</category><category>cafe</category><category>coffee</category></item><item><title>The special set at Jisimdo Hoetjip in Geoje: many dishes, but lacking</title><link>https://jaemyeong.com/en/blog/geoje-jisimdo-hoetjip-20260502/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/geoje-jisimdo-hoetjip-20260502/</guid><description>A friend and I went to Geoje on a whim and found a raw fish place that opens at 11. The set has raw fish, sushi, grilled fish, and fried food. It fell short.</description><pubDate>Tue, 29 Sep 2026 09:20:00 GMT</pubDate><content:encoded>&lt;p&gt;A friend from another city and I were talking about where to go and went to Geoje on a whim. It was about 11 on a Saturday morning. We were hungry and searched for a raw fish restaurant, but not many were open. I think it was because of the early hour. We found one that opens at 11 and went in. I wrote about the cafe we visited the same day in the &lt;a href=&quot;/en/posts/geoje-jjaekjjaek-coffee-20260502/&quot;&gt;Jjaekjjaek Coffee&lt;/a&gt; note.&lt;/p&gt;
&lt;p&gt;It is next to the Jangseungpo community center, across from the Jisimdo ticket office, on the second floor.&lt;/p&gt;
&lt;p&gt;We ordered the Jisimdo special set. It comes with raw fish, sushi, grilled fish, and fried food.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/geoje-jisimdo-hoetjip-20260502/01.jpg&quot; alt=&quot;A table with a plate of sushi, round bowls, and seafood in a wooden box divided into four&quot; /&gt;&lt;/p&gt;
&lt;p&gt;There are many dishes. But for me the selection and the amount fell short. That is how it looks next to a neighborhood raw fish place in Busan.&lt;/p&gt;
&lt;p&gt;Travelers may find it fine. The friend I went with seemed fairly satisfied.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Geoje</category><category>seafood</category><category>sashimi</category><category>lunch</category></item><item><title>Triploid oysters at Haerujil Gwangalli: big and tasty</title><link>https://jaemyeong.com/en/blog/gwangalli-haerujil-20260501/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-haerujil-20260501/</guid><description>A seafood place in Gwangalli I had wanted to try since around its opening. My first triploid oysters were big and tasty. The seafood was fresh.</description><pubDate>Tue, 29 Sep 2026 07:40:00 GMT</pubDate><content:encoded>&lt;p&gt;I had wanted to try this place since around the time it opened. A friend and I went at about 7:40 on a Friday evening. I am told you head toward Gwangalli Beach from Geumnyeonsan Station. At about 9 that night we were at &lt;a href=&quot;/en/posts/gwangalli-joseph-20260501/&quot;&gt;Joseph&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There were many guests, and I worried whether we could get in. Luckily two seats were left in a corner, and we took them. The menu says you must order at least 25,000 won, not counting alcohol.&lt;/p&gt;
&lt;p&gt;I had never had triploid oysters, so we ordered the two-piece plate. It was 15,000 won. Around the time I first wrote this visit up on my Naver blog, the menu posted on the map listed it at 16,000 won.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-haerujil-20260501/01.jpg&quot; alt=&quot;Two large oysters on a silver plate, each topped with a slice of lemon&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The oysters were big and tasty. Each comes with one slice of lemon on top. All the seafood was fresh and tasty. I plan to drop by now and then when I am in Gwangalli and there is no line.&lt;/p&gt;
&lt;p&gt;We also ordered the sweet shrimp and ankimo set. It is 25,000 won.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-haerujil-20260501/02.jpg&quot; alt=&quot;Sweet shrimp, seaweed, wasabi, and a lemon wedge on a silver plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Sweet shrimp, seaweed, and wasabi come on one plate. I think my friend felt like having ankimo that day.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>seafood</category><category>oyster</category></item><item><title>Crab-marinade sashimi at Sarangppang in Namcheon-dong: worth one visit</title><link>https://jaemyeong.com/en/blog/namcheon-sarangppang-20260501/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/namcheon-sarangppang-20260501/</guid><description>A seafood place in Namcheon-dong I found on social media and booked ahead. You wrap raw fish with soy-marinated crab. The taste was fine, and it was crowded.</description><pubDate>Tue, 29 Sep 2026 07:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I found this place on social media. A friend came to Busan from another city, so I booked ahead and we went together at about 6 on a Friday evening. It serves seafood dishes in Namcheon-dong. It is on the second floor above Gimbap Maeul, on the way from Namcheon Station toward Haebyeon Market. &lt;a href=&quot;/en/posts/gwangalli-haerujil-20260501/&quot;&gt;Haerujil Gwangalli&lt;/a&gt; is another place I wrote up from this day.&lt;/p&gt;
&lt;p&gt;The place was crowded, and many people were waiting. Without a reservation it would have been hard to get in. The reservations seemed to be full as well.&lt;/p&gt;
&lt;p&gt;We had the special female crab marinade sashimi. You wrap the raw fish together with the marinated crab and eat it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/namcheon-sarangppang-20260501/01.jpg&quot; alt=&quot;A plate of thinly sliced raw fish topped with marinated crab, with rice balls and small side dishes&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The taste was fine. I liked how the room was decorated. I also liked that the menu is built on seafood I do not often see elsewhere, such as crab, shrimp, and razor clams.&lt;/p&gt;
&lt;p&gt;It is worth one visit. Next time I want to try the razor clams.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Namcheon</category><category>seafood</category><category>sashimi</category></item><item><title>Animal Forest, an animal cafe in Gwangalli: even a pig and a chicken</title><link>https://jaemyeong.com/en/blog/gwangalli-animal-forest-20260501/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-animal-forest-20260501/</guid><description>An animal cafe in Gwangalli I visited with a friend over the Children&apos;s Day holiday. You buy a ticket, not food. We saw cats, dogs, a pig, and a chicken.</description><pubDate>Tue, 29 Sep 2026 05:10:00 GMT</pubDate><content:encoded>&lt;p&gt;At about 4:30 on a Friday afternoon, I went to Animal Forest with a friend from another city. It was during the Children&apos;s Day holiday. It is an animal cafe on the 6th floor of the corner building on the sea side of Millak Subyeon-ro in Gwangalli. The place we went to that evening is &lt;a href=&quot;/en/posts/namcheon-sarangppang-20260501/&quot;&gt;Sarangppang&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It is less a place to order food than a place you enter with a ticket. Around the time I first wrote this visit up on my Naver blog, the map listed it at 14,000 won for 90 minutes. I am told children under 18 months get in free after their documents are checked. A snack for the animals is 2,000 won per kind, and they also sell snack sets by course.&lt;/p&gt;
&lt;p&gt;The cats and dogs were resting at ease.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-animal-forest-20260501/01.jpg&quot; alt=&quot;A white and brown cat curled up with its eyes closed on a wooden shelf&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-animal-forest-20260501/02.jpg&quot; alt=&quot;A black and white dog lying on its side on the floor&quot; /&gt;&lt;/p&gt;
&lt;p&gt;We could see a pig up close too. You can also try holding a white chicken on your arm. There are not only unfamiliar animals but familiar pets as well, and there are several animals you can touch.&lt;/p&gt;
&lt;p&gt;I recommend it to anyone who wants to try something different. You can park in the basement, and cars enter from behind the building.&lt;/p&gt;
&lt;p&gt;There were many children that day. I think it was because of the holiday. I wonder whether there are fewer people on other days.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>animal cafe</category><category>cafe</category></item><item><title>Yeonhwa-ri Haenyeochon in Gijang: a great 30,000 won seafood platter</title><link>https://jaemyeong.com/en/blog/gijang-haenyeochon-20260427/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gijang-haenyeochon-20260427/</guid><description>On a family member&apos;s day off we ate by the window at Yeonhwa-ri Haenyeochon. The small seafood platter was a great deal at 30,000 won. No cards.</description><pubDate>Tue, 29 Sep 2026 03:40:00 GMT</pubDate><content:encoded>&lt;p&gt;A family member had a day off, so we went to Gijang to eat together. It was about 3 on a Monday afternoon. My family member and I both like seafood and have similar tastes.&lt;/p&gt;
&lt;p&gt;Seafood stalls are gathered along Yeonhwa 1-gil in Gijang-eup. The area is called Yeonhwa-ri Haenyeochon. I am told there are several stalls run by women divers and that they change places with the season. We went to Manseong Ajumma, the one my family knows well.&lt;/p&gt;
&lt;p&gt;As I see it, the prices and what you get are about the same at each stall. So you can simply pick one with a free window seat that looks out on the sea. We sat at a window with a sea view too.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gijang-haenyeochon-20260427/06.jpg&quot; alt=&quot;A calm sea seen beyond a railing, with buildings in the distance&quot; /&gt;&lt;/p&gt;
&lt;p&gt;We ordered the small assorted seafood for 30,000 won. Sea squirt, abalone, shrimp, and other seafood come on one tray. The menu lists the largest size at 70,000 won.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gijang-haenyeochon-20260427/01.jpg&quot; alt=&quot;A large tray of assorted seafood in small dishes, with sea squirt, abalone, and shrimp&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I was very happy to get this much for this price. Compared with other haenyeo villages in Busan, I think it is cheaper and the selection is better. That is my personal comparison.&lt;/p&gt;
&lt;p&gt;The abalone porridge is served in a pot. It is 12,000 won per person, and the minimum order is two servings. We ordered two. It arrives in the pot and each person ladles out a portion.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gijang-haenyeochon-20260427/03.jpg&quot; alt=&quot;Yellowish abalone porridge served in a pot with a ladle&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The menu says cards cannot be used. As far as I know, all the stalls close in summer and reopen in autumn. It is a long way to go, but I recommend it to anyone who wants to eat seafood while looking at the sea.&lt;/p&gt;
&lt;p&gt;After the meal we moved to &lt;a href=&quot;/en/posts/gijang-cafe-824-20260427/&quot;&gt;Cafe 824&lt;/a&gt; and had mango shaved ice.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gijang</category><category>seafood</category><category>abalone porridge</category></item><item><title>Yeonmakchang in Yeonsan-dong: pre-grilled makchang, clean taste</title><link>https://jaemyeong.com/en/blog/yeonsan-yeonmakchang-20260429/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/yeonsan-yeonmakchang-20260429/</guid><description>A place in Yeonsan-dong where a friend and I went for makchang after work. It came pre-grilled with hardly any smell. We added an order of spicy chicken feet.</description><pubDate>Tue, 29 Sep 2026 01:40:00 GMT</pubDate><content:encoded>&lt;p&gt;I wanted makchang. At about 7 on a Wednesday evening I met a friend after work and we went to Yeonmakchang Yeonsan. I do not come to Yeonsan-dong often.&lt;/p&gt;
&lt;p&gt;The place was nearly full. A little later and we would have had to wait.&lt;/p&gt;
&lt;p&gt;We ordered makchang first. It comes pre-grilled, so you grill it on the plate right away and eat it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/yeonsan-yeonmakchang-20260429/01.jpg&quot; alt=&quot;Makchang cooking on a round grill plate with squash and green onion&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The taste was clean. There was hardly any smell, so it was easy to eat. The staff were kind too.&lt;/p&gt;
&lt;p&gt;While eating the makchang we added one order of flame-grilled spicy chicken feet.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/yeonsan-yeonmakchang-20260429/02.jpg&quot; alt=&quot;Seasoned chicken feet on foil set on the grill plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;After eating here we moved on to &lt;a href=&quot;/en/posts/yeonsan-sutnom-kkochigui-20260429/&quot;&gt;Sutnom Kkochigui&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Yeonsan</category><category>makchang</category><category>barbecue</category></item><item><title>Lee Jae-mo Pizza in Seomyeon: I am not sure it is worth the line</title><link>https://jaemyeong.com/en/blog/seomyeon-leejaemo-pizza-20260428/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/seomyeon-leejaemo-pizza-20260428/</guid><description>I saw the sign on an errand in Seomyeon and went in. I ate about two slices of the Lee Jae-mo Crust and took the rest home. Not bad, but worth a long line?</description><pubDate>Mon, 28 Sep 2026 23:30:00 GMT</pubDate><content:encoded>&lt;p&gt;At about 5 on a Tuesday afternoon I was in Seomyeon on another errand and saw the Lee Jae-mo Pizza sign. About 20 years ago I went to the main store often. I had heard that the taste changed after they switched cheese and that people think less of it now, so I went in to see how different it is.&lt;/p&gt;
&lt;p&gt;I like pizza right out of the oven, so I chose to eat in. I ordered the Lee Jae-mo Crust.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/seomyeon-leejaemo-pizza-20260428/01.jpg&quot; alt=&quot;A whole pizza topped with pepperoni and ham in a black pan&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I ate about two slices and took the rest home. The taste did not leave much of an impression. I could not really tell what had changed from before either.&lt;/p&gt;
&lt;p&gt;It is not bad. But I doubt it is worth standing in a long line for. I also wondered whether visitors expect too much. I am someone who does not find Sungsimdang special either, so it may just be my taste. I do not think people who live in Busan would wait in line for it.&lt;/p&gt;
&lt;p&gt;I would come here only if a friend asked to go. Domino&apos;s cheese pizza suits me better.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Seomyeon</category><category>pizza</category></item><item><title>A glass of Yoichi at Acorn in Suyeong: a smooth end to the night</title><link>https://jaemyeong.com/en/blog/suyeong-acorn-20260425/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/suyeong-acorn-20260425/</guid><description>The third stop of the night, after Tansuho. I had a glass of Yoichi single malt, a Japanese whisky, and it was smooth. I chatted with the owner and went home.</description><pubDate>Mon, 28 Sep 2026 22:10:00 GMT</pubDate><content:encoded>&lt;p&gt;At about 8 on a Saturday night, after the second stop at &lt;a href=&quot;/en/posts/suyeong-tansuho-20260425/&quot;&gt;Tansuho&lt;/a&gt;, I went to Acorn as the third. I wanted to end the day&apos;s drinking with one glass of whisky. There is also &lt;a href=&quot;/en/posts/suyeong-acorn-20260416/&quot;&gt;a note from my visit on April 16&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;That day I had Yoichi single malt. It is a Japanese whisky.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-acorn-20260425/01.jpg&quot; alt=&quot;A bottle of Yoichi single malt next to a whisky glass&quot; /&gt;&lt;/p&gt;
&lt;p&gt;It was smooth. The owner is kind, so it is a good place to end a night of drinking over a conversation. I talked with the owner for a while and then went home.&lt;/p&gt;
&lt;p&gt;I also like Yamazaki and Kavalan. Next time I plan to try those two. The menu posted on the map lists highballs at 11,000 won and cocktails at 15,000 won. I came once more in August as well.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Suyeong</category><category>whisky</category><category>bar</category></item><item><title>Mango shaved ice at Cafe 824 in Gijang, on a terrace by the sea</title><link>https://jaemyeong.com/en/blog/gijang-cafe-824-20260427/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gijang-cafe-824-20260427/</guid><description>A seaside cafe my family and I went to after seafood at Gijang Haenyeochon. We had the signature mango shaved ice for two on the terrace, facing the sea.</description><pubDate>Mon, 28 Sep 2026 13:10:00 GMT</pubDate><content:encoded>&lt;p&gt;After seafood at &lt;a href=&quot;/en/posts/gijang-haenyeochon-20260427/&quot;&gt;Gijang Haenyeochon&lt;/a&gt;, my family and I moved to a cafe. It was about 4 on a Monday afternoon. Cafe 824 is on Gijang Haean-ro, and we sat at an outdoor terrace table with a view of the sea.&lt;/p&gt;
&lt;p&gt;The surroundings were quiet. There were not many guests, so it felt relaxed and still. I think that was because it was a weekday during the day.&lt;/p&gt;
&lt;p&gt;We ordered the signature mango shaved ice. It is for two. Large pieces of mango, scored with a knife, sit on top, and the red beans come separately in a small dish.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gijang-cafe-824-20260427/01.jpg&quot; alt=&quot;A tray of mango shaved ice on a terrace table with the sea behind it&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The shaved ice was good. Still, the main reason I recommend this cafe is the view from the seats that face the sea.&lt;/p&gt;
&lt;p&gt;I was the driver that day. The weather was so nice that I felt like going fishing and having a drink. About a month later I came once more with a friend.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gijang</category><category>cafe</category><category>dessert</category></item><item><title>Corning in Gwangan-dong: gelato with a glass of white wine</title><link>https://jaemyeong.com/en/blog/gwangalli-corning-20260501/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-corning-20260501/</guid><description>A cafe in Gwangan-dong I stopped by on a fine Friday afternoon with a friend. It sells gelato and wine by the glass. I liked the stylish room.</description><pubDate>Mon, 28 Sep 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A friend came to Busan from another city. At about 3 on a Friday afternoon, with good weather, we stopped by Corning together. It is a cafe that sells gelato and wine by the glass. We also dropped by &lt;a href=&quot;/en/posts/gwangalli-animal-forest-20260501/&quot;&gt;Animal Forest&lt;/a&gt; that day.&lt;/p&gt;
&lt;p&gt;It sits inside the alleys on the way down from Gwangan Station to the beach. I am told you turn into an alley twice from the station exit.&lt;/p&gt;
&lt;p&gt;I picked a glass of white wine. Wine by the glass costs 9,000 won, red or white. We added a cold coffee and gelato. Gelato costs 7,000 won if you pick two flavors and 9,000 won if you pick three.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-corning-20260501/01.jpg&quot; alt=&quot;A glass of white wine, an iced coffee, and a cup of gelato on a wooden tray&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The gelato was fine. I liked the feel of the place more. The room is stylish. On a day with good weather you can sit at the outdoor tables too. I got the impression that many of the guests were couples.&lt;/p&gt;
&lt;p&gt;I recommend this cafe to people who like this kind of room. At our seat was a sheet for a collaboration menu offered for five days only (May 1 to 5). It had a lemon à la mode and a few pound cakes on it.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>cafe</category><category>gelato</category><category>wine</category></item><item><title>Aged kimchi gimbap at Tansuho in Mangmi: good food, nice room</title><link>https://jaemyeong.com/en/blog/suyeong-tansuho-20260425/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/suyeong-tansuho-20260425/</guid><description>A second stop after Ijo Sutbul Galbi, at a Korean restaurant. I expected a long line, but we got in right away. The aged kimchi gimbap was good.</description><pubDate>Mon, 28 Sep 2026 10:30:00 GMT</pubDate><content:encoded>&lt;p&gt;After the first stop at &lt;a href=&quot;/en/posts/mangmi-ijo-sutbul-galbi-20260425/&quot;&gt;Ijo Sutbul Galbi&lt;/a&gt;, my younger companion and I moved to Tansuho at about 7 on a Saturday evening. It is a Korean restaurant close to the KT Suyeong branch.&lt;/p&gt;
&lt;p&gt;It looks like a place that usually has a long line, but that day we walked right in without waiting. I think we were lucky. They take reservations too.&lt;/p&gt;
&lt;p&gt;I liked the decoration and the feel of the room. A baseball player&apos;s autograph hangs on the wall. I guessed that players may come here often.&lt;/p&gt;
&lt;p&gt;What I ate that day was the aged kimchi gimbap.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-tansuho-20260425/01.jpg&quot; alt=&quot;A round plate with perilla leaves, seaweed, and several accompaniments&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I liked the food. The menu here seems to change often with the season. The aged kimchi gimbap also seemed to be off the menu around the time I wrote this up.&lt;/p&gt;
&lt;p&gt;The menu had an octopus and aged kimchi carpaccio. The oyster bossam seems to be sold when oysters are in season. Next time I want to try those two.&lt;/p&gt;
&lt;p&gt;From here we went to &lt;a href=&quot;/en/posts/suyeong-acorn-20260425/&quot;&gt;Acorn&lt;/a&gt; and finished with a glass of whisky.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Mangmi</category><category>Korean food</category><category>bar</category></item><item><title>Ijo Sutbul Galbi in Mangmi Market: cheap pork belly grilled outdoors</title><link>https://jaemyeong.com/en/blog/mangmi-ijo-sutbul-galbi-20260425/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/mangmi-ijo-sutbul-galbi-20260425/</guid><description>At my companion&apos;s suggestion we grilled fresh pork belly at an outdoor table in Mangmi Jungang Market. 9,000 won for 130 g, and less smoke outdoors.</description><pubDate>Mon, 28 Sep 2026 09:30:00 GMT</pubDate><content:encoded>&lt;p&gt;At about 5:30 on a Saturday evening, the younger companion I was with suggested it, and we went to Ijo Sutbul Galbi as the first stop of the day. It is inside Mangmi Jungang Market.&lt;/p&gt;
&lt;p&gt;We ate at an outdoor table. Even at that hour there were quite a few guests, many of them young. I wondered whether it is because outdoor tables are in fashion these days. I also got the impression that people who live nearby come often.&lt;/p&gt;
&lt;p&gt;We ordered fresh pork belly. The menu lists it at 9,000 won for 130 g. Several side dishes come with it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/mangmi-ijo-sutbul-galbi-20260425/01.jpg&quot; alt=&quot;Fresh pork belly on a black grill plate, surrounded by side dishes&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The price felt low. Grilling outdoors also meant the smoke bothered me less. The staff who served us were kind. The menu also lists pork ribs at 9,000 won for 130 g and a soybean paste stew, ordered after the meat, at 2,000 won.&lt;/p&gt;
&lt;p&gt;There was a cat shelter among the stacked boxes, and I saw a cat too. It is a good place to grill meat outdoors on a clear day, so I plan to go again when the weather is good.&lt;/p&gt;
&lt;p&gt;After eating here we went to &lt;a href=&quot;/en/posts/suyeong-tansuho-20260425/&quot;&gt;Tansuho&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Mangmi</category><category>pork</category><category>barbecue</category></item><item><title>All-you-can-eat skewers and hot pot at Yangyang near PNU: fair value</title><link>https://jaemyeong.com/en/blog/jangjeon-yangyang-yangkkochi-20260423/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/jangjeon-yangyang-yangkkochi-20260423/</guid><description>A place near PNU that my friend found for malatang and lamb skewers. Skewers and hot pot, all you can eat, for 24,000 won per person. Better than I expected.</description><pubDate>Mon, 28 Sep 2026 07:20:00 GMT</pubDate><content:encoded>&lt;p&gt;My friend wanted malatang and lamb skewers, and my friend also found the place. At about 6 on a Thursday evening we went to Yangyang Yangkkochi near Pusan National University. It is on the second floor, and there is no parking. It has no regular closing day.&lt;/p&gt;
&lt;p&gt;It is an all-you-can-eat place for skewers and hot pot. They also sell skewers only or hot pot only. We chose the combined skewers and hot pot, at 24,000 won per person.&lt;/p&gt;
&lt;p&gt;You bring the skewers and hot pot ingredients yourself from the self-service bar. The skewers turn on their own over the charcoal as they cook.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jangjeon-yangyang-yangkkochi-20260423/01.jpg&quot; alt=&quot;A table with skewers on a rotating charcoal grill, surrounded by plates of ingredients and sauces&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Because it is cheap, I did not expect much from the taste. It turned out to be fine. For this price the offer makes sense. Since you fetch things yourself, I ate at ease without feeling watched.&lt;/p&gt;
&lt;p&gt;It is worth a visit when you want hot pot or lamb skewers near PNU without spending much. I remember that we ate quite a lot. The cup for used skewers filled up.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Jangjeon</category><category>lamb</category><category>hot pot</category></item><item><title>Joseph, a bar in Gwangalli: Macallan 12 at a candlelit bar seat</title><link>https://jaemyeong.com/en/blog/gwangalli-joseph-20260421/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-joseph-20260421/</guid><description>The third stop of the night, after Yasumu. It is the bar I end up at when I am in Gwangalli. I had Macallan 12 and my friend had a non-alcoholic cocktail.</description><pubDate>Mon, 28 Sep 2026 06:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Joseph is a bar in Gwangalli that I like. When I am in Gwangalli I end up coming here. That day, at about 9 on a Tuesday night, my friend and I stopped by as the third place, after the second round at &lt;a href=&quot;/en/posts/gwangalli-yasumu-20260421/&quot;&gt;Izakaya Yasumu Gwangan&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I am told it is behind Pascucci, across from the Haman parking lot. The room is dim, with candles lit here and there. We sat at the bar.&lt;/p&gt;
&lt;p&gt;I chose Macallan 12.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-joseph-20260421/01.jpg&quot; alt=&quot;A bottle of Macallan 12 on the bar next to a long-stemmed glass of whisky&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The friend I was with does not drink, so my friend ordered a non-alcoholic cocktail. &lt;a href=&quot;/en/posts/gwangalli-joseph-20260501/&quot;&gt;I went back on May 1&lt;/a&gt;, and once more in July of the same year.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>whisky</category><category>bar</category></item><item><title>Half-and-half platter at Izakaya Yasumu Gwangan: tasty, good value</title><link>https://jaemyeong.com/en/blog/gwangalli-yasumu-20260421/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-yasumu-20260421/</guid><description>A second stop after Durumi and a walk on the beach. We ordered the 37,000 won platter of raw beef and salmon sashimi. I was too full to eat much.</description><pubDate>Mon, 28 Sep 2026 05:00:00 GMT</pubDate><content:encoded>&lt;p&gt;We had pork for the first meal at &lt;a href=&quot;/en/posts/gwangalli-durumi-20260421/&quot;&gt;Durumi Gwangan&lt;/a&gt; and then walked along Gwangalli Beach to help it settle. After that my friend and I went into Izakaya Yasumu Gwangan for a second round. It was about 8 on a Tuesday night.&lt;/p&gt;
&lt;p&gt;I am told it is across the street from Lucky Sanghoe Gwangan, in the building next to Millak Silbi. A staff member led us to the one table that was left. The other tables were said to be reserved.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-yasumu-20260421/03.jpg&quot; alt=&quot;An interior with the restaurant name on the wall, plants, and tables&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The interior is nicely decorated. I thought it suits couples on a date more than a group of friends.&lt;/p&gt;
&lt;p&gt;We ordered the half-and-half platter. It comes with raw beef sashimi and salmon sashimi, and the menu posted on the map lists it at 37,000 won.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-yasumu-20260421/01.jpg&quot; alt=&quot;Raw beef and salmon sashimi on a round plate, with white mist spreading below it&quot; /&gt;&lt;/p&gt;
&lt;p&gt;White mist rose from under the plate. I think they used dry ice. It tasted good, and the price felt low. I was full, though, and could not eat much. I think it was because I had eaten just before.&lt;/p&gt;
&lt;p&gt;I hear the signature dish here is handmade yakitori. After the meal we moved on to &lt;a href=&quot;/en/posts/gwangalli-joseph-20260421/&quot;&gt;Joseph&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>izakaya</category><category>sashimi</category></item><item><title>Pork belly and fried rice at Durumi Gwangan: worth standing in line</title><link>https://jaemyeong.com/en/blog/gwangalli-durumi-20260421/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-durumi-20260421/</guid><description>The first stop of the day with a friend. The staff explained and grilled the meat for us. We had Jeju pork belly and rib meat, then finished with fried rice.</description><pubDate>Mon, 28 Sep 2026 03:30:00 GMT</pubDate><content:encoded>&lt;p&gt;At about 5 on a Tuesday afternoon, a friend and I went to Durumi Gwangan as the first stop of the day. I am told it is in an alley about a 5-minute walk toward the sea from exit 3 of Geumnyeonsan Station.&lt;/p&gt;
&lt;p&gt;The Durumi main store in Jeonpo has a long line and is hard to get into. At the Gwangan branch, I think getting in is easier than at the main store if you come at opening time.&lt;/p&gt;
&lt;p&gt;The table was a little greasy. It is a barbecue place, so I let it pass.&lt;/p&gt;
&lt;p&gt;We first ordered the Jeju premium pork belly. A staff member explains and grills it for you. I liked the way they explained things.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-durumi-20260421/01.jpg&quot; alt=&quot;Pork belly, kimchi, and mushrooms cooking on a round grill plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Next we had the Hwangje rib meat.&lt;/p&gt;
&lt;p&gt;Among the pork barbecue places I have been to in Busan, I think this is one of the tastier ones. I could see why it is so popular. If there is no line, or you have time to wait, I would recommend it.&lt;/p&gt;
&lt;p&gt;The last dish was fried rice. I suggest you order it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-durumi-20260421/05.jpg&quot; alt=&quot;Fried rice on a stone plate topped with cheese and an egg yolk&quot; /&gt;&lt;/p&gt;
&lt;p&gt;After the meal we walked along Gwangalli Beach and then went to &lt;a href=&quot;/en/posts/gwangalli-yasumu-20260421/&quot;&gt;Izakaya Yasumu Gwangan&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>pork</category><category>barbecue</category></item><item><title>Acorn, a whisky bar in Suyeong: easy even for a first-timer</title><link>https://jaemyeong.com/en/blog/suyeong-acorn-20260416/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/suyeong-acorn-20260416/</guid><description>A small whisky bar in Suyeong that an acquaintance recommended. I had an Old Fashioned and my friend had Bunnahabhain 12. The owner is kind and cheerful.</description><pubDate>Mon, 28 Sep 2026 01:10:00 GMT</pubDate><content:encoded>&lt;p&gt;After the first stop at &lt;a href=&quot;/en/posts/suyeong-baeksu-nongwon-20260416/&quot;&gt;Baeksu Nongwon&lt;/a&gt;, a friend and I came here for a second round. It is a whisky bar recommended by a close acquaintance, and we went at about 8:30 on a Thursday night.&lt;/p&gt;
&lt;p&gt;From the McDonald&apos;s at Suyeong Station, go into the alley on the right and head straight toward Daiso. I am told it is easy to find. Whisky bottles line the inside, and the room is small. Seats may run out, so it is better to ask before you go.&lt;/p&gt;
&lt;p&gt;I ordered an Old Fashioned. I enjoyed it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-acorn-20260416/01.jpg&quot; alt=&quot;An Old Fashioned with ice and orange peel in a glass&quot; /&gt;&lt;/p&gt;
&lt;p&gt;My friend had Bunnahabhain 12.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-acorn-20260416/02.jpg&quot; alt=&quot;A bottle of Bunnahabhain 12 next to a whisky glass&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The owner is kind and cheerful. They recommend whisky to people who do not know it well, and they sell beer too. So I felt it is a place even a first-timer can visit comfortably.&lt;/p&gt;
&lt;p&gt;On the menu posted on the map, cocktails are listed at 15,000 won and highballs at 11,000 won, and whisky is listed with variable pricing. I wrote about going back on the 25th of the same month in &lt;a href=&quot;/en/posts/suyeong-acorn-20260425/&quot;&gt;my April 25 visit note&lt;/a&gt;, and I went once more in August.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Suyeong</category><category>whisky</category><category>bar</category></item><item><title>Pork belly at Baeksu Nongwon in Suyeong: a fair place for a quick stop</title><link>https://jaemyeong.com/en/blog/suyeong-baeksu-nongwon-20260416/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/suyeong-baeksu-nongwon-20260416/</guid><description>A close younger friend suggested it, and we had only pork belly at a barbecue place in Suyeong. The pork, 11,500 won for 130 g, and the service were fair.</description><pubDate>Sun, 27 Sep 2026 23:50:00 GMT</pubDate><content:encoded>&lt;p&gt;A close younger friend suggested it, so at about 7 on a Thursday evening we went to Baeksu Nongwon. It is a barbecue restaurant I have been to before, mostly with this same friend. It is a suitable place to drop by without much thought.&lt;/p&gt;
&lt;p&gt;The hall is large. In my experience I have rarely had to wait. It is on the exit 11 side of Suyeong Station, and the entrance is lit up.&lt;/p&gt;
&lt;p&gt;We ordered the Korean pork belly. It is 11,500 won for 130 g. Several side dishes come with it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-baeksu-nongwon-20260416/02.jpg&quot; alt=&quot;Two thick slabs of pork belly on a charcoal grill plate, with cooked meat set aside on foil&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The quality of the meat was fair. So was the service. That day we had only pork belly and kept the first stop short. I have a separate note from another visit in August.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Suyeong</category><category>pork</category><category>barbecue</category></item><item><title>The A set at Michin Makchang in Dongnae: not much smell, easy to eat</title><link>https://jaemyeong.com/en/blog/dongnae-michin-makchang-20260414/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/dongnae-michin-makchang-20260414/</guid><description>With a university friend after work, I tried a makchang place that looked new. We had the A set of salted and seasoned makchang and finished with pork skin.</description><pubDate>Sun, 27 Sep 2026 22:20:00 GMT</pubDate><content:encoded>&lt;p&gt;At about 7 on a Tuesday evening I met a friend I have known since university, after my friend got off work. While choosing where to eat, we saw a makchang place that looked newly opened and went in. I do not know when it actually opened.&lt;/p&gt;
&lt;p&gt;Come out of exit 4 of Dongnae Station, cross at the crosswalk, and go into the alley.&lt;/p&gt;
&lt;p&gt;The menu was neatly organized. We ordered the A set, which comes with both salted and seasoned makchang.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-michin-makchang-20260414/01.jpg&quot; alt=&quot;Makchang, bean sprouts, and chives grilling on a round iron plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The makchang did not have much of an unpleasant smell and was easy to eat. I would recommend it as a place for makchang.&lt;/p&gt;
&lt;p&gt;We finished with pork skin. Lately I have seen the place full several times.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Dongnae</category><category>makchang</category><category>pork</category></item><item><title>Kalmat Hoetjip: grunt sold out, a pricier fish at no extra charge</title><link>https://jaemyeong.com/en/blog/jangjeon-kalmat-hoetjip-20260411/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/jangjeon-kalmat-hoetjip-20260411/</guid><description>A raw fish place near PNU I have used for almost 20 years. I asked for striped jack and grunt mixed, the grunt was sold out, and they swapped in a pricier fish.</description><pubDate>Sun, 27 Sep 2026 13:20:00 GMT</pubDate><content:encoded>&lt;p&gt;This is the first place I think of when I want sashimi. It is my favorite raw fish restaurant in Busan, and I have been going for almost 20 years. I went with a friend at about 4 on a Saturday afternoon. The restaurant opens at 4, and sometimes they let you in earlier.&lt;/p&gt;
&lt;p&gt;Ordering here is by fish, one kind at a time. They sometimes accept a request to mix, so that day I asked for striped jack (shima-aji) and grunt together. I was told the grunt was sold out. They swapped in dot-benjari and did not charge extra. The menu says dot-benjari costs 10,000 won more than grunt. I felt lucky to eat a fish I had not expected.&lt;/p&gt;
&lt;p&gt;The two fish together came to 70,000 won.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jangjeon-kalmat-hoetjip-20260411/01.jpg&quot; alt=&quot;Sashimi arranged in a circle like petals on a black plate, with corn cheese behind&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Corn cheese is a side dish that comes as standard. Special cuts also came on a small plate.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jangjeon-kalmat-hoetjip-20260411/02.jpg&quot; alt=&quot;A few special cuts of fish on a square patterned plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The regular assorted sashimi is fine too. I especially like what goes into the premium sashimi, though, so I suggest the premium to anyone who goes. If you can match the hours, it is worth a visit.&lt;/p&gt;
&lt;p&gt;I wrote about going back in July in &lt;a href=&quot;/en/posts/jangjeon-kalmat-hoetjip-20260717/&quot;&gt;my July visit note&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Jangjeon</category><category>sashimi</category><category>raw fish</category></item><item><title>Charcoal chicken with chives at Ondaljip in Dongnae: good with soju</title><link>https://jaemyeong.com/en/blog/dongnae-ondaljip-20260403/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/dongnae-ondaljip-20260403/</guid><description>I met a friend after work at our usual charcoal chicken place in Dongnae. We ordered the 14,000 won chive chicken as always and drank two bottles of soju.</description><pubDate>Sun, 27 Sep 2026 12:20:00 GMT</pubDate><content:encoded>&lt;p&gt;At about 7 on a Friday evening, timed to when my friend got off work, we met at Ondaljip Dongnae. I have come here with this friend before. The restaurant grills chicken over charcoal and is on the exit 4 side of Dongnae Station.&lt;/p&gt;
&lt;p&gt;We ordered what we always do: the charcoal-grilled chicken with chives. The cut is chicken thigh, and it is 14,000 won.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-ondaljip-20260403/01.jpg&quot; alt=&quot;Charcoal-grilled chicken piled with chives on a foil tray&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The two of us drank two bottles of Chamisul. We had the chicken alongside the soju. I have a separate note from another visit in August.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Dongnae</category><category>chicken</category><category>barbecue</category></item><item><title>Daebak Donkkaseu in Mandeok: big portions, prices not what they were</title><link>https://jaemyeong.com/en/blog/mandeok-daebak-donkkaseu-20260402/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/mandeok-daebak-donkkaseu-20260402/</guid><description>A Korean-style pork cutlet place I have known since its days in Mandeok Market. Generous portions, with dough soup on the side. Prices have risen.</description><pubDate>Sun, 27 Sep 2026 09:40:00 GMT</pubDate><content:encoded>&lt;p&gt;I had not eaten pork cutlet for a while and started craving it, so I went at about 3 on a Thursday afternoon. I lived in Mandeok for a long time. I have known this place since it was in an alley inside Mandeok Market.&lt;/p&gt;
&lt;p&gt;After a short appearance on the pork cutlet episode of a TV food show, it got more guests, and then it moved out of the market to its current building. People still line up after the move.&lt;/p&gt;
&lt;p&gt;I had the pork cutlet with rice. A small serving of hand-pulled dough soup comes with the cutlet.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/mandeok-daebak-donkkaseu-20260402/01.jpg&quot; alt=&quot;A large Korean-style pork cutlet on a white plate with rice and cabbage&quot; /&gt;&lt;/p&gt;
&lt;p&gt;What stands out here is the generous portion. It is closer to a neighborhood restaurant than a place you travel to for outstanding taste.&lt;/p&gt;
&lt;p&gt;Here is one way to handle the large portion. Ask for the sauce on the side, eat about half, then ask for a container and take the rest home. Of course, if you can finish it alone, eating it all there is better.&lt;/p&gt;
&lt;p&gt;I remember the dough soup being refilled without limit when the place was in the market. Now it seems to be served once, but that is my guess from what I saw.&lt;/p&gt;
&lt;p&gt;When I want Korean-style pork cutlet there are few other places nearby, so I do expect to go again. But it used to be a place that was easy on the wallet. As I remember it, the cutlet costs more than twice what it did before the move. I used to order the cutlet with spicy chewy noodles, and now that combination comes to more than 20,000 won. So I have gone less often since the move.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Mandeok</category><category>pork cutlet</category><category>lunch</category></item><item><title>The set at Ninano in Jeonpo: my friend picks the kimchi tuna gimbap</title><link>https://jaemyeong.com/en/blog/jeonpo-ninano-20260328/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/jeonpo-ninano-20260328/</guid><description>A second stop with friends after Paldak Paldak Hoetjip. We ordered the 47,000 won set: aged kimchi tuna gimbap, suyuk katsu, and corn cheese fire chicken.</description><pubDate>Sun, 27 Sep 2026 08:40:00 GMT</pubDate><content:encoded>&lt;p&gt;After the first stop at &lt;a href=&quot;/en/posts/jeonpo-paldak-paldak-hoetjip-20260328/&quot;&gt;Paldak Paldak Hoetjip&lt;/a&gt;, my friends and I moved to Ninano Jeonpo at about 6 on a Saturday evening. A friend who had been there before suggested it. I gather that friend liked it. It is a bar on the second floor of a building on the exit 6 side of Jeonpo Station.&lt;/p&gt;
&lt;p&gt;A basic side dish comes before the food you order. They draw the bar&apos;s name and a cat face on the plate with sauce.&lt;/p&gt;
&lt;p&gt;We ordered the Pilseung Johap set. It bundles aged kimchi tuna gimbap, suyuk katsu, and corn cheese fire chicken, and costs 47,000 won.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jeonpo-ninano-20260328/01.jpg&quot; alt=&quot;Tuna gimbap wrapped in aged kimchi on a square plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The aged kimchi tuna gimbap is the dish my friend named as the best one here.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jeonpo-ninano-20260328/02.jpg&quot; alt=&quot;Sliced suyuk katsu on a wide plate with a seasoned vegetable salad&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The dishes other than the gimbap looked good as well. I went back at the end of July and tried dishes that are not in this set.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Jeonpo</category><category>bar</category><category>gimbap</category></item><item><title>Sashimi course at Jagalchi Hoetjip, Suyeong: go when the line is short</title><link>https://jaemyeong.com/en/blog/suyeong-jagalchi-hoetjip-20260324/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/suyeong-jagalchi-hoetjip-20260324/</guid><description>Back at a raw fish place in Suyeong after a long gap. The assorted sashimi course, 35,000 won a person, came with good fish and side dishes.</description><pubDate>Sun, 27 Sep 2026 07:20:00 GMT</pubDate><content:encoded>&lt;p&gt;I went back to Jagalchi Hoetjip, which I had not visited for a while. I was with a close acquaintance who lives in Haeundae, and it was about 6 on a Tuesday evening. It is a raw fish restaurant on the exit 5 side of Suyeong Station. It is fairly well known to locals and to travelers alike.&lt;/p&gt;
&lt;p&gt;That day we had the assorted sashimi course. It is 35,000 won per person.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-jagalchi-hoetjip-20260324/01.jpg&quot; alt=&quot;Assorted sashimi from several kinds of fish on a large plate decorated with flowers&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The sashimi and side dishes came out well, as you would expect from a known place. The basic course is fine. For my own taste, though, I like Marine Boy nearby better.&lt;/p&gt;
&lt;p&gt;One step up, the premium sashimi course is 45,000 won per person. I am told the braised dish comes from that course up. If your budget allows, the premium course is the better pick. I remember the premium course being 35,000 won in the past, so the price seems to have gone up. I am not sure my memory is right.&lt;/p&gt;
&lt;p&gt;I suggest coming here when the line is short or there is none. If the line is long, it is better to go to another raw fish place nearby.&lt;/p&gt;
&lt;p&gt;After the meal I moved on to &lt;a href=&quot;/en/posts/suyeong-rentan-20260324/&quot;&gt;Rentan Suyeong&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Suyeong</category><category>sashimi</category><category>raw fish</category></item><item><title>Assorted clams at Suminine in Cheongsapo: the staff grilled everything</title><link>https://jaemyeong.com/en/blog/cheongsapo-suminine-20260321/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/cheongsapo-suminine-20260321/</guid><description>After a trip to Gwangalli with my family, we went on to Cheongsapo for assorted clams. The staff grilled the scallops, clams, and shrimp for us.</description><pubDate>Sun, 27 Sep 2026 06:10:00 GMT</pubDate><content:encoded>&lt;p&gt;On a Saturday my family and I went out to Gwangalli and decided to have dinner in Cheongsapo. It was about 6 in the evening. Cheongsapo is a place I often go to for clams, and now and then for fishing.&lt;/p&gt;
&lt;p&gt;Suminine is a grilled clam restaurant in Cheongsapo. By the map directions, you go down the main road toward the Blue Line Park Cheongsapo stop and turn right, then turn right again into the side alley before you reach the big pine tree.&lt;/p&gt;
&lt;p&gt;One funny thing happened. Perhaps because my hair is long and bleached, the staff seemed to take me for a foreigner. They first spoke to me in a foreign language and were surprised to learn I am Korean.&lt;/p&gt;
&lt;p&gt;I ordered the assorted clams. Scallops and several kinds of clams come on one plate. A female staff member did all the grilling. She did the same the last time I came.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/cheongsapo-suminine-20260321/02.jpg&quot; alt=&quot;Clams, shrimp, and a foil dish on a wire grill over charcoal&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/cheongsapo-suminine-20260321/03.jpg&quot; alt=&quot;Scallops and pen shells cooking on a wire grill over charcoal&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I enjoyed the meal. I think my family ate well too. I had to drive, so I could not drink.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Cheongsapo</category><category>grilled clams</category><category>seafood</category></item><item><title>Momos Coffee at Domoheon: crowded, but the garden is worth it</title><link>https://jaemyeong.com/en/blog/suyeong-momos-coffee-domoheon-20260321/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/suyeong-momos-coffee-domoheon-20260321/</guid><description>Coming down from Hwangnyeongsan, my family and I stopped at the Momos Coffee in Domoheon. Two iced coffees, a madeleine, and a cookie. It was crowded.</description><pubDate>Sun, 27 Sep 2026 05:10:00 GMT</pubDate><content:encoded>&lt;p&gt;My family and I had gone on an outing to Hwangnyeongsan and stopped here for a rest on the way down. It was about 4:30 on a Saturday afternoon. The Momos Coffee main store always has a lot of people waiting, which puts me off. So I chose the branch in Domoheon.&lt;/p&gt;
&lt;p&gt;The cafe is on the first floor of the Domoheon main building. I parked in the Domoheon lot. This branch was crowded too, but by luck we got a seat in the corner.&lt;/p&gt;
&lt;p&gt;We ordered two iced coffees, one madeleine, and one cookie.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-momos-coffee-domoheon-20260321/01.jpg&quot; alt=&quot;Two iced coffees on a tray with a madeleine and a cookie on a plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;My family enjoyed what we had.&lt;/p&gt;
&lt;p&gt;As far as I know, Domoheon used to be the mayor&apos;s official residence. It is now open to the public. The building is well kept and the interior is nicely decorated. I thought it would be a good place for couples to stop by as well. We walked around the building and the garden, and from the garden you can see Gwangan Bridge in the distance.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-momos-coffee-domoheon-20260321/03.jpg&quot; alt=&quot;A garden with dry yellow grass, and apartments and a bridge far beyond the trees&quot; /&gt;&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Suyeong</category><category>cafe</category><category>coffee</category></item><item><title>Sashimi at Makseoreo Hoetjip in Oncheonjang: better than expected</title><link>https://jaemyeong.com/en/blog/oncheonjang-makseoreo-hoetjip-20260314/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/oncheonjang-makseoreo-hoetjip-20260314/</guid><description>A raw fish place found by chance in an alley after Dakdukkeobi. I expected little, but the sashimi and side dishes were in good shape. Prices are average.</description><pubDate>Sun, 27 Sep 2026 02:40:00 GMT</pubDate><content:encoded>&lt;p&gt;It was about 7 on a Saturday evening, after chicken galbi at &lt;a href=&quot;/en/posts/oncheonjang-dakdukkeobi-20260314/&quot;&gt;Dakdukkeobi&lt;/a&gt;. I have no regular raw fish place in Oncheonjang, so I looked around and found Makseoreo Hoetjip in an alley in Oncheonjang.&lt;/p&gt;
&lt;p&gt;I did not expect much. I ordered the assorted sashimi.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/oncheonjang-makseoreo-hoetjip-20260314/01.jpg&quot; alt=&quot;Assorted sashimi spread out lengthwise on a bamboo mat&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The fish was in better condition than I expected. The side dishes, such as steamed egg, pancake, and greens for wraps, were fine too. The price range felt average.&lt;/p&gt;
&lt;p&gt;It is a fair choice when you want a light sashimi meal. I would not go as far as telling someone who already has a favorite raw fish place to come here on purpose.&lt;/p&gt;
&lt;p&gt;I ate in the restaurant. I have a feeling that it stopped dine-in service after that, but I am not sure. When I wrote this up, the delivery menu on Naver Map listed small flounder at 40,000 won and medium flounder at 55,000 won. Those are not the price of the sashimi I had that day.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Oncheonjang</category><category>sashimi</category><category>raw fish</category></item><item><title>Chicken galbi at Dakdukkeobi in Oncheonjang: both kinds were good</title><link>https://jaemyeong.com/en/blog/oncheonjang-dakdukkeobi-20260314/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/oncheonjang-dakdukkeobi-20260314/</guid><description>A chicken galbi place a friend who works nearby introduced. We had two servings each of the salt and the gochujang kind. Easy on the wallet, and both were good.</description><pubDate>Sun, 27 Sep 2026 01:10:00 GMT</pubDate><content:encoded>&lt;p&gt;I met friends I have known since high school at about 5:30 on a Saturday evening. Dakdukkeobi was the first stop of the gathering. It is a chicken galbi restaurant introduced by a friend who works nearby and knows the places in Oncheonjang well.&lt;/p&gt;
&lt;p&gt;It is next to Sarang Nursing Hospital, in front of the Bando parking lot, and across from Bando Apartments, 636 m from exit 1 of Oncheonjang Station.&lt;/p&gt;
&lt;p&gt;The menu says the chicken takes 10 minutes of pre-grilling and that orders start at three servings. We ordered two servings each of the salt chicken galbi and the gochujang chicken galbi. Both are 9,900 won. Greens for wraps, garlic, and several sauces are set out as standard.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/oncheonjang-dakdukkeobi-20260314/01.jpg&quot; alt=&quot;Salt chicken galbi and gochujang chicken galbi cooking on a wire grill over charcoal&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The price was not a burden, and both kinds tasted good. I would suggest it to anyone who wants chicken galbi.&lt;/p&gt;
&lt;p&gt;After eating here we went to &lt;a href=&quot;/en/posts/oncheonjang-makseoreo-hoetjip-20260314/&quot;&gt;Makseoreo Hoetjip&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Oncheonjang</category><category>chicken</category><category>barbecue</category></item><item><title>Raw beef at Minjeong Hanu Suyuk Gukbap in Yeonsan-dong: fresh and fair</title><link>https://jaemyeong.com/en/blog/yeonje-minjeong-hanu-suyuk-gukbap-20260311/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/yeonje-minjeong-hanu-suyuk-gukbap-20260311/</guid><description>A second stop after Beolttejip, at a Korean beef soup place. The raw beef, 300 g for 20,000 won, was fresh, and the 9,000 won beef soup was good too.</description><pubDate>Sat, 26 Sep 2026 23:30:00 GMT</pubDate><content:encoded>&lt;p&gt;This was the second stop after eating at &lt;a href=&quot;/en/posts/yeonje-beolttejip-20260311/&quot;&gt;Beolttejip&lt;/a&gt;. I was with a close younger friend who lives in Haeundae, and it was about 7:30 on a Wednesday evening. Minjeong Hanu Suyuk Gukbap is a Korean beef soup restaurant in Yeonsan-dong. It seemed that people line up there from time to time.&lt;/p&gt;
&lt;p&gt;It is a soup place, but I also like its mungtigi, raw beef. The mungtigi is 20,000 won for 300 g and the soup is 9,000 won, so both are quite easy on the wallet.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/yeonje-minjeong-hanu-suyuk-gukbap-20260311/01.jpg&quot; alt=&quot;Red raw beef spread out on a black plate, surrounded by side dishes&quot; /&gt;&lt;/p&gt;
&lt;p&gt;If you like mungtigi, I would recommend it. The meat was fresh.&lt;/p&gt;
&lt;p&gt;The Korean beef soup comes in an earthenware pot. The soup was good too.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/yeonje-minjeong-hanu-suyuk-gukbap-20260311/02.jpg&quot; alt=&quot;Korean beef soup with red broth in an earthenware pot, with radish kimchi&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As for why the mungtigi is so fresh, my guess is that many guests mean the meat turns over quickly.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Yeonsan</category><category>beef</category><category>gukbap</category></item><item><title>Aged kimchi chicken stew at Chiaksan Matjip, Wonju: great kimchi</title><link>https://jaemyeong.com/en/blog/wonju-chiaksan-matjip-20260308/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/wonju-chiaksan-matjip-20260308/</guid><description>On the way home from Yongpyong to Busan, I stopped in Wonju and had the small aged kimchi chicken stew with a friend. The kimchi was especially good.</description><pubDate>Sat, 26 Sep 2026 22:00:00 GMT</pubDate><content:encoded>&lt;p&gt;On the way home to Busan from the Yongpyong ski resort in Daegwallyeong, I stopped in Wonju. At about 6 on a Sunday evening I had dinner with a friend who used to live in Wonju.&lt;/p&gt;
&lt;p&gt;Chiaksan Matjip Main Branch is a chicken stew restaurant in Dangye-dong, Wonju. The map description puts it across from the Dangye-dong Nonghyup. A large menu board hangs on the inside wall, with braised dishes, stews, hot pots, and stir-fries, plus meals such as hand-pulled dough soup, fried rice, and noodle soup. There was a lot to choose from.&lt;/p&gt;
&lt;p&gt;I ordered the aged kimchi chicken stew, listed as the signature dish on the map, in the small size.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/wonju-chiaksan-matjip-20260308/01.jpg&quot; alt=&quot;Chicken stew with aged kimchi in red sauce, cooked in a wide pot&quot; /&gt;&lt;/p&gt;
&lt;p&gt;It was good. The aged kimchi was especially good. I had to drive, so I did not drink, but it was the kind of dish I would have wanted a drink with.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Wonju</category><category>chicken</category><category>stew</category></item><item><title>Seafood stew at Beolttejip in Yeonsan-dong: generous and fairly priced</title><link>https://jaemyeong.com/en/blog/yeonje-beolttejip-20260311/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/yeonje-beolttejip-20260311/</guid><description>Dinner with a friend at a seafood stew place near Yeonsan Station. The pot was full of seafood, and we had raw squid too. Good value for what we paid.</description><pubDate>Sat, 26 Sep 2026 14:50:00 GMT</pubDate><content:encoded>&lt;p&gt;I met a close younger friend who lives in Haeundae in Yeonsan-dong at about 6 on a Wednesday evening. Beolttejip is a seafood stew restaurant in an alley on the exit 4 side of Yeonsan Station.&lt;/p&gt;
&lt;p&gt;The seafood stew comes in small, medium, and large, and the small is 25,000 won. The pot sits on a portable burner on the table and you eat while it boils.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/yeonje-beolttejip-20260311/01.jpg&quot; alt=&quot;Seafood stew boiling in a red pot on a portable burner&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The pot held a generous amount of seafood, and the mix was good.&lt;/p&gt;
&lt;p&gt;We also ordered raw hanchi squid. It is a seasonal item, so my guess is that it may not be available out of season. The menu here changes often with what is in season.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/yeonje-beolttejip-20260311/02.jpg&quot; alt=&quot;Thinly sliced raw squid on a white plate, with a soju bottle beside it&quot; /&gt;&lt;/p&gt;
&lt;p&gt;For what we paid, I was very satisfied. Most of the seafood dishes and sashimi here are easy on the wallet and fairly fresh. I would suggest it to anyone looking for sashimi or seafood stew in Yeonsan-dong.&lt;/p&gt;
&lt;p&gt;After the meal we moved on to &lt;a href=&quot;/en/posts/yeonje-minjeong-hanu-suyuk-gukbap-20260311/&quot;&gt;Minjeong Hanu Suyuk Gukbap&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Yeonsan</category><category>seafood</category><category>stew</category></item><item><title>Joseph in Gwangalli: a happy night with Dongdong the dog</title><link>https://jaemyeong.com/en/blog/gwangalli-joseph-20260501/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-joseph-20260501/</guid><description>A bar in Gwangalli where a friend and I went at about 9 at night to see Dongdong, a dog who sometimes visits. The owner called the dog over for us.</description><pubDate>Sat, 26 Sep 2026 14:20:00 GMT</pubDate><content:encoded>&lt;p&gt;At about 9 on a Friday night, a friend and I went to Joseph as the last stop of the day. It is the cocktail and whisky bar in Gwangalli that &lt;a href=&quot;/en/posts/gwangalli-joseph-20260421/&quot;&gt;I also visited on April 21&lt;/a&gt;. It is behind Pascucci, in front of the Haman parking lot.&lt;/p&gt;
&lt;p&gt;This time the reason was a dog. A dog named Dongdong sometimes comes to this bar. My friend is not used to drinking but likes animals, so I wanted my friend to meet Dongdong.&lt;/p&gt;
&lt;p&gt;When we arrived, Dongdong was not there. I said that was a pity, and the owner called Dongdong over.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-joseph-20260501/01.jpg&quot; alt=&quot;A brown dog sitting on the next seat at the bar with its head raised, and a hand petting it&quot; /&gt;&lt;/p&gt;
&lt;p&gt;My friend liked Dongdong too. The owner sat the dog on the seat beside us, and the sight of it sitting there made me laugh. The dog was heavier than I expected and hard to set down on the floor.&lt;/p&gt;
&lt;p&gt;We drank while playing with Dongdong and ended the day in a good mood. I went again in July and met Dongdong once more.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>bar</category><category>dog</category></item><item><title>Sashimi at Paldak Paldak Hoetjip in Jeonpo: fresh, thick slices</title><link>https://jaemyeong.com/en/blog/jeonpo-paldak-paldak-hoetjip-20260328/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/jeonpo-paldak-paldak-hoetjip-20260328/</guid><description>A raw fish place in Jeonpo-dong I often go to. The assorted sashimi was fresh, and the price and portion were fair. A grilled flounder comes with it.</description><pubDate>Sat, 26 Sep 2026 14:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When I want sashimi in Jeonpo-dong, I often go to Paldak Paldak Hoetjip. At about 5 on a Saturday afternoon I went with friends, as the first stop of the gathering.&lt;/p&gt;
&lt;p&gt;If you come late, the line can be long and the fish can run out. So it is better to come early.&lt;/p&gt;
&lt;p&gt;We ordered the assorted sashimi. The menu says it is made up of flounder, rockfish, and mullet. The small size (for 2) starts at 43,000 won, and the largest is the extra large (for 5).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jeonpo-paldak-paldak-hoetjip-20260328/01.jpg&quot; alt=&quot;Thick slices of white fish laid out neatly on a wooden board&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The fish was fresh. This place tends to cut its sashimi fairly thick. The price and the portion felt fair.&lt;/p&gt;
&lt;p&gt;One grilled flounder comes along with it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jeonpo-paldak-paldak-hoetjip-20260328/02.jpg&quot; alt=&quot;A grilled flounder with seasoning on a wire rack&quot; /&gt;&lt;/p&gt;
&lt;p&gt;There was also rice for sushi at the back of the table. I vaguely remember that they sold yellowtail in winter.&lt;/p&gt;
&lt;p&gt;I would recommend it as a place for sashimi in Jeonpo-dong. After eating here we went to &lt;a href=&quot;/en/posts/jeonpo-ninano-20260328/&quot;&gt;Ninano Jeonpo&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Jeonpo</category><category>sashimi</category><category>raw fish</category></item><item><title>Pork cartilage ribs and udon at Rentan in Suyeong: a fair second stop</title><link>https://jaemyeong.com/en/blog/suyeong-rentan-20260324/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/suyeong-rentan-20260324/</guid><description>A second stop after Jagalchi Hoetjip, at a briquette grill bar. The seasoned pork cartilage ribs had a smoky taste and were fine. I had udon too.</description><pubDate>Sat, 26 Sep 2026 13:30:00 GMT</pubDate><content:encoded>&lt;p&gt;After eating at &lt;a href=&quot;/en/posts/suyeong-jagalchi-hoetjip-20260324/&quot;&gt;Jagalchi Hoetjip&lt;/a&gt;, I moved to Rentan Suyeong for a second round. It was about 8:20 on a Tuesday night. It is a bar that mainly serves food grilled over coal briquettes, and I have come here several times for a second round.&lt;/p&gt;
&lt;p&gt;I am told it is about a 3-minute walk from Suyeong Station.&lt;/p&gt;
&lt;p&gt;Here I usually order the signature cuttlefish. That day I chose the seasoned Korean pork cartilage ribs.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-rentan-20260324/01.jpg&quot; alt=&quot;Seasoned pork cartilage ribs on a plate, next to a bottle of soju&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The taste was fair. Because it is grilled over briquettes, it has a smoky flavor. The grilled dishes here are mostly satisfying.&lt;/p&gt;
&lt;p&gt;I also ordered the street-stall style udon. I finished the second round with those two dishes that day.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-rentan-20260324/02.jpg&quot; alt=&quot;A bowl of udon topped with crown daisy&quot; /&gt;&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Suyeong</category><category>grill</category><category>bar</category></item><item><title>Udon at the Mandeok oden truck: cheap oden, an old street-stall feel</title><link>https://jaemyeong.com/en/blog/mandeok-odeng-truck-20260309/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/mandeok-odeng-truck-20260309/</guid><description>After two stops in Suyeong, I stopped at the oden truck in Mandeok. I asked for udon with oden and rice cake in one bowl. The oden and rice cake were cheap.</description><pubDate>Sat, 26 Sep 2026 13:10:00 GMT</pubDate><content:encoded>&lt;p&gt;It was about 9 on a Monday night, and I was heading home after &lt;a href=&quot;/en/posts/suyeong-shanghai-tan-20260309/&quot;&gt;Shanghai Tan&lt;/a&gt; and &lt;a href=&quot;/en/posts/suyeong-haesam-meongge-malmijal-20260309/&quot;&gt;Haesam Meongge Malmijal&lt;/a&gt; in Suyeong. I wanted something to settle my stomach after drinking, so I stopped at the oden truck in Mandeok. I have used it many times.&lt;/p&gt;
&lt;p&gt;The truck has no name of its own and is not registered on the map. It parks in front of the 24-hour Jeonju Myeongga bean sprout soup restaurant on Mandeok 2-ro, so I marked that restaurant on the map. That restaurant is 225 m from exit 1 of Mandeok Station. &quot;Mandeok Oden Truck&quot; is simply what I call it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/mandeok-odeng-truck-20260309/03.jpg&quot; alt=&quot;Oden skewers, eggs, and paper cups on the counter of the truck&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I ordered udon and asked for oden and rice cake in the same bowl.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/mandeok-odeng-truck-20260309/01.jpg&quot; alt=&quot;Udon in a red bowl topped with a rice cake skewer and oden&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The oden and rice cake are cheap, so there is no burden. I also like that it still feels like an old street stall. My impression, though, is that it is not a place to expect much in the way of hygiene. There are tables where you can sit.&lt;/p&gt;
&lt;p&gt;I stop by when it comes to mind on my way home. I have a separate note from a visit in August as well.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Mandeok</category><category>oden</category><category>street food</category></item><item><title>Seasonal seafood platter at Haesam Meongge Malmijal in Suyeong: fresh</title><link>https://jaemyeong.com/en/blog/suyeong-haesam-meongge-malmijal-20260309/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/suyeong-haesam-meongge-malmijal-20260309/</guid><description>A second stop after Shanghai Tan, at a seasonal seafood bar. The seasonal platter was fresh, and the sea urchin in the dish I added had no fishy taste.</description><pubDate>Sat, 26 Sep 2026 12:40:00 GMT</pubDate><content:encoded>&lt;p&gt;After the meal at &lt;a href=&quot;/en/posts/suyeong-shanghai-tan-20260309/&quot;&gt;Shanghai Tan&lt;/a&gt;, I wanted seafood and looked for a place nearby. That is how we came to Haesam Meongge Malmijal for a second round. I was with a friend I have known since high school, and it was about 6:30 on a Monday evening.&lt;/p&gt;
&lt;p&gt;It is a bar in Suyeong that serves seasonal seafood, set inside an alley. The room was tidy, and I thought it would suit couples as well.&lt;/p&gt;
&lt;p&gt;We ordered the seasonal platter first.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-haesam-meongge-malmijal-20260309/01.jpg&quot; alt=&quot;A seasonal platter with many kinds of seafood packed onto a long plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The seafood was fresh and tasted good. Then we added the purple sea urchin, sweet shrimp, and avocado.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-haesam-meongge-malmijal-20260309/02.jpg&quot; alt=&quot;A plate with shrimp, avocado, and seaweed arranged around a small bowl of soy sauce&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The sea urchin had no fishy taste, and I enjoyed it. Both dishes are worth recommending, though the seasonal platter suited me better.&lt;/p&gt;
&lt;p&gt;On the way home that night I made one more stop, at &lt;a href=&quot;/en/posts/mandeok-odeng-truck-20260309/&quot;&gt;the Mandeok oden truck&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Suyeong</category><category>seafood</category><category>bar</category></item><item><title>Lamb skewers at Shanghai Tan in Suyeong: cheap and good, tricky fire</title><link>https://jaemyeong.com/en/blog/suyeong-shanghai-tan-20260309/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/suyeong-shanghai-tan-20260309/</guid><description>A lamb skewer place by Suyeong Station I have visited since it opened. Skewers, mala skewers, lamb ribs, and egg fried rice with a friend. Easy on the wallet.</description><pubDate>Sat, 26 Sep 2026 12:10:00 GMT</pubDate><content:encoded>&lt;p&gt;Shanghai Tan is a lamb skewer restaurant I have been to several times since its early days. At about 5 on a Monday afternoon, a friend and I went there as the first stop of the day. It is on the first floor of the building right to the left as you come out of exit 7 of Suyeong Station.&lt;/p&gt;
&lt;p&gt;If you come late, there is often a line. If you want a quiet meal, it is better to arrive early.&lt;/p&gt;
&lt;h2&gt;Skewers and ribs&lt;/h2&gt;
&lt;p&gt;We ordered lamb skewers and mala lamb skewers. The mala ones have seasoning on the outside, so you can tell them apart.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-shanghai-tan-20260309/01.jpg&quot; alt=&quot;Lamb skewers hanging in a row and cooking over charcoal&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The lamb skewers were very good. The lamb ribs are grilled on a wire grill.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-shanghai-tan-20260309/02.jpg&quot; alt=&quot;Lamb ribs cooking on a wire grill over charcoal&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The fire is hard to manage. Fat drips from the skewers and flames can flare up.&lt;/p&gt;
&lt;h2&gt;Egg fried rice&lt;/h2&gt;
&lt;p&gt;The egg fried rice was good too.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-shanghai-tan-20260309/03.jpg&quot; alt=&quot;Yellow egg fried rice in a black bowl&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I did not note the price of each dish, but the meal felt easy on the wallet. From here we moved on to &lt;a href=&quot;/en/posts/suyeong-haesam-meongge-malmijal-20260309/&quot;&gt;Haesam Meongge Malmijal&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Suyeong</category><category>lamb</category><category>skewers</category></item><item><title>Fresh pork makchang at Taeyoung in Dongnae: no off smell, nice chew</title><link>https://jaemyeong.com/en/blog/dongnae-taeyoung-saengmakchang-20260310/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/dongnae-taeyoung-saengmakchang-20260310/</guid><description>On a friend&apos;s day off we went to a makchang place in Dongnae. The fresh pork makchang had no off smell and a good chew, and the staff grilled all of it.</description><pubDate>Sat, 26 Sep 2026 11:40:00 GMT</pubDate><content:encoded>&lt;p&gt;A friend I have known since university had a day off, so we decided to eat together. At about 4:40 on a Tuesday afternoon we went to Taeyoung Saengmakchang in Dongnae. I think I found the place on social media, but I do not remember for sure.&lt;/p&gt;
&lt;p&gt;I like makchang, grilled pork intestine. My friend is not very fond of makchang or beef tripe. Looking back, I think I mostly went to beef tripe places in Dongnae.&lt;/p&gt;
&lt;p&gt;The restaurant is on the exit 4 side of Dongnae Station. I was told to face the main gate of Myeongnyun Xi and take the alley on the left. There were no other guests when we went.&lt;/p&gt;
&lt;p&gt;When I want makchang in Dongnae, this is the place I plan to choose. That day we ordered the fresh pork makchang, the signature dish. The staff did all of the grilling.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-taeyoung-saengmakchang-20260310/01.jpg&quot; alt=&quot;Makchang and rice cakes lined up on a square grill plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The makchang had no unpleasant smell and a nice, springy chew. We had soju with it.&lt;/p&gt;
&lt;p&gt;I do not know what it is like when the place is busy.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Dongnae</category><category>makchang</category><category>pork</category></item><item><title>Sambari in Dongnae: good grilled octopus and shrimp, not cheap</title><link>https://jaemyeong.com/en/blog/dongnae-sambari-20260604/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/dongnae-sambari-20260604/</guid><description>I have been here many times. The staff grill the octopus over charcoal and clean the shrimp for you. It is not cheap, but I was happy with the food again.</description><pubDate>Wed, 23 Sep 2026 18:18:00 GMT</pubDate><content:encoded>&lt;p&gt;The Sambari main branch is a place I had already visited several times. This time a friend and I went on a Thursday evening, a little before eight. It is 99 m from exit 4 of Dongnae Station, a seafood bar that also serves grilled clams.&lt;/p&gt;
&lt;p&gt;What I like here is the charcoal-grilled octopus and the shrimp. We had both that day.&lt;/p&gt;
&lt;h2&gt;They grill and clean it for you&lt;/h2&gt;
&lt;p&gt;The staff put the seafood on the charcoal one item at a time, grill it, and clean it for you.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-sambari-20260604/01.jpg&quot; alt=&quot;Octopus cooking on a charcoal wire grill next to a foil cup of corn&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The raw shrimp comes to the table already peeled.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-sambari-20260604/02.jpg&quot; alt=&quot;Peeled raw shrimp on a plate of ice with lemon slices&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The shrimp heads are served separately.&lt;/p&gt;
&lt;h2&gt;Price and crowd&lt;/h2&gt;
&lt;p&gt;The price range is not low. I was still happy with the taste this time, and I plan to go again. I remember standing in a long line here in the past, but these days there seemed to be somewhat fewer guests. That is only my impression.&lt;/p&gt;
&lt;p&gt;We finished with ramyeon.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-sambari-20260604/04.jpg&quot; alt=&quot;A pot of ramyeon topped with mussels and bean sprouts, with soju bottles behind it&quot; /&gt;&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Dongnae</category><category>seafood</category><category>bar</category></item><item><title>Grilled chicken platter at Isegye in Seomyeon after an hour in line</title><link>https://jaemyeong.com/en/blog/seomyeon-isegye-20260514/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/seomyeon-isegye-20260514/</guid><description>A friend and I had the chicken platter and a grilled sticky rice ball in Seomyeon. The staff grill everything. The long wait is the one drawback.</description><pubDate>Wed, 23 Sep 2026 15:59:00 GMT</pubDate><content:encoded>&lt;p&gt;On a Thursday evening I met a friend in Seomyeon for a drink, timed to when my friend got off work. We went to Isegye, a restaurant that specializes in chicken. It is about 120 m from exit 8 of Seomyeon Station.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/seomyeon-isegye-20260514/01.jpg&quot; alt=&quot;The wooden sidewalk sign of Isegye, with a rooster mark and the opening hours&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Many people wait for a table here. As I remember it, we waited about an hour that day.&lt;/p&gt;
&lt;p&gt;At the charcoal grill in front of the table, the staff explain every dish and grill it for you, so guests can simply eat.&lt;/p&gt;
&lt;h2&gt;The platter and the rice ball&lt;/h2&gt;
&lt;p&gt;We ordered the medium assorted platter.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/seomyeon-isegye-20260514/03.jpg&quot; alt=&quot;Grilled chicken pieces on a black plate with shishito peppers, cherry tomatoes, and garlic&quot; /&gt;&lt;/p&gt;
&lt;p&gt;We added the grilled sticky rice ball later.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/seomyeon-isegye-20260514/04.jpg&quot; alt=&quot;One round sticky rice ball grilled golden brown on the outside&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The chicken was good overall and tasted clean. I have been to a number of yakitori places in Seomyeon and Dongnae, and compared with those, this one felt easier on the wallet and tasted good too.&lt;/p&gt;
&lt;p&gt;I was happy with the taste and the price. The only thing that makes me hesitate is the long wait. On a day when I can accept that, I would like to go often.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Seomyeon</category><category>grilled chicken</category><category>bar</category></item><item><title>Squid and cuttlefish were the best part at Eobuui Sulsang, Songjeong</title><link>https://jaemyeong.com/en/blog/songjeong-eobuui-sulsang-20260510/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/songjeong-eobuui-sulsang-20260510/</guid><description>Two of us ordered the scallop and red snow crab set and the squid and cuttlefish plate. Both squid dishes were good. The crab had less meat than I hoped.</description><pubDate>Wed, 23 Sep 2026 15:41:00 GMT</pubDate><content:encoded>&lt;p&gt;I wanted bigfin reef squid, so a friend and I went to Songjeong. It was a Sunday evening, shortly after the place opened, and there were no other guests yet. The staff still seemed to be setting up. The sea is visible in front of the restaurant, and the view was good.&lt;/p&gt;
&lt;p&gt;We ordered plenty for two men. The set of Hokkaido scallops and steamed red snow crab was 89,000 won, and the bigfin reef squid with steamed cuttlefish was 80,000 won.&lt;/p&gt;
&lt;h2&gt;Scallops and red snow crab&lt;/h2&gt;
&lt;p&gt;You grill the scallops yourself on the table grill. They were fresh and tasted good.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/songjeong-eobuui-sulsang-20260510/03.jpg&quot; alt=&quot;Hokkaido scallops layered in a large shell-shaped dish&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The steamed red snow crab was fresh and tasty as well. For its size, though, there was not much meat to eat, and that was a letdown.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/songjeong-eobuui-sulsang-20260510/05.jpg&quot; alt=&quot;Three red snow crabs on a plate&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Bigfin reef squid and steamed cuttlefish&lt;/h2&gt;
&lt;p&gt;The bigfin reef squid was the reason for the trip. It was as good as I had hoped.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/songjeong-eobuui-sulsang-20260510/06.jpg&quot; alt=&quot;Thinly sliced bigfin reef squid topped with greens, with two bowls of sauce&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The steamed cuttlefish had an especially good chew. I was happy with both squid dishes.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/songjeong-eobuui-sulsang-20260510/07.jpg&quot; alt=&quot;A plate of steamed cuttlefish piled with water parsley&quot; /&gt;&lt;/p&gt;
&lt;p&gt;We did not order sea squirt, but it came on the house.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/songjeong-eobuui-sulsang-20260510/08.jpg&quot; alt=&quot;Cleaned sea squirt on a white plate&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Price and setting&lt;/h2&gt;
&lt;p&gt;The prices are on the high side. Still, the seasonal seafood is fresh and the sea is right in front of the restaurant, so I thought it would suit people looking for fresh seafood, or a couple who want to eat with a view. The menu changes with the season.&lt;/p&gt;
&lt;p&gt;There are outdoor tables, so I guessed that eating outside is possible when the weather is right. I plan to go once more for the bigfin reef squid.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Songjeong</category><category>seafood</category><category>scallop</category></item><item><title>RnT in Daegwallyeong, my burger stop: a single should be enough</title><link>https://jaemyeong.com/en/blog/pyeongchang-rnt-20260622/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/pyeongchang-rnt-20260622/</guid><description>A handmade burger shop I visit often in Daegwallyeong. I had my usual bacon burger set. The double is a big portion. A single would be enough for me.</description><pubDate>Wed, 23 Sep 2026 06:52:00 GMT</pubDate><content:encoded>&lt;p&gt;When I am in Daegwallyeong and want a burger, RnT is the place that comes to mind. Getting to a McDonald&apos;s means going all the way to Gangneung. I tend to stop by in ski season and whenever I come to Daegwallyeong, and this time I went on a Monday at about 12:40. The shop is right next to the Alpensia promotion hall. As far as I know it is already fairly well known. I think it is a good place and suggest it to people visiting Daegwallyeong.&lt;/p&gt;
&lt;p&gt;I ordered the bacon burger set, which is what I usually pick.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/pyeongchang-rnt-20260622/01.jpg&quot; alt=&quot;A bacon burger and fries on a plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The double burger is a generous portion. I think a single would be enough for me.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Pyeongchang</category><category>Daegwallyeong</category><category>burger</category></item><item><title>Boiled turban shell at Ara Saengseongui, a good second stop</title><link>https://jaemyeong.com/en/blog/haeundae-ara-saengseongui-20260618/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/haeundae-ara-saengseongui-20260618/</guid><description>A second round with a friend who lives in Haeundae, at a grilled fish place in Jwadong Market. The boiled turban shell tasted good and the price felt fair.</description><pubDate>Wed, 23 Sep 2026 06:21:00 GMT</pubDate><content:encoded>&lt;p&gt;A friend who lives in Haeundae and I wanted one more drink, so we went to Ara Saengseongui in Jwadong Market. It was about 8:50 on a Thursday night. I had been to this place a few times before. It is good for a light drink in Jwadong Market, and I plan to go again.&lt;/p&gt;
&lt;p&gt;Jwadong Market sits further in than Haeundae Market. In my view it has a number of places worth eating at. The restaurant is 368 m from exit 4 of Jangsan Station. There seemed to be quite a few local guests, though that is only my impression.&lt;/p&gt;
&lt;p&gt;The name says grilled fish, but there are also soups and boiled meat, so there is a lot to choose from. That night we ordered the boiled turban shell.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/haeundae-ara-saengseongui-20260618/01.jpg&quot; alt=&quot;Boiled turban shell on a long plate with greens, with bottles behind it&quot; /&gt;&lt;/p&gt;
&lt;p&gt;It tasted good and the price felt reasonable.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Haeundae</category><category>seafood</category><category>market</category></item><item><title>Rough-cut raw fish at a fair price at the Gwangalli Eobang Festival</title><link>https://jaemyeong.com/en/blog/gwangalli-eobang-chukje-20260614/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-eobang-chukje-20260614/</guid><description>My first time at the Gwangalli Eobang Festival, with friends. We ate raw fish from a beach food stall. The price was moderate and the slices were rough.</description><pubDate>Wed, 23 Sep 2026 05:57:00 GMT</pubDate><content:encoded>&lt;p&gt;I knew the Gwangalli Eobang Festival is held every year, but this was my first visit. I went with friends on a Sunday, at about half past two in the afternoon.&lt;/p&gt;
&lt;p&gt;Gwangalli Beach is 793 m from exit 3 of Gwangan Station. We passed exit 4 and walked toward the beach.&lt;/p&gt;
&lt;p&gt;The festival grounds had a number of hands-on booths, and there seemed to be plenty of space for children to play.&lt;/p&gt;
&lt;h2&gt;Raw fish from the beach stalls&lt;/h2&gt;
&lt;p&gt;We kept the meal simple and ate at the food stalls set up on the beach. We ordered only raw fish.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-eobang-chukje-20260614/02.jpg&quot; alt=&quot;A tray with sliced raw fish on a red plate, chili sauce packets, and green chilies&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The price was in the middle, neither cheap nor expensive. The fish was cut roughly. I thought it had been prepared ahead and kept cold, but that is a guess based on it being a festival stall and not a raw fish restaurant.&lt;/p&gt;
&lt;p&gt;We went during the day. I imagined that eating at the outdoor tables after sunset would have some charm.&lt;/p&gt;
&lt;h2&gt;Will I go next year&lt;/h2&gt;
&lt;p&gt;Going back for next year&apos;s festival feels like a hassle, so I do not plan to return.&lt;/p&gt;
&lt;p&gt;After the festival we moved to a grilled clam restaurant nearby. That story is in &lt;a href=&quot;/en/posts/gwangalli-jogaeview-20260614/&quot;&gt;my visit to Jogaeview Gwangan&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>Eobang Festival</category><category>raw fish</category></item><item><title>Eochon Hoetjip in Suyeong: good sashimi, awkward upstairs seats</title><link>https://jaemyeong.com/en/blog/suyeong-eochon-hoetjip-20260616/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/suyeong-eochon-hoetjip-20260616/</guid><description>A first visit to a raw fish place inside Suyeong Paldo Market. The small 40,000 won platter was good for the price. The low attic-like room was not comfortable.</description><pubDate>Wed, 23 Sep 2026 05:39:00 GMT</pubDate><content:encoded>&lt;p&gt;A friend I often eat out with and I went to Eochon Hoetjip, inside Suyeong Paldo Market, for the first time. It was about 6:40 on a Tuesday evening. I am not sure where I first heard of the place. I think I saw it on Instagram. It is 187 m from exit 3 of Suyeong Station.&lt;/p&gt;
&lt;p&gt;We were seated on the second floor. The room looks like a converted attic, and the ceiling is low, so it was hard to walk around standing straight. The seats were not comfortable either. There is no call button, which made ordering more from upstairs a hassle. A staff member from the first floor carried the food up.&lt;/p&gt;
&lt;p&gt;We ordered the small assorted sashimi for 40,000 won. Sushi, soft tofu, and salad come with it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-eochon-hoetjip-20260616/02.jpg&quot; alt=&quot;Assorted sashimi laid out on a round bamboo mat with lemon and parsley&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The fish was in good condition, and at this price it felt easy on the wallet. The two of us drank three bottles of soju. Near the end of the meal a grilled fish head came out.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/suyeong-eochon-hoetjip-20260616/04.jpg&quot; alt=&quot;A grilled fish head on a square patterned plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This place suits people who care more about the fish and the price than about a comfortable seat. I was happy with the meal and wanted to return, and about two months later I did go once more.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Suyeong</category><category>raw fish</category><category>market</category></item><item><title>Moving Time Capsule backups to SMB on macOS 27</title><link>https://jaemyeong.com/en/blog/macos-27-time-capsule-timecapsulesmb/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/macos-27-time-capsule-timecapsulesmb/</guid><description>macOS 27 dropped the AFP client and ended Time Machine backups to a 4th-generation Time Capsule. How TimeCapsuleSMB restored them over SMB3, and the risks.</description><pubDate>Sun, 13 Sep 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The Macs on my network used a 4th-generation AirPort Time Capsule as the destination for automatic Time Machine backups. After the upgrade to macOS 27.0, those backups stopped working. &lt;a href=&quot;https://support.apple.com/en-us/102423&quot;&gt;Apple&apos;s support article&lt;/a&gt; states that AFP-based Time Capsule backups are not supported on macOS 27 or later. As rechecked on October 4, 2026, the article is dated September 14, 2026.&lt;/p&gt;
&lt;p&gt;To keep using the device, I chose &lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB&quot;&gt;TimeCapsuleSMB&lt;/a&gt;. It is an unofficial tool that runs Samba 4 inside the device so that backups go over SMB3. On September 14, 2026, I confirmed that a first Time Machine backup finished from a Mac mini on macOS 27. Below, what I ran myself and what I only read in documentation are kept apart.&lt;/p&gt;
&lt;h2&gt;What went away is the AFP client&lt;/h2&gt;
&lt;p&gt;The stock server on a Time Capsule offers AFP and SMB1. The TimeCapsuleSMB README and &lt;a href=&quot;https://eclecticlight.co/2025/05/15/check-your-network-backups-and-shares-as-afp-is-being-removed/&quot;&gt;an article from The Eclectic Light Company&lt;/a&gt; describe it this way. In macOS 27.0 the AFP client is gone, and &lt;code&gt;mount_smbfs&lt;/code&gt; remains.&lt;/p&gt;
&lt;p&gt;There was advance notice. &lt;a href=&quot;https://support.apple.com/en-us/121011&quot;&gt;The enterprise release notes for macOS Sequoia 15.5&lt;/a&gt; announced that the AFP client would be removed in the future. The support article says backups to an AFP NAS are not recommended and are not supported on macOS 27 or later, and it applies the same condition to AirPort Extreme and Time Capsule. Earlier, &lt;a href=&quot;https://developer.apple.com/documentation/macos-release-notes/macos-15-release-notes&quot;&gt;the macOS Sequoia 15 release notes&lt;/a&gt; recorded a fix for a failure to create a new encrypted backup on a Time Capsule or AFP server.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.macrumors.com/2026/06/17/macos-27-golden-gate-kills-time-capsule-support/&quot;&gt;A MacRumors article&lt;/a&gt; explains the cause with TLS 1.2, but that is a different matter. The minimum TLS requirement in &lt;a href=&quot;https://developer.apple.com/documentation/macos-release-notes/macos-27-release-notes&quot;&gt;the macOS 27 Golden Gate release notes&lt;/a&gt; covers some processes related to device management, automated device enrollment, installing configuration profiles and apps, and software update. Time Machine is not among them. The same release notes have no item about removing AFP either. So the conclusion that AFP is gone rests on the support article and on the fact that the files are missing in macOS 27.0.&lt;/p&gt;
&lt;p&gt;This is the result of trying an AFP mount on macOS 27.0. I used an address that does not exist, and only the directory path is replaced with &lt;code&gt;&amp;lt;dir&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ mount -t afp //example.invalid/share &amp;lt;dir&amp;gt;
mount: exec /Library/Filesystems/afp.fs/Contents/Resources/mount_afp for &amp;lt;dir&amp;gt;: No such file or directory
mount: &amp;lt;dir&amp;gt; failed with 72
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command ends before it even resolves the name, because the helper file is missing. &lt;code&gt;/sbin/mount_afp&lt;/code&gt; does not exist, there is no AFP bundle in &lt;code&gt;/System/Library/Filesystems&lt;/code&gt;, and the network file system plug-ins have no AFP entry. On the other hand, the &lt;code&gt;setdestination&lt;/code&gt; section of &lt;code&gt;man tmutil&lt;/code&gt; still mentions AFP and SMB shares. Wording in a manual page is not evidence that AFP works.&lt;/p&gt;
&lt;p&gt;AirPort Utility changed as well. A fresh install of macOS 27 does not include it. After an upgrade the existing app can remain, but it is not guaranteed to work from 27 on. My Mac was updated from macOS 26.6.2, and AirPort Utility 6.3.9 was still there.&lt;/p&gt;
&lt;h2&gt;The available paths&lt;/h2&gt;
&lt;p&gt;The replacements Apple lists are an external storage device, a NAS that supports Time Machine, and a shared folder on another Mac.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A directly attached external drive: the configuration Apple commonly gives. A network outage does not matter, but a laptop backs up only while the drive is plugged in.&lt;/li&gt;
&lt;li&gt;A NAS with SMB: several Macs can back up to one place. The device costs money.&lt;/li&gt;
&lt;li&gt;A shared folder on another Mac: it uses a Mac that is already there. Apple recommends that both Macs run macOS 11 or later.&lt;/li&gt;
&lt;li&gt;Staying on macOS 26: AFP keeps working for a while. It is hard to keep going once security updates end.&lt;/li&gt;
&lt;li&gt;TimeCapsuleSMB: it keeps the device in use and leaves room to carry over old backups. The cost is accepting an unofficial tool running as root on the device.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I took the last path. I installed with the default settings first, then examined the binaries and the issues, and changed the settings and deployed again based on what I found. The security section below is what I learned after installing, but it belongs before installation, so I put it first.&lt;/p&gt;
&lt;h2&gt;What Time Machine needs from an SMB server&lt;/h2&gt;
&lt;p&gt;For Time Machine to use an SMB share as a backup destination, the server has to support Apple-specific features. In Samba, the &lt;a href=&quot;https://gitlab.com/samba-team/samba/-/blob/master/source3/modules/vfs_fruit.c&quot;&gt;vfs_fruit&lt;/a&gt; module does this.&lt;/p&gt;
&lt;p&gt;This share definition comes from Samba&apos;s &lt;a href=&quot;https://gitlab.com/samba-team/samba/-/blob/master/selftest/target/Samba3.pm&quot;&gt;public test configuration&lt;/a&gt;. Nothing in it is edited during installation. The excerpt only shows which options are needed.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[vfs_fruit_timemachine]
	vfs objects = fruit streams_xattr acl_xattr xattr_tdb
	fruit:time machine = yes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A share with &lt;code&gt;fruit:time machine = yes&lt;/code&gt; is advertised over Bonjour as &lt;code&gt;_adisk._tcp&lt;/code&gt;. The registration is in &lt;a href=&quot;https://gitlab.com/samba-team/samba/-/blob/master/source3/smbd/avahi_register.c&quot;&gt;Samba&apos;s Avahi registration code&lt;/a&gt;. &lt;code&gt;vfs_fruit&lt;/code&gt; negotiates the AAPL extension of SMB2 and handles Finder metadata. The extension needs SMB2 or later, so the SMB1 of the stock server cannot meet the condition.&lt;/p&gt;
&lt;h2&gt;What TimeCapsuleSMB does on the device&lt;/h2&gt;
&lt;p&gt;The v2.2.9 I installed runs Samba 4.24.3 and accepts connections over SMB3. A separate mDNS helper advertises &lt;code&gt;_smb._tcp&lt;/code&gt; and &lt;code&gt;_adisk._tcp&lt;/code&gt;. By default only SMB2 and SMB3 are allowed, and turning on &lt;code&gt;Allow Any SMB Protocol&lt;/code&gt; lifts that limit.&lt;/p&gt;
&lt;p&gt;The installed files are split across two storage areas. &lt;code&gt;/mnt/Flash&lt;/code&gt; is small but survives a reboot, so the boot script, the configuration, and the small mDNS helper go there. The large Samba binaries live in &lt;code&gt;.samba4&lt;/code&gt; on the hard disk. Every time the device starts, they are copied to the &lt;code&gt;/mnt/Memory&lt;/code&gt; RAM disk and run from there. The Apple firmware spins down an idle hard disk, so they cannot run directly from the disk.&lt;/p&gt;
&lt;p&gt;During installation, the app first turns on SSH on the device and sends the files through it. Data that was already on the disk is not touched. The uninstall feature removes the files on the hard disk and the loader in &lt;code&gt;/mnt/Flash&lt;/code&gt;. Deleting the installed files and restoring the boot hook are two different jobs, which is worth knowing. The command that returns the firmware to stock is &lt;code&gt;flash --restore&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The binaries differ by generation. The 5th generation is NetBSD 6, and generations 1 to 4 are NetBSD 4. My 4th-generation device got the build for NetBSD 4.0 little-endian.&lt;/p&gt;
&lt;h2&gt;Security conditions to know before installing&lt;/h2&gt;
&lt;p&gt;The first thing to know about is the heartbeat. The &lt;code&gt;nbns-advertiser&lt;/code&gt; in v2.2.9 contains a heartbeat feature. It downloads &lt;code&gt;heartbeat4le&lt;/code&gt; and a signature file over HTTP, verifies them, and runs the result. The address found in the strings of the binary is &lt;code&gt;http://timecapsulesmb.jamesyc.com/downloads/bin/heartbeat4le&lt;/code&gt;. The source of this feature is not in the v2.2.9 tag.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/issues/299&quot;&gt;Issue 299&lt;/a&gt; reports that this feature runs every 12 hours with root privileges and sends device information. The maintainer answered that it is telemetry, that it can be turned off with a setting, and that the structure was changed in v3.0.0. On September 14, 2026, when I installed it, v3.0.0 had not been released. Looking at the release list again on October 4, 2026, v3.0.0 was published on September 15 (UTC), and the latest release is v3.2.0. I have not installed the v3 line, so the installation and telemetry details in this post are based on v2.2.9. Signature verification keeps an arbitrary file from being run, but what gets run depends on the maintainer&apos;s server and signing key.&lt;/p&gt;
&lt;p&gt;I also checked how far the telemetry setting reaches. The app and the CLI write &lt;code&gt;TELEMETRY=false&lt;/code&gt; to &lt;code&gt;.bootstrap&lt;/code&gt; on the Mac, and that stops the usage events sent from the Mac. But in the deployed code and binary strings of v2.2.9, this value is not passed into the device&apos;s configuration. Which setting issue 299 refers to is not clear.&lt;/p&gt;
&lt;p&gt;The way to stop the heartbeat on the device is to turn NBNS off. &lt;code&gt;nbns-advertiser&lt;/code&gt; is always placed in &lt;code&gt;.samba4&lt;/code&gt; on the hard disk, but it is copied to the RAM disk and run only when &lt;code&gt;NBNS_ENABLED=1&lt;/code&gt;. In the app, turning off &lt;code&gt;Enable NBNS&lt;/code&gt; and running &lt;code&gt;Install/Update&lt;/code&gt; again deploys &lt;code&gt;NBNS_ENABLED=0&lt;/code&gt;. In the CLI it is &lt;code&gt;deploy --no-nbns&lt;/code&gt;. The basis for this conclusion is the boot code and the strings in the binary. I did not verify with packets that the network requests actually stopped.&lt;/p&gt;
&lt;p&gt;With NBNS off, NetBIOS name responses stop, so the device can drop out of discovery on Windows. Macs find it through Bonjour and do not strictly need NBNS, and my device worked normally after the redeploy.&lt;/p&gt;
&lt;p&gt;The remaining conditions are these.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Bind SMB to LAN Only&lt;/code&gt; is off by default. When it is off, the server can bind to every interface including WAN, and when it is on, it is limited to the LAN. If the device also acts as the router, turning it on is the safe choice.&lt;/li&gt;
&lt;li&gt;SMB authentication accepts any user name and uses the device password of the Time Capsule. Guests are blocked, and an authenticated user is mapped to root.&lt;/li&gt;
&lt;li&gt;The README says to use it only on the LAN and not to open ports to the internet. The FAQ says it can be used on a home network and should be avoided in security-sensitive environments.&lt;/li&gt;
&lt;li&gt;The first launch of the app needs the Gatekeeper warning to be allowed by hand. &lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/issues/284&quot;&gt;Issue 284&lt;/a&gt; includes a case where the maintainer provided an unpublished build through an external share link. Getting the app from an official release is the better choice.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The steps I followed with the app&lt;/h2&gt;
&lt;p&gt;I installed with the app, not the CLI. The steps were as follows.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Download the app from the GitHub releases. After unpacking and opening it, Gatekeeper blocks it, and it runs only after it is allowed by hand.&lt;/li&gt;
&lt;li&gt;Give the app the local network permission. It is under &quot;Privacy &amp;amp; Security&quot; in System Settings, and the app has to be quit and reopened after the permission is given.&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;Add Device&lt;/code&gt;, pick the device, enter the device password, and save it with &lt;code&gt;Save Device&lt;/code&gt;. The app then turns on SSH, and I waited for that to finish.&lt;/li&gt;
&lt;li&gt;Change three settings: Mac telemetry off, &lt;code&gt;Enable NBNS&lt;/code&gt; off, and &lt;code&gt;Bind SMB to LAN Only&lt;/code&gt; on.&lt;/li&gt;
&lt;li&gt;Press &lt;code&gt;Install/Update&lt;/code&gt;. The NBNS and LAN settings go to the device at this step.&lt;/li&gt;
&lt;li&gt;For generations 1 to 4, write the boot hook.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;Checkup&lt;/code&gt; after 5 to 10 minutes.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;On the 4th-generation device, the deploy took about 6 minutes. The checks that the device rebooted and that the runtime was activated succeeded, and Checkup showed 0 failures and 0 warnings.&lt;/p&gt;
&lt;p&gt;The README also describes what to do on failure. If the SSH step is stuck, restart the app and register the device again, or reboot the device itself. If the deploy step is stuck, delete the device entry saved in the app, register it again, and run the deploy once more. In some cases one deploy does not transfer every file, and it has to be repeated several times.&lt;/p&gt;
&lt;h3&gt;The boot hook on generations 1 to 4&lt;/h3&gt;
&lt;p&gt;The NetBSD 4 firmware on generations 1 to 4 cannot keep a boot hook. Without a patch, &lt;code&gt;tcapsule activate&lt;/code&gt; has to be run after every reboot. In the app, the &lt;code&gt;Persistent NetBSD4 Boot Hook&lt;/code&gt; menu runs &lt;code&gt;Back Up and Inspect&lt;/code&gt;, &lt;code&gt;Plan Patch&lt;/code&gt;, and &lt;code&gt;Write Patch&lt;/code&gt; in that order.&lt;/p&gt;
&lt;p&gt;The firmware on my device is 7.8.1. Both banks were backed up to the Mac, and an image built from 7.8.1 in Apple&apos;s catalog was written only to the primary bank. The secondary bank is still the original. The check that reads the bank back after writing and compares it also passed. The reboot command is not available in patch mode, so I unplugged the power cord and plugged it back in, and after that Samba started without &lt;code&gt;activate&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Writing to flash carries risk. &lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/blob/v2.2.9/FAQ.md&quot;&gt;The v2.2.9 FAQ&lt;/a&gt; warns that losing power during the write can leave the device unusable. &lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/blob/v2.2.9/README.md&quot;&gt;The v2.2.9 README&lt;/a&gt; says &lt;code&gt;tcapsule flash --restore&lt;/code&gt; returns the chosen bank to stock, but I did not test a restore. According to the README and the FAQ, the 5th generation needs no boot hook, starts Samba automatically after a reboot, and reboots once during the deploy. I did not test the 5th generation either.&lt;/p&gt;
&lt;h2&gt;Installing with the CLI&lt;/h2&gt;
&lt;p&gt;I did not run the CLI. The order in the README, with the option that turns NBNS off added, looks like this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;./tcapsule bootstrap
.venv/bin/tcapsule configure
.venv/bin/tcapsule deploy --no-nbns
.venv/bin/tcapsule doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;bootstrap&lt;/code&gt; creates a Python virtual environment inside the repository, and &lt;code&gt;configure&lt;/code&gt; writes the address and password to a configuration file. &lt;code&gt;deploy --no-nbns&lt;/code&gt; deploys with NBNS off, and &lt;code&gt;doctor&lt;/code&gt; checks the state. On generations 1 to 4, after &lt;code&gt;deploy&lt;/code&gt;, &lt;code&gt;flash&lt;/code&gt; makes a backup and &lt;code&gt;flash --patch&lt;/code&gt; writes the boot hook.&lt;/p&gt;
&lt;p&gt;The documented requirements are macOS 14 or later, Python 3.9 or later, Homebrew, and &lt;code&gt;smbclient&lt;/code&gt;. If the target is a NetBSD 4 device, &lt;code&gt;sshpass&lt;/code&gt; is needed as well.&lt;/p&gt;
&lt;h2&gt;Reusing existing backups&lt;/h2&gt;
&lt;p&gt;This section summarizes &lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/FAQ.md&quot;&gt;the FAQ on main&lt;/a&gt;. The baseline is commit 34c075f on September 14, 2026, and I did not move a backup myself. Installation does not delete the disk or an existing &lt;code&gt;.sparsebundle&lt;/code&gt;.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;First check that the SMB connection works.&lt;/li&gt;
&lt;li&gt;Put the &lt;code&gt;.sparsebundle&lt;/code&gt; at the root of the SMB share. With standard internal-disk settings, this is the &lt;code&gt;ShareRoot&lt;/code&gt; folder. A bundle inside a per-user folder is moved to the same place.&lt;/li&gt;
&lt;li&gt;Set the Time Machine destination to the SMB share again.&lt;/li&gt;
&lt;li&gt;When Time Machine offers to continue with the earlier backup, accept it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If the earlier bundle is not found or cannot be reused, Time Machine may create a new bundle. When macOS still cannot find the existing backup after the move, the remedy the FAQ gives is a reboot. With the backup not mounted, reboot the Time Capsule first and then the Mac.&lt;/p&gt;
&lt;h2&gt;What I verified&lt;/h2&gt;
&lt;p&gt;This is the environment I verified. The Mac used for installation runs macOS 27.0, and the Mac that backed up is a Mac mini on macOS 27. The device is a 4th-generation AirPort Time Capsule with firmware 7.8.1 and NetBSD 4.0. The app is 2.2.9, Samba is 4.24.3, and the date is September 14, 2026. On the Mac mini I set the SMB share as the backup destination, and the first Time Machine backup finished.&lt;/p&gt;
&lt;p&gt;A few commands help when checking the state. This one finds devices that advertise an SMB service over Bonjour.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dns-sd -B _smb._tcp local.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This one finds devices that advertise a Time Machine backup service. The two commands are run separately, and since the output keeps going, each is stopped with Control-C.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dns-sd -B _adisk._tcp local.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This one lists the backup destinations registered on the Mac.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tmutil destinationinfo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first command shows the SMB device name, and the second shows the device name of the backup disk. Seeing the advertisement does not mean that authentication and writing work. According to the documentation, &lt;code&gt;doctor&lt;/code&gt; checks the Samba processes, port 445, the Bonjour advertisement, the authenticated share list, and file operations inside the share.&lt;/p&gt;
&lt;h2&gt;What to watch while it stays in use&lt;/h2&gt;
&lt;p&gt;Keeping the app or the CLI folder after installation is the better choice. Running Checkup, deploying again, &lt;code&gt;activate&lt;/code&gt;, and uninstalling all need that tool. The FAQ explains that the CLI folder can be deleted, but that it has to be kept or downloaded again to run maintenance commands. To move to a new version, do not uninstall first. Deploy once more on top of the existing installation. After that, check that the NBNS setting, the LAN setting, and the telemetry structure are unchanged, and finish with Checkup. Generations 1 to 4 without the boot hook need &lt;code&gt;activate&lt;/code&gt; after a reboot.&lt;/p&gt;
&lt;p&gt;Changes on the OS side matter too. The FAQ records a regression of Time Machine network backups in macOS 26.4.x and 15.7.5 through 15.7.7. After an OS update, it is worth confirming that the first backup finishes.&lt;/p&gt;
&lt;p&gt;There are also cases in the issues. &lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/issues/294&quot;&gt;Issue 294&lt;/a&gt; is about SMB stalling during a backup. It reports that wired connections or a newer external access point were more stable than the built-in Wi-Fi, but the cause is not settled. In &lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/issues/271&quot;&gt;issue 271&lt;/a&gt;, the maintainer judged the cause to be instability in Samba and the network. &lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/issues/177&quot;&gt;Issue 177&lt;/a&gt; is a case where the device was factory reset during the first deploy. The data on the disk remains, but the network has to be set up again with AirPort Utility, and on macOS 27 that app is not guaranteed to work. For that reason, write down the device&apos;s settings before installing.&lt;/p&gt;
&lt;p&gt;I also read the main branch, which was still unreleased when I installed. In &lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/README.md&quot;&gt;commit 34c075f&lt;/a&gt; from September 14, 2026 and &lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/build/native/README.md&quot;&gt;the native helper README&lt;/a&gt;, the heartbeat is split out into a telemetry helper, which downloads and runs a signed debug executable when the server tells it to. This is code before release, and I did not confirm how it behaves, so the release notes and the FAQ should be read again before updating.&lt;/p&gt;
&lt;p&gt;This setup puts an unofficial project on a device that Apple no longer supports. Important backups are better kept in one more place, on an external drive or an SMB NAS.&lt;/p&gt;
&lt;p&gt;What I did not verify myself is the CLI installation, the restore to stock firmware, and the move of existing backups. I did not test a 5th-generation device, an AirPort Extreme, or an external disk attached to the device either. I did not test accessing files over the stock SMB1 on macOS 27 either. This post is about the Time Machine problem. I did not confirm with a packet capture that the heartbeat stopped either.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>macOS</category><category>Time Machine</category><category>SMB</category><category>Troubleshooting</category></item><item><title>A Guinness on the terrace at Issi in Haeundae, facing the sea</title><link>https://jaemyeong.com/en/blog/haeundae-issi-20260628/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/haeundae-issi-20260628/</guid><description>After barbecue, a friend and I moved to Issi, across from Haeundae Beach, for Guinness. The terrace is a good spot to look at the sea and talk.</description><pubDate>Tue, 08 Sep 2026 05:00:00 GMT</pubDate><content:encoded>&lt;p&gt;After the barbecue at &lt;a href=&quot;/en/posts/haeundae-butasinise-20260628/&quot;&gt;Butasinise Haeundae&lt;/a&gt;, a friend and I went to Issi for a second round. It was about 8:30 on a Sunday night.&lt;/p&gt;
&lt;p&gt;Issi is a Western-style restaurant on the first floor of Palais de Seas, right across from Haeundae Beach. From the subway it is a fair walk. There are terrace seats outside, and that day I saw that pets were allowed on the terrace.&lt;/p&gt;
&lt;p&gt;I mostly drink Guinness stout. I had one that day as well.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/haeundae-issi-20260628/01.jpg&quot; alt=&quot;A glass of Guinness stout on a table under red light&quot; /&gt;&lt;/p&gt;
&lt;p&gt;From the terrace you can see the sea. It is a good place to talk without hurry over a drink, and it suits a light beer, whisky, or wine. It also fits well into a walk along the beach.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Haeundae</category><category>beer</category><category>terrace</category></item><item><title>Striped jack sashimi at Dongnae Gimssi: good fish, slow to arrive</title><link>https://jaemyeong.com/en/blog/dongnae-dongnae-gimssi-20260704/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/dongnae-dongnae-gimssi-20260704/</guid><description>I went to an izakaya near Dongnae Station for striped jack. The 70,000 won sashimi was good fish, but it took long to arrive and came with little else.</description><pubDate>Mon, 07 Sep 2026 05:00:00 GMT</pubDate><content:encoded>&lt;p&gt;It was a Saturday and I wanted striped jack. I called a friend who was getting off work, and at about 6:45 in the evening we went to Dongnae Gimssi together. It is an izakaya near Dongnae Station.&lt;/p&gt;
&lt;p&gt;For striped jack I usually go to &lt;a href=&quot;/en/posts/jangjeon-kalmat-hoetjip-20260717/&quot;&gt;Kalmat Hoetjip&lt;/a&gt; near Pusan National University. But getting in there takes a long wait and it closes early, which can be a burden. On a day when that place is hard to get into, I think of this one as an alternative for striped jack, and I plan to go again.&lt;/p&gt;
&lt;p&gt;We ordered the seasonal striped jack sashimi as a single dish, not a set, for 70,000 won.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-dongnae-gimssi-20260704/01.jpg&quot; alt=&quot;Sashimi arranged in a circle on a glass plate with radish sprouts in the middle&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I was fairly happy with the quality of the fish. We did wait a long time between ordering and getting the plate, though. The only side dish was some peanuts, so there was almost nothing to eat with it. I tried to allow for it being an izakaya and not a raw fish restaurant, but so few side dishes was a letdown.&lt;/p&gt;
&lt;p&gt;The two of us drank two bottles of soju.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Dongnae</category><category>sashimi</category><category>izakaya</category></item><item><title>All-you-can-eat clams at Jogaeview Gwangalli: great view, weak meal</title><link>https://jaemyeong.com/en/blog/gwangalli-jogaeview-20260614/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-jogaeview-20260614/</guid><description>Three of us had the all-you-can-eat clam table with extra scallops. The clams were fresh, but the refills and the heat mean I will not go back.</description><pubDate>Sun, 06 Sep 2026 16:48:00 GMT</pubDate><content:encoded>&lt;p&gt;After looking around &lt;a href=&quot;/en/posts/gwangalli-eobang-chukje-20260614/&quot;&gt;the Gwangalli Eobang Festival&lt;/a&gt;, my friends and I went for grilled clams. There were three of us, and it was around three on a Sunday afternoon, early for a meal.&lt;/p&gt;
&lt;p&gt;Because we were early, we got a seat that looks down on Gwangalli. The view was really good.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-jogaeview-20260614/01.jpg&quot; alt=&quot;Gwangalli Beach and the sea seen from a window seat&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The clam table and the scallops&lt;/h2&gt;
&lt;p&gt;We ordered the all-you-can-eat Sanhaejinmi table and added seasonal scallops.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-jogaeview-20260614/02.jpg&quot; alt=&quot;A table with two grills covered with clams, scallops, and cheese dishes&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The clams were fresh. Taken as a whole, though, the meal fell short of what I expected, for three reasons.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Scallops: I expected large scallops, but the ones that came were small. They looked like red scallops to me, which is my own guess.&lt;/li&gt;
&lt;li&gt;Refills: When we asked for more, some clams came uncleaned and the portion got smaller. That is what happened to us that day.&lt;/li&gt;
&lt;li&gt;Heat: The grill gave off so much heat that it was very hot while we ate.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I have no plan to go back to this restaurant. I have rarely been satisfied at raw fish or grilled clam places near Gwangalli, so I tend to avoid seafood in this area. In my experience, neighborhood raw fish restaurants were better in both price and taste. That is only a personal comparison.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>grilled clams</category><category>all-you-can-eat</category></item><item><title>Thin-cut squid delivered from Uosin in Mandeok: plenty, but pricey</title><link>https://jaemyeong.com/en/blog/mandeok-uosin-20260626/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/mandeok-uosin-20260626/</guid><description>I ordered delivery from a nearby live squid restaurant. It was plenty for one and well cut, but spending over 50,000 won on one meal was a burden.</description><pubDate>Sun, 06 Sep 2026 06:32:00 GMT</pubDate><content:encoded>&lt;p&gt;At about 5 on a Friday afternoon I wanted squid at home, so I ordered delivery. Uosin is a live squid restaurant close to my home, and I have eaten there with my family before. The restaurant is 240 m from exit 1 of Mandeok Station.&lt;/p&gt;
&lt;p&gt;I like squid, but prices have gone up a lot lately and I feel it. This meal also cost more than 50,000 won.&lt;/p&gt;
&lt;p&gt;The house style is to cut the squid as thin as thread. The menu says you can ask for a different thickness.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/mandeok-uosin-20260626/01.jpg&quot; alt=&quot;Raw squid cut into thin threads on a white plate, sprinkled with sesame seeds&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I had no real complaint about the knife work or the freshness. I do prefer wide, thin slices to thin threads, though. The portion was plenty for one person. I do not know whether it was one squid or two.&lt;/p&gt;
&lt;p&gt;I hear the squid sells out early every day, so my guess is that it comes in daily. If you live near Mandeok, it is worth considering, at the restaurant or by delivery. Next time I plan to go and eat there with my family.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Mandeok</category><category>squid</category><category>delivery</category></item><item><title>Dokdo Kkotsaeu in Dongnae: expensive, but very satisfying to eat</title><link>https://jaemyeong.com/en/blog/dongnae-dokdo-kkotsaeu-20260711/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/dongnae-dokdo-kkotsaeu-20260711/</guid><description>A second stop at a spot prawn specialist near Dongnae Station. The raw prawn, cleaned in front of you, was great. The portion was small for the price.</description><pubDate>Sun, 06 Sep 2026 05:00:00 GMT</pubDate><content:encoded>&lt;p&gt;After the first round at &lt;a href=&quot;/en/posts/dongnae-singsing-domae-hoetjip-20260711/&quot;&gt;Singsing Domae Hoetjip&lt;/a&gt;, I looked for the next place and chose Dokdo Kkotsaeu. I like spot prawn. It was about 10 on a Saturday night. The restaurant specializes in spot prawn and is 59 m to the left of exit 4 of Dongnae Station.&lt;/p&gt;
&lt;p&gt;Spot prawn comes in small, medium, and large, and the small starts at 70,000 won. They clean the prawns in front of you.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-dokdo-kkotsaeu-20260711/01.jpg&quot; alt=&quot;Peeled spot prawns laid side by side on a stone slab covered with ice&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Eaten raw, the prawn tasted good and had a good bite. The heads come back deep-fried, and they were crisp. A salt-grilled dish was served too.&lt;/p&gt;
&lt;p&gt;The price is a heavy burden, and the portion felt small for it. Still, eating it was so satisfying that I felt the price was worth bearing. I would eat this more often if I could afford to.&lt;/p&gt;
&lt;p&gt;Raw horned turban was available that day, so I added it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-dokdo-kkotsaeu-20260711/03.jpg&quot; alt=&quot;Sliced raw horned turban on a long plate, sprinkled with sesame seeds&quot; /&gt;&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Dongnae</category><category>shrimp</category><category>seafood</category></item><item><title>Butasinise in Haeundae: good pork, but I could do without the music</title><link>https://jaemyeong.com/en/blog/haeundae-butasinise-20260628/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/haeundae-butasinise-20260628/</guid><description>The Si set at a pork barbecue place in Haeundae: bone-in pork belly, neck, and skin. The meat was good and the staff were kind. The music was not to my taste.</description><pubDate>Sat, 05 Sep 2026 05:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Someone younger than me who lives in Haeundae suggested a drink, so at about 7 on a Sunday evening we went to Butasinise Haeundae. It is a pork barbecue restaurant 499 m from exit 7 of Haeundae Station.&lt;/p&gt;
&lt;p&gt;The room was tidy. I wondered whether it had opened recently, but I am not sure. A group having a company dinner was there when we went.&lt;/p&gt;
&lt;p&gt;We ordered the Si set, which comes with bone-in pork belly, pork neck, and pork skin. The person who took the order and grilled the meat was a staff member from abroad. The grilling looked a little unpracticed, but they were kind, so I did not mind.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/haeundae-butasinise-20260628/01.jpg&quot; alt=&quot;Pork, grilled vegetables, and a bowl of sauce on a square grill&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The meat had no off smell and tasted good.&lt;/p&gt;
&lt;p&gt;What I did not like was the music. Korean pop songs were playing, and I felt this place would be better quiet, without music. The noise from the company dinner did not bother me much. The music did.&lt;/p&gt;
&lt;p&gt;If you live nearby, it is worth one visit. I would go again too, but Haeundae is far from my home, so I do not know how often. There is also a note on &lt;a href=&quot;/en/posts/haeundae-issi-20260628/&quot;&gt;Issi&lt;/a&gt;, which I visited on the same day.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Haeundae</category><category>pork</category><category>barbecue</category></item><item><title>Marinated galbi at Silla Nongwon in Deokcheon: the kids liked it more</title><link>https://jaemyeong.com/en/blog/deokcheon-silla-nongwon-20260624/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/deokcheon-silla-nongwon-20260624/</guid><description>After a hospital visit, my family ate at a galbi place near Deokcheon Station. The kids said it beat their usual spot. I liked the price and taste too.</description><pubDate>Fri, 04 Sep 2026 05:00:00 GMT</pubDate><content:encoded>&lt;p&gt;We went for dinner after visiting a family member at Gupo Seongsim Hospital. It was about 7:20 on a Wednesday evening, and the family included two of my nieces and nephews.&lt;/p&gt;
&lt;p&gt;We usually go to a galbi place near home that the kids like. That day we chose a different restaurant we had not been to for a while. Trying a new place was part of the fun. Silla Nongwon is 116 m from exit 10 of Deokcheon Station, and I heard it has been around for 20 years. The restaurant is large, and there seemed to be many families.&lt;/p&gt;
&lt;p&gt;We ordered marinated galbi. The meat comes in a bowl, sitting in its marinade.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/deokcheon-silla-nongwon-20260624/02.jpg&quot; alt=&quot;A wide bowl of galbi sitting in marinade, next to lettuce for wraps&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/deokcheon-silla-nongwon-20260624/01.jpg&quot; alt=&quot;Marinated galbi cooking on a wire grill over charcoal&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Both kids enjoyed it and said it tasted better than their usual place. I also felt the price and the taste were good enough to recommend.&lt;/p&gt;
&lt;p&gt;I do not expect to be in this area often. If I do come back, though, I would eat here.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Deokcheon</category><category>galbi</category><category>family</category></item><item><title>Miso, an oden bar in Dongnae: an easy second stop when you are full</title><link>https://jaemyeong.com/en/blog/dongnae-miso-20260625/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/dongnae-miso-20260625/</guid><description>After filling up at the first place, we had a drink over oden and rice cake skewers. The staff are kind and the food is light, good for a second round.</description><pubDate>Thu, 03 Sep 2026 05:00:00 GMT</pubDate><content:encoded>&lt;p&gt;We had already filled up at the first place, so for the second round we picked a place where a little food is enough. That was Miso, an oden bar, at about 9:30 on a Thursday night. There is also a note on &lt;a href=&quot;/en/posts/dongnae-dyeonbyeorak-makchang-20260625/&quot;&gt;Dyeonbyeorak Makchang&lt;/a&gt;, which I visited on the same day.&lt;/p&gt;
&lt;p&gt;It is 105 m from exit 4 of Dongnae Station, across from the Dongnae public parking lot. There are two kinds of seats: ordinary tables, and tables built around the oden bar. We usually sit at the oden bar. The staff are very kind, and takeout is possible.&lt;/p&gt;
&lt;p&gt;The oden bar is shared. You pick the oden you want yourself, and the rice cake skewer is ordered separately. Each person must order at least four oden.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-miso-20260625/01.jpg&quot; alt=&quot;The broth trays of an oden bar with skewers in each section, and a bottle of soju&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I mostly eat the basic square oden, and I have one rice cake skewer every time I go. Sometimes I add grilled toothfish, but not that night. It goes well with a bottle or two of soju.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Dongnae</category><category>oden</category><category>bar</category></item><item><title>Small rock bream at Singsing Domae Hoetjip in Dongnae: fresh and fair</title><link>https://jaemyeong.com/en/blog/dongnae-singsing-domae-hoetjip-20260711/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/dongnae-singsing-domae-hoetjip-20260711/</guid><description>A raw fish place in front of Dongnae Station I go to for sashimi at an easy price. I had the small rock bream for 65,000 won with a bottle of soju.</description><pubDate>Wed, 02 Sep 2026 06:29:00 GMT</pubDate><content:encoded>&lt;p&gt;When I want sashimi at a price that does not hurt, I go to Singsing Domae Hoetjip. It is right in front of exit 4 of Dongnae Station, 22 m away. This time I went at about 8 on a Saturday evening. It suits a light first round of sashimi, so I plan to keep going.&lt;/p&gt;
&lt;p&gt;It is a busy place. It is usually close to full, and sometimes there is a wait. They take reservations and do takeout and delivery.&lt;/p&gt;
&lt;p&gt;I eat many kinds of sashimi, and rock bream is one of the fish I like most. That day I ordered the small rock bream for 65,000 won, and had one bottle of soju.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-singsing-domae-hoetjip-20260711/02.jpg&quot; alt=&quot;Thin slices of white fish laid out neatly on a wide plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The fish was fresh and the price was not a burden. My guess is that the low price brings many guests, the fish turns over quickly, and that keeps it fresh.&lt;/p&gt;
&lt;p&gt;There is also a note on &lt;a href=&quot;/en/posts/dongnae-dokdo-kkotsaeu-20260711/&quot;&gt;Dokdo Kkotsaeu&lt;/a&gt;, which I visited on the same day.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Dongnae</category><category>sashimi</category><category>raw fish</category></item><item><title>Dyeonbyeorak Makchang in Dongnae: clean-tasting and chewy</title><link>https://jaemyeong.com/en/blog/dongnae-dyeonbyeorak-makchang-20260625/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/dongnae-dyeonbyeorak-makchang-20260625/</guid><description>A friend and I had the plain and seasoned makchang set near Dongnae Station, then added fried rice. The makchang had no off smell, and the room was tidy.</description><pubDate>Wed, 02 Sep 2026 04:33:00 GMT</pubDate><content:encoded>&lt;p&gt;I like makchang, grilled pork intestine, and have been to quite a few places that serve it. This was a Thursday. A friend got off work, we met at about 7:30 in the evening, and we had drinks at Dyeonbyeorak Makchang.&lt;/p&gt;
&lt;p&gt;The restaurant is 124 m from exit 4 of Dongnae Station. Cross the road and go a short way to the left. The spot used to be Orihyang, which served duck and monkfish. That place closed and the makchang restaurant moved in. Perhaps because it opened not long ago, the room was tidy.&lt;/p&gt;
&lt;p&gt;We ordered the set for two to three people with both plain and seasoned makchang, for 39,000 won.&lt;/p&gt;
&lt;h2&gt;Plain and seasoned makchang&lt;/h2&gt;
&lt;p&gt;The makchang comes already grilled once. I did not notice any off smell, and it was mild and chewy, which I liked.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-dyeonbyeorak-makchang-20260625/01.jpg&quot; alt=&quot;Golden-brown makchang on a wire grill&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The seasoned makchang is part of the same set.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-dyeonbyeorak-makchang-20260625/02.jpg&quot; alt=&quot;Red seasoned makchang with chives in a paper-lined dish&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Fried rice and soju&lt;/h2&gt;
&lt;p&gt;We added fried rice. It tasted the way I expected.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/dongnae-dyeonbyeorak-makchang-20260625/03.jpg&quot; alt=&quot;Fried rice spread out on paper&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As I remember it, three bottles of soju went quickly. They also do takeout and delivery and take groups. It is a place I will think of on a day I want makchang.&lt;/p&gt;
&lt;p&gt;There is also a note on &lt;a href=&quot;/en/posts/dongnae-miso-20260625/&quot;&gt;Miso, an oden bar&lt;/a&gt;, which I visited on the same day.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Dongnae</category><category>makchang</category><category>bar</category></item><item><title>Fresh assorted seafood at Yongwon Gadeok Hoetjip in Gupo Market</title><link>https://jaemyeong.com/en/blog/gupo-yongwon-gadeok-hoetjip-20260630/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gupo-yongwon-gadeok-hoetjip-20260630/</guid><description>My mother and I had the small 40,000 won assorted seafood plate in Gupo Market. The seafood was fresh, and even the small size came with spoon worm.</description><pubDate>Wed, 02 Sep 2026 03:54:00 GMT</pubDate><content:encoded>&lt;p&gt;At about noon on a Tuesday, after visiting my father at Gupo Seongsim Hospital, my mother and I went to Gupo Market. We first meant to go to Sijang Kalguksu, but it was closed that day, so we looked for another place nearby.&lt;/p&gt;
&lt;p&gt;My mother and I both like seafood and sometimes go out for it together. The last place we went was Gijang Haenyeochon, and we ate well there. But it closes early and stops opening in the summer heat. So that day I really wanted seafood.&lt;/p&gt;
&lt;p&gt;Gupo Market is fairly close to home, yet I rarely go. My mother goes often and knows the restaurants well. Yongwon Gadeok Hoetjip is inside the market, 250 m from exit 1 of Deokcheon Station.&lt;/p&gt;
&lt;p&gt;We ordered the small assorted seafood for 40,000 won.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gupo-yongwon-gadeok-hoetjip-20260630/02.jpg&quot; alt=&quot;A round plate of assorted seafood arranged on ice&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The seafood was fresh, and at this price it felt easy on the wallet. The side dishes that come with it were neat and tasty too.&lt;/p&gt;
&lt;p&gt;The spoon worm stood out for its nutty taste. As I remember it, Gijang Haenyeochon only served spoon worm from the large size up, which disappointed me. Here it was included even in the small size.&lt;/p&gt;
&lt;p&gt;I had to drive, so I could not have soju, and that was a pity. If I come to Gupo Market with a friend later, I think I will suggest this place.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gupo</category><category>seafood</category><category>market</category></item><item><title>The bottled cola at Starbucks Reserve: trying it once was enough</title><link>https://jaemyeong.com/en/blog/seomyeon-starbucks-jungangdaero-r-20260722/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/seomyeon-starbucks-jungangdaero-r-20260722/</guid><description>I bought the 4,700 won bottled cola at a Starbucks Reserve store because it comes with a bottle opener. Not bad, but the fizz was weak. Once was enough.</description><pubDate>Sun, 23 Aug 2026 05:47:00 GMT</pubDate><content:encoded>&lt;p&gt;I like cola. I had been curious about the drinks sold at Starbucks Reserve stores, and a note saying the cola comes with a bottle opener made me more curious. So on a Wednesday during the day I bought a bottle.&lt;/p&gt;
&lt;p&gt;The store is a Reserve store on the first floor of Samjeong Tower in Seomyeon. The drink is called The Real Premium Cola and costs 4,700 won. It is made by Baladin, an Italian brand, and a small bottle opener is attached to the bottle. To say it up front: with the price in mind I cannot call it a good pick, and I do not plan to buy it again.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/seomyeon-starbucks-jungangdaero-r-20260722/01.jpg&quot; alt=&quot;A glass bottle of cola with a name tag and a green bottle opener on its neck&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The fizz was on the weak side. The taste was not bad, but it was different from the cola I know, enough that I wondered whether to call it cola. My only guess is that natural ingredients are the reason.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Seomyeon</category><category>cafe</category><category>cola</category></item><item><title>Bigfin reef squid at Amaika in Seomyeon: I ordered a second dish</title><link>https://jaemyeong.com/en/blog/seomyeon-amaika-20260722/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/seomyeon-amaika-20260722/</guid><description>An izakaya in Seomyeon I had seen on YouTube. The squid was so good that I added the konowata ika somen. I wanted more but stopped because of the cost.</description><pubDate>Sun, 23 Aug 2026 05:21:00 GMT</pubDate><content:encoded>&lt;p&gt;After my dental treatment was finished, I wanted bigfin reef squid. Amaika is a place I had seen on YouTube and meant to visit for a while. I like the chef in the video and wanted to taste the food it showed. I walked in at about 5 on a Wednesday afternoon, right at opening.&lt;/p&gt;
&lt;p&gt;The izakaya is on the second floor of the building diagonally across from Gaemijip Seomyeon, on the exit 1 side of Seomyeon Station. There is a sign board in front of the building, which seemed to list the day&apos;s suggested dishes and the reservations.&lt;/p&gt;
&lt;p&gt;I first ordered one dish, the Amaika bigfin reef squid, for 42,000 won.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/seomyeon-amaika-20260722/01.jpg&quot; alt=&quot;Raw bigfin reef squid spread out like petals in a circle, with green seaweed&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The taste and the bite were both very good. Among the squid I have eaten, I rate this especially high. So I ordered one more squid dish, the konowata ika somen for 48,000 won. It is the same squid cut into thin strips, served with salted sea cucumber innards.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/seomyeon-amaika-20260722/02.jpg&quot; alt=&quot;A plate of thinly cut squid topped with chopped green onion, with a soju bottle behind&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I drank one bottle of Chamisul. I wanted to eat more, but the spending was a burden, so I stopped there.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Seomyeon</category><category>squid</category><category>izakaya</category></item><item><title>Uri Pocha in Gwangalli: a plate of only what we wanted to eat</title><link>https://jaemyeong.com/en/blog/gwangalli-uri-pocha-20260723/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-uri-pocha-20260723/</guid><description>A second stop with a friend. I picked small squid and shrimp, my friend picked fresh tuna, and it came as one plate. It was fresh and the staff were kind.</description><pubDate>Sun, 23 Aug 2026 04:39:00 GMT</pubDate><content:encoded>&lt;p&gt;After eating at &lt;a href=&quot;/en/posts/gwangalli-golmok-pocha-20260723/&quot;&gt;Golmok Pocha&lt;/a&gt;, a friend and I went to the Uri Pocha main branch for a second round. It was about 7:30 on a Thursday evening. The raw fish restaurant is behind Hanseo Hospital, up past the Gwangan Homeplus. It is 527 m from exit 14 of Suyeong Station.&lt;/p&gt;
&lt;p&gt;I like small squid and shrimp, and my friend likes fresh tuna. We each told the owner what we wanted and asked for it on one plate. The plate we got had fresh tuna, mackerel sashimi, raw shrimp, abalone, and small squid.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-uri-pocha-20260723/01.jpg&quot; alt=&quot;A divided plate with red tuna, shrimp, abalone, and other seafood&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Overall it was fresh and tasted good. The staff were kind as well.&lt;/p&gt;
&lt;p&gt;The place is known for yellowtail in winter. I have heard that people line up in yellowtail season. There were not many guests when we went, and I suspect that is because it was not the season.&lt;/p&gt;
&lt;p&gt;Next time I would like to come in winter for the yellowtail.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>sashimi</category><category>seafood</category></item><item><title>LA galbi at Golmok Pocha in Gwangalli: still good after a long gap</title><link>https://jaemyeong.com/en/blog/gwangalli-golmok-pocha-20260723/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gwangalli-golmok-pocha-20260723/</guid><description>I went back to a pub in Gwangan-dong that I used to visit often. The LA galbi, which the staff cut up at the table, tasted good again, and the service was kind.</description><pubDate>Sun, 23 Aug 2026 04:04:00 GMT</pubDate><content:encoded>&lt;p&gt;Golmok Pocha is a pub I went to several times in the past. I had not been for a while, and I came back at about 6 on a Thursday evening. This is the second branch, in Gwangan-dong, on the exit 1 side of Gwangan Station. There used to be a first branch, but it seems to be gone now.&lt;/p&gt;
&lt;p&gt;I remembered liking the LA galbi before, so I ordered it again. The basic side dishes come first and the galbi follows. A staff member brings the galbi to the table and cuts all of it up for you.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gwangalli-golmok-pocha-20260723/01.jpg&quot; alt=&quot;Grilled LA galbi with vegetables on a black iron plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The LA galbi was good this time too. I also ordered one bottle of soju. The staff were kind.&lt;/p&gt;
&lt;p&gt;There was a fish tank, and I saw seafood on the menu. I do not think I saw seafood dishes here before, so I wondered whether the menu had changed in the meantime.&lt;/p&gt;
&lt;p&gt;There is also a note on &lt;a href=&quot;/en/posts/gwangalli-uri-pocha-20260723/&quot;&gt;Uri Pocha&lt;/a&gt;, which I visited on the same day.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Gwangalli</category><category>galbi</category><category>bar</category></item><item><title>Tanti in Jeonggwan: a fine view, but weak coffee and strict rules</title><link>https://jaemyeong.com/en/blog/jeonggwan-tanti-20260729/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/jeonggwan-tanti-20260729/</guid><description>A large bakery cafe in Jeonggwan with my family. The room, the view, and the parking were good. The one-drink rule, the cold brew, and the sweet bread were not.</description><pubDate>Mon, 17 Aug 2026 17:16:00 GMT</pubDate><content:encoded>&lt;p&gt;After visiting the columbarium where my father rests, my family and I stopped at a cafe. It was about 5:30 on a Wednesday afternoon, and the children in my brother&apos;s family were with us. Tanti is a large cafe up on a mountain in Jeonggwan, Gijang. It is hard to reach without a car, but there was plenty of parking.&lt;/p&gt;
&lt;p&gt;The room is large and the view is good. My first impression was positive. For the price, though, the service and the food did not satisfy me, so I have no plan to go back. I prefer the cafes on the Gijang coast.&lt;/p&gt;
&lt;p&gt;I hear it stays open late at night, and my guess is that this is for the night view. It may suit couples on a date more than families.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jeonggwan-tanti-20260729/01.jpg&quot; alt=&quot;Mountains and a town seen beyond the water garden of a cafe terrace&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jeonggwan-tanti-20260729/03.jpg&quot; alt=&quot;A large interior with a white staircase, flower decorations, and rows of tables&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The ordering rules&lt;/h2&gt;
&lt;p&gt;That good first impression faded as we ordered. Each person has to order one drink, and the rule was applied to the children too. We were told that the children&apos;s drinks can only be ordered for kids of kindergarten age or younger. The second floor was closed to children and seniors. There are bungalows outside, and it seemed you need permission from the staff to use them.&lt;/p&gt;
&lt;p&gt;A notice at the counter said, in effect, that they would give more value than what you pay. From what I experienced, I could not agree.&lt;/p&gt;
&lt;h2&gt;Coffee and bread&lt;/h2&gt;
&lt;p&gt;I had stayed up the whole night before and needed caffeine, so I ordered a cold brew. It was very disappointing. It was the worst cold brew I have had. The coffee tasted thin overall, and I thought Compose Coffee would have been better.&lt;/p&gt;
&lt;p&gt;The drinks the children ordered were so sweet that most of them were left. The bread was far too sweet as well. I do not expect much from the taste at very large bakery cafes to begin with.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Jeonggwan</category><category>cafe</category><category>bakery</category></item><item><title>Assorted sashimi at Seonpung in Gangneung: one I was happy with</title><link>https://jaemyeong.com/en/blog/gangneung-seonpung-20260720/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/gangneung-seonpung-20260720/</guid><description>With a couple who live in Daegwallyeong, I went to Gangneung for sashimi. We found the place on Naver Map. I liked the mix of fish and the side dishes.</description><pubDate>Mon, 17 Aug 2026 16:49:00 GMT</pubDate><content:encoded>&lt;p&gt;I was visiting a couple, friends of mine, who live in Daegwallyeong. It feels hard to find a raw fish restaurant in Daegwallyeong, so the three of us went out to Gangneung for sashimi. It was about 8 on a Monday evening.&lt;/p&gt;
&lt;p&gt;Whenever I travel in Gangwon, I have been unhappy with seafood prices. So this time I searched Naver Map and picked a place where the price looked fair. That was Seonpung, in Gyo-dong, Gangneung.&lt;/p&gt;
&lt;p&gt;We ordered the assorted sashimi.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/gangneung-seonpung-20260720/01.jpg&quot; alt=&quot;Assorted sashimi lined up in rows on a square plate with lemon&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As I remember it, the plate had marumi, flounder, puffer, and pike conger. I am not certain. Compared with what I had eaten at raw fish places in Gangwon before, I was happy with the mix, and the side dishes were good too.&lt;/p&gt;
&lt;p&gt;My friend, who is pregnant, drove that day while the rest of us drank. I felt sorry about leaving the driving to her.&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Gangneung</category><category>sashimi</category><category>raw fish</category></item><item><title>Kalmat Hoetjip near PNU: twenty years of going back for the knife work</title><link>https://jaemyeong.com/en/blog/jangjeon-kalmat-hoetjip-20260717/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/jangjeon-kalmat-hoetjip-20260717/</guid><description>A raw fish place in Jangjeon-dong I have gone to for about 20 years. Premium sashimi for three and for two, plus a rockfish stew with a coupon.</description><pubDate>Mon, 17 Aug 2026 16:26:00 GMT</pubDate><content:encoded>&lt;p&gt;I have been going to Kalmat Hoetjip since I was a university student. By my count that is about 20 years. It is in Jangjeon-dong, close to Pusan National University Station. This time I went at about 3 on a Friday afternoon. I wrote about an earlier visit that April in &lt;a href=&quot;/en/posts/jangjeon-kalmat-hoetjip-20260411/&quot;&gt;my April visit note&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The restaurant opens at 4 in the afternoon. If you go in the evening, there is a good chance the fish has run out and you cannot eat. So it is best to arrive at opening time. Since the restaurant expanded, it can take more guests.&lt;/p&gt;
&lt;p&gt;I ordered the Kalmat premium sashimi for three (100,000 won) and for two (70,000 won).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jangjeon-kalmat-hoetjip-20260717/01.jpg&quot; alt=&quot;Sashimi from several kinds of fish layered on a black plate&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As I remember it, the fish that day were black-spotted jack mackerel, grunt, and red sea bream, but I am not certain. These days I like the jack mackerel so much that I could eat it daily. It is about as fatty as yellowtail, a fish I do not care for much.&lt;/p&gt;
&lt;p&gt;I think the knife work here is especially good, even next to other raw fish places in Busan. That is my personal view. The fish is in good condition for a price that does not hurt, and the side dishes are generous.&lt;/p&gt;
&lt;p&gt;As someone who lives in Busan, I do not think a raw fish restaurant has to be by the sea. The hours are hard to match, which is the drawback, but if you have the time I would recommend it strongly.&lt;/p&gt;
&lt;p&gt;I used saved coupons and got a spicy rockfish stew.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../../assets/posts/jangjeon-kalmat-hoetjip-20260717/04.jpg&quot; alt=&quot;A red spicy fish stew boiling in an earthenware pot&quot; /&gt;&lt;/p&gt;
</content:encoded><category>sikgi</category><category>Busan</category><category>Jangjeon</category><category>sashimi</category><category>raw fish</category></item><item><title>Rules that make an App Store review submission safe to rerun</title><link>https://jaemyeong.com/en/blog/app-store-connect-idempotent-submission/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/app-store-connect-idempotent-submission/</guid><description>If the server accepts a review request but the response is lost, a rerun is rejected as a duplicate. How DailySudoku decides what a rerun should do.</description><pubDate>Mon, 17 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;DailySudoku is an app that gives one Sudoku puzzle per day. I build it alone and ship it on the &lt;a href=&quot;https://apps.apple.com/app/id1149229748&quot;&gt;App Store&lt;/a&gt;, &lt;a href=&quot;https://play.google.com/store/apps/details?id=so.object.sudoku&quot;&gt;Google Play&lt;/a&gt;, and the &lt;a href=&quot;https://dailysudoku.app/ko/&quot;&gt;web&lt;/a&gt;. Pushing a tag runs everything from build upload to the iOS review request. When one channel fails, the design is to rerun only that channel.&lt;/p&gt;
&lt;p&gt;That design had a gap. If the server processes a request but the client ends without receiving the response, the rerun is rejected as a duplicate. The Android side fixed the same problem earlier, and this time I added an idempotency check to iOS. What follows is based on the iOS automation on the develop branch as of August 16, 2026.&lt;/p&gt;
&lt;h2&gt;The state names were different to begin with&lt;/h2&gt;
&lt;p&gt;To make a decision, the code has to read the current state of the version. I opened the &lt;a href=&quot;https://docs.fastlane.tools/advanced/Spaceship/&quot;&gt;spaceship&lt;/a&gt; implementation in the fastlane version pinned by &lt;code&gt;Gemfile.lock&lt;/code&gt; and found two accessors for the state.&lt;/p&gt;
&lt;p&gt;This is the accessor declaration in the spaceship app version model.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;attr_accessor :app_store_state      # Deprecated in App Store Connect API specification 3.3
attr_accessor :app_version_state    # ← 현행
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The comment on the second line, 현행, means &quot;current.&quot;&lt;/p&gt;
&lt;p&gt;The two accessors differ in more than the name. They return different values. &lt;code&gt;READY_FOR_SALE&lt;/code&gt; on the old one is &lt;code&gt;READY_FOR_DISTRIBUTION&lt;/code&gt; on the current one, and &lt;code&gt;PROCESSING_FOR_APP_STORE&lt;/code&gt; is &lt;code&gt;PROCESSING_FOR_DISTRIBUTION&lt;/code&gt;. The filter that spaceship uses to look up versions also uses the current field. To decide based on the &lt;a href=&quot;https://developer.apple.com/documentation/appstoreconnectapi/app_store_versions&quot;&gt;app version resource in the App Store Connect API&lt;/a&gt;, the code has to read the current accessor.&lt;/p&gt;
&lt;p&gt;The name has a trap too. In JSON the field is &lt;code&gt;appVersionState&lt;/code&gt;, but the accessor name after Ruby mapping is different. Accessing it with the camel-case name gives nil, and code that compares against nil passes the syntax check. The flow then falls into the &quot;unknown state&quot; branch without any error.&lt;/p&gt;
&lt;h2&gt;When the version state alone gives the wrong answer&lt;/h2&gt;
&lt;p&gt;At first I classified &lt;code&gt;READY_FOR_REVIEW&lt;/code&gt; as &quot;submission accepted.&quot; That was wrong. The same state remains when the run stops after preparing the version and creating the submission object, right before the last API call. If this state counts as success, the rerun ends without making the actual review request.&lt;/p&gt;
&lt;p&gt;The build needed its own check. Even with the same marketing version, the build number selected on the version can differ because of a manual submission or an earlier run. Whether the uploaded build exists and whether that build is attached to the version are two different conditions. Without separating them, the review state of a different binary is read as success for this build.&lt;/p&gt;
&lt;h2&gt;A second axis: the submission object for the whole app&lt;/h2&gt;
&lt;p&gt;A second investigation showed that the submission tool does not reject based on the version state. It checks whether the app as a whole has a &lt;a href=&quot;https://developer.apple.com/documentation/appstoreconnectapi/review_submissions&quot;&gt;review submission&lt;/a&gt; in progress.&lt;/p&gt;
&lt;p&gt;This is part of the branch that fastlane &lt;a href=&quot;https://docs.fastlane.tools/actions/deliver/&quot;&gt;deliver&lt;/a&gt; checks before submitting for review.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if app.get_in_progress_review_submission(platform:)
  UI.user_error!(&quot;Cannot submit for review - A review submission is already in progress&quot;)
  # …
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;READY_FOR_REVIEW&lt;/code&gt; state and an unfinished submission object can exist at the same time. So the version state alone cannot decide the outcome. The state of the submission object has to be read as well.&lt;/p&gt;
&lt;p&gt;I got this wrong once more. Reducing the lookup result to &quot;object exists or not&quot; erases the difference between waiting, in review, and unresolved issues. A rejected version could still be judged a success because the build and the notes match. I changed the code to keep the submission object&apos;s state as it is and to apply blocking conditions first.&lt;/p&gt;
&lt;p&gt;An enum value was also missing. Of the 15 states, I had classified only 14, and the missing one was the approved state. A rerun while waiting for manual release after approval could fail. Now a self-test walks through every enum value in the pinned gem and asserts that zero values are unclassified. If a gem update adds a new value, the verification step fails before any real submission.&lt;/p&gt;
&lt;h2&gt;A third defect in the submission note comparison&lt;/h2&gt;
&lt;p&gt;In the test that compares submission notes per locale, I had typed the keys by hand. As a result the test exercised only the comparison function. It did not check whether my locale keys match the keys the API returns. If the spelling differs, every locale is judged missing and the whole rerun is blocked.&lt;/p&gt;
&lt;p&gt;The error message was a problem too. The old message pointed to stale notes as the cause, which did not help when the real cause was a key mismatch. So I handle one case separately: every locale is missing, but the set of notes on the server is not empty. In that case the code prints the key list from the input and the key list from the server response.&lt;/p&gt;
&lt;h2&gt;Four decision rules&lt;/h2&gt;
&lt;p&gt;After three rounds of fixes, I reduced the rules to four.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Keep the version state and the app-wide submission state as independent axes.&lt;/li&gt;
&lt;li&gt;On an unknown value, fail closed and ask a person to decide.&lt;/li&gt;
&lt;li&gt;If either axis shows a blocking signal, blocking wins over submitting.&lt;/li&gt;
&lt;li&gt;A dry run only reports the state and does not stop because of that state.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The priority in rule 3 is fixed in the structure, not left to the order of conditional statements. Rule 4 exists because the state can change between the rehearsal and the real tag run.&lt;/p&gt;
&lt;h2&gt;How I verified it without calling App Store Connect&lt;/h2&gt;
&lt;p&gt;The decision logic is split into pure functions that run without credentials. That makes it possible to verify with a self-test and no call to the &lt;a href=&quot;https://developer.apple.com/documentation/appstoreconnectapi&quot;&gt;App Store Connect API&lt;/a&gt;. The checks cover a table per version state, a table per submission object state, and the classification of every enum value in the gem.&lt;/p&gt;
&lt;p&gt;There is also a guard against an implementation that ignores the second axis. Two contrast cases keep the version state the same and change only the submission object state. If they produce the same decision, the test fails. For the note comparison I used mutation. I replaced the comparison function with one that always returns false and one that always returns true, and confirmed that both the too-permissive defect and the too-strict defect are caught. I covered the idea of checking the checker with a control in &lt;a href=&quot;/en/posts/verification-script-negative-control/&quot;&gt;a separate post about negative controls in verification scripts&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Looking back, one round of fixes was avoidable. A document that investigated the second axis was already in the repository, and I started without reading it. I need the habit of rereading the notes I wrote months ago.&lt;/p&gt;
&lt;p&gt;Some things remain unverified. I have no record of reproducing the failure where the server finishes and only the response is lost. It was hard to build a way to reproduce it. What I verified is the decision logic that reads combinations of states, not the whole failure. The exhaustive enum check also covers only the values that the pinned gem knows. Whether the gem reflects the full, latest set of states on the server cannot be known until the gem is updated.&lt;/p&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>CI-CD</category><category>fastlane</category><category>Automation</category><category>App Store Connect</category></item><item><title>Fixing the order of the UMP explainer and the ATT request on iOS</title><link>https://jaemyeong.com/en/blog/att-ump-ordering-ios-consent/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/att-ump-ordering-ios-consent/</guid><description>DailySudoku requested ATT from the app, while Google UMP could also request it after an explainer. How I found the ATT calls in the SDK and reordered them.</description><pubDate>Mon, 17 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;DailySudoku is a personal project that gives one Sudoku puzzle every day. It ships on the &lt;a href=&quot;https://apps.apple.com/app/id1149229748&quot;&gt;App Store&lt;/a&gt;, &lt;a href=&quot;https://play.google.com/store/apps/details?id=so.object.sudoku&quot;&gt;Google Play&lt;/a&gt;, and the &lt;a href=&quot;https://dailysudoku.app/ko/&quot;&gt;web&lt;/a&gt;. To keep it free, I added banner ads and consent handling.&lt;/p&gt;
&lt;p&gt;Here are the abbreviations used in this post. IDFA (Identifier for Advertisers) is Apple&apos;s advertising identifier. ATT (App Tracking Transparency) is the Apple framework that asks for tracking permission, and UMP (User Messaging Platform) is the consent screen SDK from Google. GDPR (General Data Protection Regulation) is the EU privacy regulation, EEA stands for European Economic Area, and NPA (Non-Personalized Ads) means ads that are not personalized.&lt;/p&gt;
&lt;p&gt;The ATT system alert leaves little room to change its wording. So I wanted a separate screen that explains the reason before the request, and I drafted an IDFA explainer message in the AdMob console in 8 locales. The console said that this explainer screen triggers the ATT alert. If the SDK requests ATT by itself, then with the current structure, where the app already requests ATT, the explainer might never be shown. I stopped publishing and started investigating. This was about three and a half weeks after the earlier work.&lt;/p&gt;
&lt;h2&gt;The design from a month before&lt;/h2&gt;
&lt;p&gt;I had connected ATT and audited it about a month earlier. The reasoning at the time was this. The UMP screen is skipped in some regions, and if ATT sits inside the UMP completion callback, ATT is skipped there too. At the time, I expected the UMP screen in regions under GDPR, the United Kingdom, and some US states. Korea is the main market, so the app needed to obtain the IDFA outside the EEA as well.&lt;/p&gt;
&lt;p&gt;So the calling rule did not depend on the UMP screen. In the startup task of &lt;code&gt;AppCoordinator&lt;/code&gt;, the call sat after &lt;code&gt;refreshConsent()&lt;/code&gt; and before &lt;code&gt;maybeStartAdMob()&lt;/code&gt;, and it applied to every user who was not adFree. The actual ATT request ran in its own &lt;code&gt;Task&lt;/code&gt;, so it ran at the same time as UMP.&lt;/p&gt;
&lt;p&gt;Ad requests used the more restrictive of the two answers. If either ATT was denied or UMP consent was missing, the request carried &lt;code&gt;npa=1&lt;/code&gt;. Back then I checked the four combinations of ATT and UMP states with unit tests, and confirmed in the simulator that the ATT alert appears on first launch, including in a non-EEA locale.&lt;/p&gt;
&lt;h2&gt;Whichever shows first wins&lt;/h2&gt;
&lt;p&gt;The ATT request screen appears only when the status is &lt;code&gt;.notDetermined&lt;/code&gt;. A call after the user has answered just returns the existing status. So if the app&apos;s request shows first, the SDK&apos;s explainer has no place to appear. If the SDK moves first, the explainer appears and ATT follows. Which one came first depended on how long the network took. The reversed order, where ATT appears first and the explainer comes after it, could happen as well.&lt;/p&gt;
&lt;p&gt;The existing tests did not reveal this problem. I found it while preparing to publish the AdMob message.&lt;/p&gt;
&lt;h2&gt;The documents had no answer, so I opened the binary&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://developers.google.com/admob/ios/privacy&quot;&gt;Google guide to UMP privacy integration on iOS&lt;/a&gt; showed how to create the explainer message. At the time I could not find a basis for who calls ATT, how a sample uses it, or what the execution order is. The &lt;a href=&quot;https://developer.apple.com/documentation/apptrackingtransparency&quot;&gt;Apple AppTrackingTransparency documentation&lt;/a&gt; had no answer about UMP either. One line of guidance in the console was not enough to justify changing the code.&lt;/p&gt;
&lt;p&gt;So I looked at the &lt;code&gt;UserMessagingPlatform&lt;/code&gt; binary directly.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UserMessagingPlatform 바이너리:
  _objc_msgSend$requestTrackingAuthorizationWithCompletionHandler:
  _objc_msgSend$trackingAuthorizationStatus
  &quot;ATTrackingManager&quot;   ← 문자열(동적 조회)

otool -L: AppTrackingTransparency 하드 링크 없음
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean labels in the block read &quot;binary&quot; (바이너리), &quot;string, dynamic lookup&quot; (문자열, 동적 조회), and &quot;no hard link&quot; (하드 링크 없음).&lt;/p&gt;
&lt;p&gt;The binary contains the request selector and the status selector, and &lt;code&gt;ATTrackingManager&lt;/code&gt; appears as a string. The &lt;code&gt;otool -L&lt;/code&gt; output showed no direct link to AppTrackingTransparency. I read this as the SDK looking up the class dynamically. From that I inferred that the app has to link the framework, but I did not reproduce what happens when the link is removed.&lt;/p&gt;
&lt;h2&gt;Four decisions that changed the order&lt;/h2&gt;
&lt;p&gt;First, the app&apos;s ATT request moved to after UMP finishes. If the permission is already decided, the guard returns right away, so there is no duplicate request.&lt;/p&gt;
&lt;p&gt;Second, I kept the app&apos;s ATT call. I assumed that the SDK does not request ATT when the explainer message is not published, and in that case the app requests it instead. This reduces the dependence on whether the message is published before or after the code is merged. My judgment was that removing the app&apos;s call would leave no way to obtain the IDFA until publishing, and AdMob ads would be served as non-personalized.&lt;/p&gt;
&lt;p&gt;Third, UMP runs once even on the path where no consent screen is needed. I took &lt;code&gt;needsForm&lt;/code&gt; to mean only whether a consent screen is required. The point is to give the SDK a chance to handle the explainer outside the EEA too, and I expected it to return immediately when no message is published.&lt;/p&gt;
&lt;p&gt;This is the branch for the case where no consent screen is needed.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;case .none, .formOnly:
    guard AdsConfig.adMobEnabled else { break }
    // 폼이 «필요 없어도» UMP 흐름을 한 번 돌립니다.
    // 메시지가 게시돼 있지 않으면 즉시 반환합니다(no-op).
    umpFlowRan = await consentProvider.presentConsentForm()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two comments say that the UMP flow runs once even when no form is needed, and that it returns immediately as a no-op when no message is published.&lt;/p&gt;
&lt;p&gt;Fourth, &lt;code&gt;maybeStartAdMob()&lt;/code&gt; does not wait for the ATT answer. In the main market the ad chain uses AdFit, so there is nothing to gain from waiting for the first AdMob impression. I also considered that the banner can be covered while the ATT alert is up.&lt;/p&gt;
&lt;p&gt;This part schedules ATT only when the UMP flow actually ran.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if AdsConfig.adMobEnabled, umpFlowRan {
    Task { await ATTAuthorization.request() }
}
maybeStartAdMob()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Three fixes from code review&lt;/h2&gt;
&lt;p&gt;The first was the refresh failure. &lt;code&gt;.refreshFailed&lt;/code&gt;, which comes from a network error, fell into the &lt;code&gt;.none&lt;/code&gt; handling in an &lt;code&gt;if&lt;/code&gt;/&lt;code&gt;else if&lt;/code&gt; structure. If the cache still held &quot;no screen needed,&quot; UMP returned success and the app could request ATT without knowing whether the explainer was published. I switched to a &lt;code&gt;switch&lt;/code&gt; and handled the failure separately. A side benefit is that the compiler points out a missing branch when the enum gains a value later. In the excerpt below, the &lt;code&gt;.none&lt;/code&gt; and &lt;code&gt;.formOnly&lt;/code&gt; branch is the same as the code shown earlier, so it is shortened.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;switch prompt {
case .refreshFailed:
    // 게시 여부를 모르는 상태입니다. 아무것도 하지 않습니다.
    // 동의 상태는 UMP가 마지막으로 아는 값이 유지되고, 다음 런치에 재시도됩니다.
    break
case .introThenForm:
    umpFlowRan = await presentConsentIntro()
case .none, .formOnly:
    // …
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The comments under &lt;code&gt;.refreshFailed&lt;/code&gt; say that the publishing state is unknown so nothing is done, that the consent state keeps the last value UMP knows, and that it is retried on the next launch.&lt;/p&gt;
&lt;p&gt;The second was the app&apos;s own intro sheet. When the intro sheet shown before the SDK screen is dismissed with the scrim or the grabber, &lt;code&gt;presentConsentForm()&lt;/code&gt; is not called. &lt;code&gt;umpFlowRan&lt;/code&gt; checks whether UMP actually ran, and on this path ATT is skipped. The intro sheet is shown again each time the app starts.&lt;/p&gt;
&lt;p&gt;The third was the ad provider condition. A guard that looks at third-party ads as a whole passes when only another provider is enabled. ATT could run even in a configuration with AdMob turned off. The WKWebView-based provider does not use the IDFA. I narrowed the ATT and UMP conditions to &lt;code&gt;AdsConfig.adMobEnabled&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;What ATTAuthorization.request() does&lt;/h2&gt;
&lt;p&gt;The code that imports AppTrackingTransparency is gathered in one file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static func request() async {
    guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
    if UIApplication.shared.applicationState != .active {
        await withCheckedContinuation { continuation in
            var observer: NSObjectProtocol?
            observer = NotificationCenter.default.addObserver(
                forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
            ) { _ in
                if let observer { NotificationCenter.default.removeObserver(observer) }
                continuation.resume()
            }
        }
    }
    await withCheckedContinuation { continuation in
        ATTrackingManager.requestTrackingAuthorization { _ in continuation.resume() }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The guard at the top ends right away unless the status is &lt;code&gt;.notDetermined&lt;/code&gt;. Even though the function is called on every launch, it does not call the &lt;a href=&quot;https://developer.apple.com/documentation/apptrackingtransparency/attrackingmanager/requesttrackingauthorization(completionhandler:)&quot;&gt;request API&lt;/a&gt; for users who have already answered.&lt;/p&gt;
&lt;p&gt;Next comes the check that the app is &lt;code&gt;.active&lt;/code&gt;. Requesting directly from a startup callback such as &lt;code&gt;didFinishLaunching&lt;/code&gt; or &lt;code&gt;willConnectTo&lt;/code&gt; can return &lt;code&gt;.notDetermined&lt;/code&gt; without showing a screen. Usually &lt;code&gt;sceneDidBecomeActive&lt;/code&gt; has passed while UMP waits on the network, but the function waits for &lt;code&gt;didBecomeActiveNotification&lt;/code&gt; itself so that it does not rely on that timing.&lt;/p&gt;
&lt;h2&gt;What I confirmed and what I could not&lt;/h2&gt;
&lt;p&gt;Based on the develop branch as of 2026-08-16 (commits 2ae29c42 through 657d87c7), iOS passed 301 tests in 60 suites with &lt;code&gt;xcodebuild test&lt;/code&gt;, and Android succeeded at &lt;code&gt;compileDebugKotlin&lt;/code&gt;. On first launch in the simulator, the ATT alert appeared as before, and running UMP with no published message did not add another modal. The log on the following launch showed &lt;code&gt;[ATTrackingManager] Returning from trackingAuthorizationStatus - 0&lt;/code&gt;. The value 0 means not determined, so that path requests again.&lt;/p&gt;
&lt;p&gt;To imitate a regulated US state, I added the &lt;code&gt;-AdMobDebugUSState&lt;/code&gt; argument on iOS and &lt;code&gt;--ez admobDebugUsState true&lt;/code&gt; on Android. I confirmed in the SDK header that &lt;code&gt;UMPDebugGeographyRegulatedUSState&lt;/code&gt; has the value 3 and wired it in.&lt;/p&gt;
&lt;p&gt;Two things are unconfirmed. &lt;code&gt;simctl privacy grant tracking&lt;/code&gt; failed with &lt;code&gt;Operation not permitted&lt;/code&gt;, so there was no way to inject an ATT answer automatically. Whether the app stops asking after the user has answered is left for QA on a real device. I also could not see whether the explainer shows again on the second launch after the message is published, because the publishing conditions were not met at the time.&lt;/p&gt;
&lt;p&gt;There are remaining limits too. Until the explainer message is published, the app&apos;s ATT alert can appear without the explainer. That is the intended fallback behavior. What I saw in the binary is an observation limited to the UMP release in use at the time, and it is not behavior that the official Google documentation guarantees. It has to be investigated again when the SDK changes.&lt;/p&gt;
&lt;p&gt;One piece of documentation debt is left. The comments in &lt;code&gt;ATTAuthorization.swift&lt;/code&gt; still describe the old calling rule. The statement that it runs regardless of the screen, the statement that it runs right after &lt;code&gt;refreshConsent()&lt;/code&gt;, and the statement that ATT and UMP are independent no longer match the implementation. I corrected two comments in other files but missed this file, so I filed an issue for it. When a premise changes, an earlier decision has to be reviewed again. So a decision record should include the premises that make the conclusion hold, not only the conclusion.&lt;/p&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>Swift</category><category>Advertising</category><category>Privacy</category><category>ATT</category></item><item><title>Keeping the export compliance answer in sync across two files and ASC</title><link>https://jaemyeong.com/en/blog/export-compliance-two-source-validation/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/export-compliance-two-source-validation/</guid><description>The encryption export answer lives in Info.plist, a submission declaration file, and App Store Connect. Fixing a false success and a blocking regression.</description><pubDate>Mon, 17 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I ship DailySudoku, a personal app that gives one Sudoku game a day, on iOS, Android, and the web together. The release channels are the &lt;a href=&quot;https://apps.apple.com/app/id1149229748&quot;&gt;App Store&lt;/a&gt;, &lt;a href=&quot;https://play.google.com/store/apps/details?id=so.object.sudoku&quot;&gt;Google Play&lt;/a&gt;, and the &lt;a href=&quot;https://dailysudoku.app/ko/&quot;&gt;web&lt;/a&gt;. Pushing a git tag runs everything automatically up to the review request in App Store Connect (ASC).&lt;/p&gt;
&lt;p&gt;While &lt;a href=&quot;/en/posts/app-store-connect-idempotent-submission/&quot;&gt;making the review submission safe to rerun&lt;/a&gt;, I added a check for the encryption declaration. In the process I fixed defects of my own making four more times. The code described here is the store metadata check and the iOS release automation on the develop branch as of August 16, 2026.&lt;/p&gt;
&lt;h2&gt;Where the answer lives&lt;/h2&gt;
&lt;p&gt;Uploading an app requires &lt;a href=&quot;https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations&quot;&gt;declaring whether it uses encryption&lt;/a&gt;. In the repository, that value is in two places. One is the &lt;a href=&quot;https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption&quot;&gt;ITSAppUsesNonExemptEncryption&lt;/a&gt; key in &lt;code&gt;Info.plist&lt;/code&gt;. The other is the submission declaration file that the automation takes as input.&lt;/p&gt;
&lt;p&gt;If the two values disagree, ASC asks an extra question and the pipeline stops. The declaration file had the value false and a comment saying that both files must be changed together. No code actually checked what that comment said.&lt;/p&gt;
&lt;h2&gt;Having an answer is not the same as having the right one&lt;/h2&gt;
&lt;p&gt;At first I decided not to compare the values. If the version is waiting for a compliance answer, the flow moves to manual handling anyway, so I judged that a separate comparison was not needed.&lt;/p&gt;
&lt;p&gt;That judgment had a gap. Whether an answer exists and whether that answer equals the approved value are two different conditions. After a person enters a value in ASC, or after an earlier automated run enters a different value, the state still becomes resolved. If the build, the release notes, and the release method also match, a rerun can be judged a success. Then the review goes ahead with a declaration outside what was approved. A wrong declaration can become a reason for a review rejection or a policy violation.&lt;/p&gt;
&lt;p&gt;As the first fix, the actual value in ASC is compared with the approved value, and a missing answer blocks the run.&lt;/p&gt;
&lt;h2&gt;A regression found 30 minutes later&lt;/h2&gt;
&lt;p&gt;That fix caused another problem right away. The function that supplies the approved value uses Ruby symbols as keys, but the code that reads the value looked it up with a string key.&lt;/p&gt;
&lt;p&gt;This is the result of looking up the same value with the two kinds of key.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;문자열로 조회: nil     ← 옛 코드
심볼로 조회  : false   ← 고친 코드 (= 선언 파일의 승인값)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first line is the lookup with a string, which was the old code. The second is the lookup with a symbol, which is the fixed code and equals the approved value in the declaration file.&lt;/p&gt;
&lt;p&gt;Because the lookup returned nil and not false, the value was always judged different from the one in ASC. As a result, every retry was blocked.&lt;/p&gt;
&lt;p&gt;This was the third defect that blocked everything all the time. The earlier two were in the promotion comparison and the locale identifiers, and this one was the lookup of the declared value. The existing self-test checked only the number of keys and the types of the values. So I added an assertion that looks the value up with exactly the key the consuming code uses.&lt;/p&gt;
&lt;h2&gt;Looking once more for the same kind of defect&lt;/h2&gt;
&lt;p&gt;Before review, I searched for more blocking paths of the same kind, and a fourth candidate came up. The previous commit treated a nil value in ASC as a missing answer.&lt;/p&gt;
&lt;p&gt;But when compliance is settled by the declaration in the app binary&apos;s &lt;code&gt;Info.plist&lt;/code&gt;, the declaration field of the Build can be empty. This is an inference from how the submission tool is implemented. The submission tool, &lt;a href=&quot;https://docs.fastlane.tools/actions/deliver/&quot;&gt;fastlane deliver&lt;/a&gt;, writes the value only when it is nil. Shipped as it was, the code would have blocked valid retries too. This time it was found before release.&lt;/p&gt;
&lt;p&gt;After the change, the approved value is compared only when the ASC field has a value. A case that is really unresolved is stopped by the check on whether the version is waiting for an answer. The roles are separated so that a nil value and an unresolved state are not treated as the same thing.&lt;/p&gt;
&lt;h2&gt;An offline check that compares the two files&lt;/h2&gt;
&lt;p&gt;Apart from the runtime decision, I turned the contract inside the repository into a check. It compares &lt;code&gt;Info.plist&lt;/code&gt; with the submission declaration. A conflict is known as soon as only one file is edited, so it can be caught on every PR.&lt;/p&gt;
&lt;p&gt;This is the part that asserts the two values read from the files are equal.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plist_value = plist[key]                    # Info.plist
declared = decl[field]                      # 제출 선언 파일
assert plist_value == declared, (
    &quot;수출 규정 선언이 두 곳에서 어긋난다 — ASC가 되묻거나 잘못된 신고로 심사가 진행된다. &quot;
    &quot;**한쪽만 고치지 말 것**(선언 파일 주석).&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second comment means &quot;submission declaration file.&quot; The message says that the export compliance declaration disagrees in two places, that ASC will ask again or the review will go ahead with a wrong declaration, and that one side must not be fixed alone.&lt;/p&gt;
&lt;p&gt;The type is checked as well. When a value is extracted with &lt;code&gt;plutil&lt;/code&gt;, a false inside an XML string node also comes out as a string. Converting only the result of comparing that string with &quot;true&quot; into a Boolean hides the fact that the original type was wrong. So the file is parsed with Python&apos;s &lt;a href=&quot;https://docs.python.org/3/library/plistlib.html&quot;&gt;plistlib&lt;/a&gt;, and then the value is checked to be a bool.&lt;/p&gt;
&lt;p&gt;This assertion confirms that the value read from &lt;code&gt;Info.plist&lt;/code&gt; is a Boolean.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;assert isinstance(plist_value, bool), (
    f&quot;Info.plist 의 {key} 가 Boolean 이 아니다: {plist_value!r}. &quot;
    &quot;ASC 는 이 키를 Boolean 으로 요구한다 — &amp;lt;true/&amp;gt; 또는 &amp;lt;false/&amp;gt; 여야 한다&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The message says that the key in Info.plist is not a Boolean, and that ASC requires this key as a Boolean, written as &lt;code&gt;&amp;lt;true/&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;false/&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A missing key is a failure too, because without the key ASC asks again and the pipeline stops.&lt;/p&gt;
&lt;h2&gt;The check also ran in the wrong place&lt;/h2&gt;
&lt;p&gt;I first put this check in the self-test of the iOS release workflow. On a PR, that workflow runs only when a path under &lt;code&gt;apps/ios/**&lt;/code&gt; changes. The submission declaration file is outside that path, so a PR that changed only the declaration did not run the check.&lt;/p&gt;
&lt;p&gt;I moved it into the store metadata check, which owns both files. That check runs on every PR and push.&lt;/p&gt;
&lt;p&gt;I confirmed the result in three ways. A mutation that flips the Boolean in &lt;code&gt;Info.plist&lt;/code&gt; makes the assertion fail. The self-test looks the value up with the key that the consuming code really uses. And I matched every gem accessor and constant used in the code against the pinned source, one by one, because referring to names without checking them was the repeated cause. Feeding a check an input that should fail is covered in &lt;a href=&quot;/en/posts/verification-script-negative-control/&quot;&gt;the post on applying a negative control to a verification script&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A promise written as a comment is easy to mistake for a managed contract, so it should become an assertion. Searching for more failure paths of the same kind while fixing one defect paid off. And the change a check watches has to be included in the conditions that trigger that check. Adding a safeguard can itself create new errors, which is what happened here.&lt;/p&gt;
&lt;p&gt;I have never made a real submission with mismatched declarations. Testing the review queue with a wrong declaration was not an option. The statement that ASC asks again when the two values conflict or the key is missing rests on the experience recorded in the declaration file&apos;s comment and on the API&apos;s requirements. The basis for the nil handling is also the tool&apos;s implementation, and I could not confirm in the documentation the exact condition under which ASC fills the Build&apos;s declaration field.&lt;/p&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>CI-CD</category><category>App Store Connect</category><category>Automation</category><category>Testing</category></item><item><title>When parallel tests disturbed each other through global notifications</title><link>https://jaemyeong.com/en/blog/parallel-test-global-notification-isolation/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/parallel-test-global-notification-isolation/</guid><description>An ad banner test that passed locally in 0.02 seconds hit a 45-minute limit in CI. The cause was a global notification from another test, fixed by injection.</description><pubDate>Mon, 17 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;DailySudoku, a Sudoku app I build as a side project, gives one free game a day and shows a banner ad. It is available on the &lt;a href=&quot;https://apps.apple.com/app/id1149229748&quot;&gt;App Store&lt;/a&gt;, &lt;a href=&quot;https://play.google.com/store/apps/details?id=so.object.sudoku&quot;&gt;Google Play&lt;/a&gt;, and the &lt;a href=&quot;https://dailysudoku.app/ko/&quot;&gt;web&lt;/a&gt;. A failure showed up in the iOS tests for that banner ad.&lt;/p&gt;
&lt;p&gt;A test named &lt;code&gt;holdsImpressionUntilTheSlotIsLiveAgain&lt;/code&gt; passed locally in about 0.02 seconds, while the CI job ran into its 45-minute limit. After the first fix the endless wait was gone, but the test kept failing. The code baseline is the iOS implementation at commits &lt;code&gt;d44808bb&lt;/code&gt; and &lt;code&gt;b7646ee2&lt;/code&gt; on the develop branch as of August 14, 2026.&lt;/p&gt;
&lt;h2&gt;The notifications the banner listened to&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;AdBannerView&lt;/code&gt; has to react to a remove-ads purchase, a consent change, and a return to the foreground. So it observed three notifications on &lt;code&gt;NotificationCenter.default&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is the code where the banner registered its observers.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for name in [SudokuStore.didChange, AdsNotifications.didChange, UIApplication.didBecomeActiveNotification] {
    NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The last one, the did-become-active notification, is there for a reason. Something has to restart the load and the impression handling that were held back while the slot was inactive. When the user returns to a screen that is already showing, &lt;code&gt;viewWillAppear&lt;/code&gt; cannot be expected to be called. Without this trigger, both the ad slot and the impression record can stay empty.&lt;/p&gt;
&lt;h2&gt;At first I suspected load on the runner&lt;/h2&gt;
&lt;p&gt;Several jobs share the CI runner, so load was my first suspect. Then I found a different problem in the test code. Execution continued after &lt;code&gt;#expect&lt;/code&gt; failed, and it then entered a &lt;code&gt;while&lt;/code&gt; with no upper bound that waited for &lt;code&gt;shownKind&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Reduced to its shape, the flow looked like this. The arguments and the loop body are omitted.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#expect(await waitForChain(               // 실패해도 실행이 멈추지 않는다
    // …
))
while banner.shownKind == nil {           // 그래서 여기서 영원히 대기한다
    // …
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean comment on the first line says that execution does not stop even on failure, and the one on the &lt;code&gt;while&lt;/code&gt; line says that it therefore waits here forever.&lt;/p&gt;
&lt;p&gt;I measured the time too. Run alone, this test took 0.024 seconds locally. Under contention, there was a case where the asynchronous MainActor work that builds the chain was delayed beyond the 4-second observation window.&lt;/p&gt;
&lt;p&gt;I also ruled out other changes as the cause. &lt;code&gt;releaseForcedProvider&lt;/code&gt; in the PR that changed ad routing is always &lt;code&gt;nil&lt;/code&gt; in DEBUG. The PR under review has no change in the ad directory. Both were confirmed from the ancestry and the diff of the two PRs.&lt;/p&gt;
&lt;p&gt;Defensive changes went in first. The wait was raised from 4 seconds to 8 to match the provider&apos;s network time limit, and &lt;code&gt;waitForShown&lt;/code&gt; was built on the existing polling approach. &lt;a href=&quot;https://developer.apple.com/documentation/testing/require(_:_:sourcelocation:)&quot;&gt;#require&lt;/a&gt; stops progress after a failure, and the unbounded loop became a wait with a time limit. The result was that a 45-minute wait turned into an 8-second failure. The interference between tests was still there.&lt;/p&gt;
&lt;h2&gt;A notification from another test reset the banner&lt;/h2&gt;
&lt;p&gt;The hypothesis is the combination of &lt;a href=&quot;https://developer.apple.com/documentation/testing/parallelization&quot;&gt;parallel execution in Swift Testing&lt;/a&gt; and a notification center shared by the whole process. A static search found 7 test files that post &lt;code&gt;SudokuStore.didChange&lt;/code&gt;. A notification posted by an unrelated test synchronously calls &lt;code&gt;refresh()&lt;/code&gt; on the banners that are observing. When ads are disabled, the &lt;code&gt;!enabled&lt;/code&gt; branch clears &lt;code&gt;chain&lt;/code&gt;, &lt;code&gt;shownKind&lt;/code&gt;, and &lt;code&gt;pendingImpression&lt;/code&gt; and bumps the generation.&lt;/p&gt;
&lt;p&gt;Two conditions made only this test vulnerable.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The watcher uses &lt;code&gt;shownKind&lt;/code&gt; as input to decide the remove-ads state. A reset can happen once the value exists. A notification before the value exists only calls &lt;code&gt;setNeedsLayout()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Unlike the other tests, this one does not call &lt;code&gt;layoutIfNeeded()&lt;/code&gt; before its first wait. So after a reset there is no layout pass to restart the load.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I judged that this is the only test where both conditions hold together.&lt;/p&gt;
&lt;p&gt;The same hypothesis explained the difference between local runs and CI. When the test passed locally, the suites running alongside it presumably did not post a notification during the risky window. The observation that contention delays timing was valid in itself, but it did not fit as the cause of this failure.&lt;/p&gt;
&lt;p&gt;I confirmed the hypothesis with an experiment. Adding a &lt;code&gt;Task&lt;/code&gt; that posts the notification made the failure reproducible locally. The existing test, run in parallel, failed at the same place in the code.&lt;/p&gt;
&lt;h2&gt;Injecting the notification center&lt;/h2&gt;
&lt;p&gt;I added &lt;code&gt;notificationCenter: NotificationCenter = .default&lt;/code&gt; to the initializer of &lt;code&gt;AdBannerView&lt;/code&gt; and changed the observers to register on the injected center.&lt;/p&gt;
&lt;p&gt;This is part of the changed initializer. The setup code before the observer registration is omitted.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;init(
    colors: SudokuColors,
    adsProvider: AdsProvider,
    consentProvider: ConsentProvider,
    notificationCenter: NotificationCenter = .default   // 추가
) {
    // …
    for name in [SudokuStore.didChange, AdsNotifications.didChange, UIApplication.didBecomeActiveNotification] {
        notificationCenter.addObserver(self, selector: #selector(refresh), name: name, object: nil)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean comment next to the new parameter means &quot;added.&quot;&lt;/p&gt;
&lt;p&gt;Three test files each pass their own instance. Because of the default value, the app keeps using the same &lt;a href=&quot;https://developer.apple.com/documentation/foundation/notificationcenter&quot;&gt;NotificationCenter&lt;/a&gt; as before. &lt;code&gt;adsProvider&lt;/code&gt;, &lt;code&gt;consentProvider&lt;/code&gt;, &lt;code&gt;makeAd&lt;/code&gt;, and &lt;code&gt;logger&lt;/code&gt; were already injected, so this follows the same pattern. The reset behavior in &lt;code&gt;refresh()&lt;/code&gt; and &lt;code&gt;didMoveToWindow()&lt;/code&gt; means that the creative is removed, so I left it as it was.&lt;/p&gt;
&lt;p&gt;Other options were considered. &lt;code&gt;@Suite(.serialized)&lt;/code&gt; only limits concurrency inside that suite, so it has no effect on notifications posted by other suites. And when layout does not run again, raising 8 seconds to 80, or waiting forever, does not recover the test.&lt;/p&gt;
&lt;h2&gt;How the fix was confirmed&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;xcodebuild test&lt;/code&gt; reported 299 tests in 60 suites passing. One regression test was added, which took the count from 298 to 299. The problem test alone ran in 0.023 seconds, and the whole run took 8.58 seconds.&lt;/p&gt;
&lt;p&gt;I also checked that the new test catches the defect. With a mutation that puts the center back to &lt;code&gt;.default&lt;/code&gt;, both the new test and the existing test failed. Deliberately putting a failing condition into verification code is covered in &lt;a href=&quot;/en/posts/verification-script-negative-control/&quot;&gt;the post on applying a negative control to a verification script&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Four rules came out of this. A failure that happens only in CI is not assumed to be a performance problem. A hypothesis built from reading code is confirmed by reproduction. A regression test is checked by putting the defect back and seeing it fail. A time limit and a stop after failure have value apart from the isolation fix, so they stay.&lt;/p&gt;
&lt;p&gt;What I observed directly is the failure under an artificially created notification. I did not find which suite actually posted the notification in the CI run that failed. The search result of 7 files cannot settle who the sender was at that time. Whether the centers in the three test files are also independent per test case is not covered in this post.&lt;/p&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>Swift</category><category>Testing</category><category>CI-CD</category><category>Troubleshooting</category></item><item><title>How a canceled Task still ran to the end in a review prompt</title><link>https://jaemyeong.com/en/blog/task-sleep-cancellation-review-prompt-gate/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/task-sleep-cancellation-review-prompt-gate/</guid><description>A review prompt scheduled 2 seconds after a win could still run after the screen was left. A cancel after Task.sleep returns does not come back as an error.</description><pubDate>Mon, 17 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I added a feature to DailySudoku, a personal project that gives one Sudoku game a day, that asks for a store rating after a win. The store and web addresses are the &lt;a href=&quot;https://apps.apple.com/app/id1149229748&quot;&gt;App Store&lt;/a&gt;, &lt;a href=&quot;https://play.google.com/store/apps/details?id=so.object.sudoku&quot;&gt;Google Play&lt;/a&gt;, and the &lt;a href=&quot;https://dailysudoku.app/ko/&quot;&gt;web&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/appstore/requestreview(in:)-1q8qs&quot;&gt;AppStore.requestReview(in:)&lt;/a&gt; on iOS does not report whether the prompt actually appeared, and there is no completion callback. The system limits how often it shows, and the app also limits requests to one per version. If an attempt is recorded at a moment when the screen is not suitable for a request, the chance for that release is used up. Two code reviews each found one such path.&lt;/p&gt;
&lt;p&gt;An earlier version of this post gave the wrong name for Swift&apos;s cancellation error and described the behavior of &lt;code&gt;Task.sleep&lt;/code&gt; incorrectly. On October 4, 2026, I ran the cancellation behavior again in a standalone program on Swift 6.4 and corrected it. The app&apos;s tests and the real race were not rerun this time.&lt;/p&gt;
&lt;h2&gt;The flow that schedules a request&lt;/h2&gt;
&lt;p&gt;When a game ends in a win, &lt;code&gt;markPending()&lt;/code&gt; checks eligibility and only marks it pending. StoreKit is not called at that point. When the completion screen is shown, &lt;code&gt;screenDidShowCompletion()&lt;/code&gt; schedules a run 2 seconds later only if it is pending, and when the screen is left, &lt;code&gt;screenWillDisappear()&lt;/code&gt; cancels the schedule. Using a delay follows the sample in &lt;a href=&quot;https://developer.apple.com/documentation/storekit/requesting-app-store-reviews&quot;&gt;Apple&apos;s guide to requesting reviews&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fire&lt;/code&gt;, which does the work, does not keep the scene from the time of scheduling. It reads &lt;code&gt;presenter.view.window?.windowScene&lt;/code&gt; right before running.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private func fire(presenter: UIViewController?, repository: ReviewPromptRepository) {
    scheduledTask = nil
    guard let presenter, let scene = presenter.view.window?.windowScene else { return }
    let version = ReviewPromptConfig.currentAppVersion
    let epochDay = EpochDays.today(millis: Int64(Date().timeIntervalSince1970 * 1000))
    // 시도 기록은 여기에서만 합니다.
    repository.recordAttempt(epochDay: epochDay, version: version)
    AppStore.requestReview(in: scene)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean comment says the attempt is recorded only here.&lt;/p&gt;
&lt;p&gt;Pending is not saved as an attempt so that a canceled schedule does not start the cooldown. The code assumed that the window is nil once the screen is left, but that assumption may not hold during a transition or in the background.&lt;/p&gt;
&lt;h2&gt;First finding: the window stays in the background&lt;/h2&gt;
&lt;p&gt;The first review found the case where the app goes to the background during the 2-second wait. UIKit can keep the window attached in the background, so the presence of a window alone does not tell whether a request is appropriate.&lt;/p&gt;
&lt;p&gt;I added a condition that the scene is &lt;code&gt;.foregroundActive&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;guard let presenter, let scene = presenter.view.window?.windowScene,
      scene.activationState == .foregroundActive else { return }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;During a screen transition, this condition does not guarantee a suitable state for a request either.&lt;/p&gt;
&lt;h2&gt;Second finding: a path that reached fire after cancel&lt;/h2&gt;
&lt;p&gt;A review two days later found a path where &lt;code&gt;fire&lt;/code&gt; can run even though the schedule was canceled when the screen was left. The schedule looked like this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;scheduledTask = Task { [weak presenter] in
    do { try await Task.sleep(for: .seconds(2)) }
    catch { return }   // 취소됨 — 여기서 끝난다고 생각했습니다
    fire(presenter: presenter, repository: repository)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The comment says &quot;canceled, and I thought it ended here.&quot;&lt;/p&gt;
&lt;p&gt;I thought that on cancel, &lt;a href=&quot;https://developer.apple.com/documentation/swift/task/sleep(for:tolerance:clock:)&quot;&gt;Task.sleep&lt;/a&gt; throws and the &lt;code&gt;catch&lt;/code&gt; ends the task. That is only half right. The earlier version called this error &lt;code&gt;CancellationException&lt;/code&gt;, which is the Kotlin name. In Swift it is &lt;code&gt;CancellationError&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is the behavior I confirmed by running code (Swift 6.4, a standalone program).&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Calling &lt;code&gt;try await Task.sleep&lt;/code&gt; inside a task that is already canceled gives &lt;code&gt;CancellationError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Canceling in the middle of a 30-second wait also gives &lt;code&gt;CancellationError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Canceling after &lt;code&gt;Task.sleep&lt;/code&gt; has returned normally lets the next statement run. &lt;code&gt;Task.isCancelled&lt;/code&gt; is true at that point.&lt;/li&gt;
&lt;li&gt;Wrapping the call in &lt;code&gt;try?&lt;/code&gt; only turns the error into nil, and the next statement runs. The canceled state remains.&lt;/li&gt;
&lt;li&gt;Calling &lt;code&gt;Task.checkCancellation()&lt;/code&gt; in the canceled state gives &lt;code&gt;CancellationError&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is the output of running the three cases. &lt;code&gt;before&lt;/code&gt; is calling sleep after cancel, &lt;code&gt;during&lt;/code&gt; is canceling in the middle of the wait, and &lt;code&gt;after&lt;/code&gt; is canceling after sleep returned.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;before: isCancelled=true
before: error=CancellationError, CancellationError=true
try?: nil=true, continued=true, isCancelled=true
check: CancellationError=true
during: CancellationError=true
after: sleep returned
after: continued=true, isCancelled=true
after: check CancellationError=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;after&lt;/code&gt; result is the path in question. Once sleep has returned, a late cancel does not turn that call into a failure. The &lt;code&gt;catch&lt;/code&gt; is skipped, so execution reaches &lt;code&gt;fire&lt;/code&gt;. The earlier version stated this broadly as &quot;an expired sleep does not throw,&quot; but what was confirmed goes only as far as &quot;a cancel after a normal return.&quot; The exact race between the moment the timer ends and the moment the task resumes could not be settled by this experiment.&lt;/p&gt;
&lt;p&gt;The fix is one line. After handling the error from sleep, the code checks once more whether it is canceled now.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;do { try await Task.sleep(for: .seconds(2)) }
catch { return }
guard !Task.isCancelled else { return }   // 이 줄이 있어야 합니다
fire(presenter: presenter, repository: repository)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The comment says &quot;this line is needed.&quot;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;catch&lt;/code&gt; covers a cancel during the wait, and the &lt;code&gt;guard&lt;/code&gt; covers a cancel after the return. Handling the error from &lt;code&gt;Task.checkCancellation()&lt;/code&gt; is another way to do it. What this &lt;code&gt;guard&lt;/code&gt; sees is the state at that instant. It does not remove the chance of a cancel arriving from another execution context after the check. Whether the screen events and this code run one after another in the same context cannot be confirmed from the excerpt alone.&lt;/p&gt;
&lt;h2&gt;The comparison with Android&lt;/h2&gt;
&lt;p&gt;My judgment back then was that the same problem does not exist on the Android side, for two reasons. According to &lt;a href=&quot;https://kotlinlang.org/docs/coroutines-cancellation.html&quot;&gt;the Kotlin cancellation documentation&lt;/a&gt;, suspending functions such as &lt;code&gt;delay&lt;/code&gt; check for cancellation when they suspend. &lt;a href=&quot;https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html&quot;&gt;The API documentation of &lt;code&gt;delay&lt;/code&gt;&lt;/a&gt; says that if it was cancelled while suspended, &lt;code&gt;CancellationException&lt;/code&gt; is thrown even when it is ready to return. And &lt;code&gt;recordPrompt&lt;/code&gt; is placed after &lt;code&gt;suspendCancellableCoroutine&lt;/code&gt;. That guarantee covers a cancellation that arrives while the coroutine is suspended, though. If the cancellation arrives after &lt;code&gt;suspendCancellableCoroutine&lt;/code&gt; has returned normally and before &lt;code&gt;recordPrompt&lt;/code&gt; is called, the next statement can still run, as in the &lt;code&gt;after&lt;/code&gt; case of the measurement above. Whether the Android code checks in between, for example with &lt;code&gt;ensureActive()&lt;/code&gt;, and a result of reproducing the cancellation are not in this post, so the Android side cannot be called safe. &lt;code&gt;recordAttempt&lt;/code&gt; on iOS is a synchronous call and has no point at all where cancellation is checked. The flow of &lt;a href=&quot;https://developer.android.com/guide/playcore/in-app-review&quot;&gt;Google Play in-app reviews&lt;/a&gt; is not part of this comparison.&lt;/p&gt;
&lt;p&gt;The earlier version added here that &quot;Swift&apos;s sleep checks for cancellation only while waiting.&quot; As &lt;code&gt;before&lt;/code&gt; in the output above shows, a task that is already canceled also gets the error, so that explanation does not hold. The Kotlin side was not confirmed by running code. What an API guarantees and whether the whole feature is safe are different questions, and each platform has to be checked separately.&lt;/p&gt;
&lt;h2&gt;Eligibility is a pure function&lt;/h2&gt;
&lt;p&gt;The part that computes whether a request is allowed is separated into a function that does not depend on StoreKit.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static func shouldRequestReview(
    wonCompletionCount: Int,
    lastAttemptEpochDay: Int64,
    todayEpochDay: Int64,
    lastPromptedVersion: String?,
    currentVersion: String,
    forceOverride: Bool
) -&amp;gt; Bool {
    if forceOverride { return true }
    guard wonCompletionCount &amp;gt;= ReviewPromptConfig.wonCompletionThreshold else { return false }
    guard todayEpochDay - lastAttemptEpochDay &amp;gt;= ReviewPromptConfig.cooldownDays else { return false }
    if let lastPromptedVersion, lastPromptedVersion == currentVersion { return false }
    return true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;forceOverride&lt;/code&gt; is on, the request is allowed first. After that, the number of wins is compared with the threshold, the days since the last attempt are compared with the cooldown, and the request is refused when the current version equals the last prompted version. Since it does not call StoreKit&apos;s &lt;code&gt;AppStore&lt;/code&gt;, it can be tested with a truth table.&lt;/p&gt;
&lt;p&gt;iOS and Android each read the same 12 inputs from one JSON file and test against them. The Android check on the number of cases was changed from &lt;code&gt;&amp;gt;= 10&lt;/code&gt; to exactly 12, because &lt;code&gt;&amp;gt;= 10&lt;/code&gt; did not catch the shared fixture changing or the two sides drifting apart. How the date boundary is handled connects to &lt;a href=&quot;/en/posts/daily-leaderboard-date-boundary-submission-reliability/&quot;&gt;the post on leaderboard date boundaries&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I chose the win threshold and the cooldown myself. By my own assessment, the values allow a request earlier than the intent of &lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/ratings-and-reviews&quot;&gt;Apple&apos;s guidelines on ratings and reviews&lt;/a&gt;, which is not to ask before the user has formed an opinion. I treat the system limit as extra protection and wrote the reason for the choice in a comment next to the constants. Nothing guarantees that the system limit stands in for the recommendation in the guidelines.&lt;/p&gt;
&lt;h2&gt;What was verified&lt;/h2&gt;
&lt;p&gt;The original verification was based on commits &lt;code&gt;eeb4d90d&lt;/code&gt;, &lt;code&gt;9884d36f&lt;/code&gt;, and &lt;code&gt;fa69105c&lt;/code&gt; on the develop branch as of August 15, 2026. &lt;code&gt;xcodebuild test&lt;/code&gt; on iOS passed with 287 tests in 60 suites, and &lt;code&gt;testDebugUnitTest&lt;/code&gt; on Android passed with 297 tests in 42 classes. Eligibility has 12 cases on both platforms. These numbers are the record from that time and were not rerun this time.&lt;/p&gt;
&lt;p&gt;Much is not covered by automated tests. The API does not report whether the prompt was shown, so automated tests cannot confirm that the system prompt actually appeared. Neither platform has a test that automatically checks execution after cancel. iOS has no such test file, and only the part that marks pending is within unit tests. Even with a test file, the kill switch in the xctestplan would have blocked the real call. The cancellation path was reviewed by tracing the code and with a static call graph, and both defects came out of code review.&lt;/p&gt;
&lt;p&gt;On a device, the check uses a debug build from Xcode with a launch argument that skips the eligibility decision. According to &lt;a href=&quot;https://developer.apple.com/documentation/storekit/appstore/requestreview(in:)-1q8qs&quot;&gt;the &lt;code&gt;requestReview(in:)&lt;/code&gt; documentation&lt;/a&gt;, this method has no effect in apps distributed through TestFlight.&lt;/p&gt;
&lt;p&gt;What I confirmed by running code this time goes only as far as cancellation behavior in a standalone Swift program. The real race involving UIKit, StoreKit, and the MainActor queue, and whether that path is closed in the app after the fix, could not be reproduced.&lt;/p&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>Swift</category><category>StoreKit</category><category>Concurrency</category><category>Android</category></item><item><title>Turning Apple Pencil handwriting into Sudoku digits</title><link>https://jaemyeong.com/en/blog/apple-pencil-handwriting-digit-input-pipeline/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/apple-pencil-handwriting-digit-input-pipeline/</guid><description>Placing a handwritten digit in DailySudoku: splitting strokes into taps and ink, grouping them, building model input, and applying only trusted results.</description><pubDate>Thu, 06 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The goal for Apple Pencil input in DailySudoku was simple. Writing a 7 on a cell places a 7, and a quick dot with the pencil selects that cell. When a palm touches the screen or the recognition result is uncertain, no digit should be placed.&lt;/p&gt;
&lt;p&gt;Keeping that goal meant solving four things: receiving input without clashing with the existing board gestures, grouping several strokes into one character, converting the input into the same shape used in training, and deciding when to apply a result. Below is the implementation based on the develop branch as of 2026-08-07 (commit 6db30672), along with the parts that automated tests alone could not confirm.&lt;/p&gt;
&lt;h2&gt;Sending the pencil and the finger down different paths&lt;/h2&gt;
&lt;p&gt;I placed a child view, &lt;code&gt;PencilDrawOverlay&lt;/code&gt;, on top of &lt;code&gt;BoardView&lt;/code&gt;. &lt;code&gt;PencilDrawOverlay&lt;/code&gt; is a transparent &lt;code&gt;PKCanvasView&lt;/code&gt; with the &lt;a href=&quot;https://developer.apple.com/documentation/pencilkit/pkcanvasviewdrawingpolicy&quot;&gt;&lt;code&gt;.pencilOnly&lt;/code&gt; policy&lt;/a&gt;, so only the pencil can draw on it. The board&apos;s recognizer, in turn, does not accept the pencil.&lt;/p&gt;
&lt;p&gt;These are the touch types the board recognizer accepts.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;허용: direct, indirect, indirectPointer
제외: pencil
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the block, 허용 means &quot;allowed&quot; and 제외 means &quot;excluded.&quot;&lt;/p&gt;
&lt;p&gt;With this split, the pencil goes to the overlay, and the finger, mouse, and trackpad go to the original cell selection handling. While a stroke is being written, the board&apos;s pan is turned off and tap stays on. The overlay is not exposed as an accessibility element. VoiceOver users keep using the 81 virtual cells described in &lt;a href=&quot;/en/posts/adaptive-accessible-sudoku-board/&quot;&gt;the post about board accessibility&lt;/a&gt;. The cell geometry is shared, and only the path a touch takes is different.&lt;/p&gt;
&lt;h2&gt;Tap or ink, and which group&lt;/h2&gt;
&lt;p&gt;If the diagonal of the first stroke&apos;s bounding box is 6pt or less and the stroke lasts 0.12 seconds or less, it is a tap. Both conditions must hold. For a tap, the ink is cleared and the cell at the center of the bounding box is selected. A stroke near a group that is already being written counts as ink even when it is small and short, because it can be part of a digit.&lt;/p&gt;
&lt;p&gt;A group is finalized 0.45 seconds after the last stroke. Each added stroke resets the end of the wait. The value accounts for digits such as 4, 5, and 7 that can take more than one stroke.&lt;/p&gt;
&lt;p&gt;If a stroke that is not a tap is more than 60pt away from the center of the current group in the x or y direction, the previous group is finalized and a new group starts. For a short, small stroke far away, the tap decision comes first. The previous group is processed and then the new cell is selected. The target cell comes from passing the center of the whole group&apos;s box to &lt;code&gt;BoardView.cellIndex&lt;/code&gt;. The rounded-corner check also uses the same function as the board. Classifying a stroke, assigning it to a group, and choosing the target cell are separate steps.&lt;/p&gt;
&lt;p&gt;The weak point is that 60pt is a fixed distance. Cell spacing changes with screen size, and the boundary for writing in adjacent cells one after another is not settled by tests. I am considering a value proportional to the cell size, or measuring distances on supported devices and calibrating.&lt;/p&gt;
&lt;h2&gt;Building the 28×28 the model sees&lt;/h2&gt;
&lt;p&gt;Point lists are taken from the paths of &lt;code&gt;PKDrawing&lt;/code&gt; and drawn again into a grayscale buffer the size of the board. Then the image is cropped and scaled to match the MNIST format.&lt;/p&gt;
&lt;p&gt;This is the preprocessing flow in order.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PKDrawing
  -&amp;gt; [[CGPoint]]
  -&amp;gt; round-cap grayscale raster
  -&amp;gt; ink bounding-box crop
  -&amp;gt; longest side 20px, aspect ratio 유지
  -&amp;gt; bilinear resample
  -&amp;gt; center of mass를 28×28 중앙으로 이동
  -&amp;gt; [1, 1, 28, 28] Float tensor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean in the block says to keep the aspect ratio (유지) and to move the center of mass to the center of the 28×28 image.&lt;/p&gt;
&lt;p&gt;The stroke width shown on screen is 6pt for normal input and 3pt for notes. For inference, both are redrawn at 6pt. This keeps the display mode from changing the distribution of the input the model receives.&lt;/p&gt;
&lt;p&gt;The preprocessing tests check that empty input returns nil, that the same input gives the same result, that the longest side is 20px, and that the center of mass is in place. They also compare the Core ML label and probability from the Python reference with the Swift result. If any of the crop, scale, pixel direction, or intensity range differs from the training conditions, the model can run without an error and still misrecognize the digit. A successful run does not show that the preprocessing is correct.&lt;/p&gt;
&lt;h2&gt;Results to accept and results to drop&lt;/h2&gt;
&lt;p&gt;The model keeps all 10 classes, 0 through 9. Sudoku has no 0, but I kept it so that an ambiguous circle is not forced into 6 or 9. Input classified as 0 is dropped at the apply step.&lt;/p&gt;
&lt;p&gt;The default confidence threshold is 0.8. For notes, where a correction costs less, it is lowered by 0.1 to 0.7. Only when the result is between 1 and 9 and meets the threshold does the app select the cell and place the digit. A 0, a value out of range, low confidence, a model loading failure, a preprocessing failure, and an inference error are all rejects. A reject does not increase the mistake count. It simply does nothing. The ink is cleared regardless of the result.&lt;/p&gt;
&lt;p&gt;In the current structure, failures with different causes all merge into a single &lt;code&gt;.reject&lt;/code&gt;. There is no way to tell whether the model failed to load, the probability was low, or the label was wrong. Counts per cause are needed. I am considering watching the failure rate with on-device statistics only, without sending the original strokes, but that is not implemented yet.&lt;/p&gt;
&lt;h2&gt;Results that arrive late&lt;/h2&gt;
&lt;p&gt;Rendering and prediction run in &lt;code&gt;Task.detached&lt;/code&gt;, and an accepted result calls &lt;code&gt;selectCell&lt;/code&gt; and &lt;code&gt;placeDigit&lt;/code&gt; on the main actor. There are two problems here.&lt;/p&gt;
&lt;p&gt;The first is sharing the model. &lt;code&gt;CoreMLDigitRecognizer&lt;/code&gt; is declared &lt;code&gt;@unchecked Sendable&lt;/code&gt;, so several tasks can use the same &lt;code&gt;MLModel&lt;/code&gt; at once. The &lt;a href=&quot;https://developer.apple.com/documentation/coreml/mlmodel&quot;&gt;&lt;code&gt;MLModel&lt;/code&gt; documentation&lt;/a&gt; sets a condition that calls on one instance be serialized. &lt;code&gt;@unchecked Sendable&lt;/code&gt; only skips the compiler check and does not change the runtime condition of &lt;code&gt;MLModel&lt;/code&gt;. So the current declaration may conflict with that condition.&lt;/p&gt;
&lt;p&gt;This is a proposal to let an actor own the recognizer. It is not applied yet.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;actor DigitInference {
    private let recognizer: CoreMLDigitRecognizer

    init() throws {
        recognizer = try CoreMLDigitRecognizer()
    }

    func recognize(_ pixels: [Float]) throws -&amp;gt; DigitRecognition? {
        try recognizer.recognize(pixels: pixels)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this, predictions run one at a time, and callers have to use &lt;code&gt;await&lt;/code&gt;. Cloning the model per queue is another option, but it is something to review only after measurements show that a single actor falls short on latency or throughput.&lt;/p&gt;
&lt;p&gt;The second problem is that an old result can be applied after the state has changed. The completion checks only whether the game is in the playing state. After a pause and resume, or after switching note mode, a result requested earlier can still be applied. The two modes can also disagree: the threshold is chosen from the mode at capture time, while &lt;code&gt;placeDigit&lt;/code&gt; acts on the mode at completion time. I am considering storing the three values below with each request and checking that they still match right before applying.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;input revision + target cell + entry mode
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the values differ, the result is discarded. A monotonically increasing number works as the revision.&lt;/p&gt;
&lt;h2&gt;What automated tests covered and what they did not&lt;/h2&gt;
&lt;p&gt;The automated tests focus on the pure reducer and renderer. They cover hit testing on the rounded board, the tap and ink conditions, debounce, restart on a distant stroke, normalization, the reject branches, loading the bundled model, the Python and Swift comparison, the allowed touch list, and the delegate wiring.&lt;/p&gt;
&lt;p&gt;There are only two handwriting fixtures, 1 and 7. That means only two kinds of input are checked from rendering through to the expected digit. The threshold search in the training script is also a proxy evaluation that uses MNIST with rotation and stroke-width changes, plus 0 and noise. So the recognition rate for real pencil handwriting, and whether 0.8 is the best value, are not proven.&lt;/p&gt;
&lt;p&gt;For the delegate, there is a record in a source comment. It diagnosed that on iOS 26.5, PencilKit falls into recursion when the canvas sets itself as its own delegate, so a separate bridge is used. The test only checks whether the canvas and the delegate are the same object. I did not recheck the device logs from that time. Another limit is that the &lt;code&gt;.pencilOnly&lt;/code&gt; path cannot be exercised with a mouse in the simulator.&lt;/p&gt;
&lt;p&gt;Six things need to be checked on devices.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Precision and reject rate for 1 through 9 across handwriting styles&lt;/li&gt;
&lt;li&gt;Latency of the first response and the order of consecutive results&lt;/li&gt;
&lt;li&gt;The 60pt boundary on each screen size&lt;/li&gt;
&lt;li&gt;Behavior when pencil, finger, pointer, and VoiceOver are mixed&lt;/li&gt;
&lt;li&gt;Discarding earlier results during pause and resume or a mode change&lt;/li&gt;
&lt;li&gt;Whether users can understand a model loading failure or repeated rejects&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For metrics, I plan to look at the share of wrong inputs among accepted results, the reject rate, and p95 latency. My judgment was to protect the precision of applied actions first and accept that more inputs go unrecognized.&lt;/p&gt;
&lt;h2&gt;What it takes to switch to PKStrokeRecognizer in iOS 27&lt;/h2&gt;
&lt;p&gt;The iOS 27 beta has &lt;a href=&quot;https://developer.apple.com/documentation/pencilkit/pkstrokerecognizer&quot;&gt;&lt;code&gt;PKStrokeRecognizer&lt;/code&gt;&lt;/a&gt;. It is an actor, and it is an API that recognizes strokes as text asynchronously on the device. It takes a &lt;code&gt;PKDrawing&lt;/code&gt; as input. Recognition results can differ by OS, so storing a result means keeping &lt;code&gt;recognitionVersion&lt;/code&gt; with it. A &lt;a href=&quot;https://developer.apple.com/documentation/pencilkit/recognizing-handwriting-and-converting-to-text&quot;&gt;guide to recognizing handwriting and converting it to text&lt;/a&gt; is published as well. An app with an iOS 26.5 deployment target needs the Xcode 27 beta SDK, &lt;code&gt;#available(iOS 27.0, *)&lt;/code&gt;, and a fallback path that uses Core ML.&lt;/p&gt;
&lt;p&gt;Swapping only the implementation is hard. The current &lt;code&gt;DigitRecognizing&lt;/code&gt; takes &lt;code&gt;[Float]&lt;/code&gt; and returns a digit and a confidence. &lt;code&gt;PKStrokeRecognizer&lt;/code&gt; takes input through &lt;code&gt;updateDrawing(_:)&lt;/code&gt;, returns &lt;code&gt;String?&lt;/code&gt; from &lt;code&gt;recognizedText(strokeIDs:)&lt;/code&gt;, and has no confidence. So a higher asynchronous boundary that takes the drawing and its context is needed.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;recognize(drawing, context) async -&amp;gt; candidate 또는 reject
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here 또는 means &quot;or&quot;: the call returns either a candidate or a reject.&lt;/p&gt;
&lt;p&gt;The Core ML side can keep the current rendering and probability policy, and the new adapter can limit the text to 1 through 9. The rule for applying a result that has no probability automatically needs separate product validation. During the beta period I plan to keep the existing model.&lt;/p&gt;
&lt;p&gt;The values 0.8, 0.7, and 60pt in this post are constants set by policy, not optimal values found by measurement. Two handwriting fixtures and a proxy evaluation on MNIST cannot speak for real accuracy, and the six items above stay open until they are checked on devices.&lt;/p&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>Swift</category><category>PencilKit</category><category>Core ML</category><category>Apple Pencil</category></item><item><title>Exposing 81 Sudoku cells to VoiceOver and TalkBack</title><link>https://jaemyeong.com/en/blog/adaptive-accessible-sudoku-board/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/adaptive-accessible-sudoku-board/</guid><description>The DailySudoku board is one view on screen, but assistive technology needs 81 cells. How iOS and Android expose each cell, and what is still unverified.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The DailySudoku board is a 9×9 square that shows digits and notes. For someone who looks at the screen and taps with a finger, turning a touch position into a cell number is enough. Someone who uses VoiceOver or TalkBack needs more: which cell has focus, what the cell contains, whether it is selected, and how to activate it.&lt;/p&gt;
&lt;p&gt;This post compares the iOS and Android implementations based on the source code as of 2026-08-06. It is not a report that accessibility is done. It records the design rules I used and the parts that are still unverified.&lt;/p&gt;
&lt;h2&gt;What each cell has to say&lt;/h2&gt;
&lt;p&gt;On screen, the board is a single UIView on iOS and a single gesture container on Android. Assistive technology still receives 81 separate cells. Each cell needs this information:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Position: row and column numbers, starting from 1&lt;/li&gt;
&lt;li&gt;Content: empty, a given digit, a value the user entered, an error, or notes&lt;/li&gt;
&lt;li&gt;State: selected or not&lt;/li&gt;
&lt;li&gt;Action and area: selecting the cell, and a rectangle that matches the cell on screen&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An identifier or test tag such as &lt;code&gt;game.cell.r0c0&lt;/code&gt; is only for automated tests to find a cell. The description the user hears has to be separate from that identifier: a localized sentence that carries the position and the content. An example is a sentence saying that row 2, column 7 holds the digit 4.&lt;/p&gt;
&lt;h2&gt;One model for state, one function for input&lt;/h2&gt;
&lt;p&gt;The core game state is projected into models in the &lt;code&gt;CellUi&lt;/code&gt; family. The model holds five things.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CellUi = value + given + error + notes + highlight
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The colors and digits on screen, the calculation from coordinates to an index, and the description, state, and action for assistive technology all come from this model. The two platforms do not need the same view hierarchy. They need the same information from the same model.&lt;/p&gt;
&lt;p&gt;Input also goes to one place. A tap, a drag, a VoiceOver activation, and a TalkBack double-tap all end in &lt;code&gt;selectCell(index)&lt;/code&gt;. I avoided separate accessibility-only logic because it could drift away from the state on screen.&lt;/p&gt;
&lt;h2&gt;iOS: one view, 81 accessibility elements&lt;/h2&gt;
&lt;p&gt;On iOS, a single &lt;code&gt;BoardView&lt;/code&gt; draws the background, the grid, the digits, and the notes. Instead of one UIView per cell, it creates 81 &lt;a href=&quot;https://developer.apple.com/documentation/uikit/uiaccessibilityelement&quot;&gt;&lt;code&gt;UIAccessibilityElement&lt;/code&gt;&lt;/a&gt; objects. Apple&apos;s documentation describes this as the way to expose items that are not views.&lt;/p&gt;
&lt;p&gt;Each element gets these values:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Array order: row-major (left to right, then top to bottom)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;accessibilityLabel&lt;/code&gt;: the coordinates plus whichever of empty, given, error, value, or notes applies&lt;/li&gt;
&lt;li&gt;&lt;code&gt;accessibilityTraits&lt;/code&gt;: button, with selected added for the selected cell&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/uikit/uiaccessibilityelement/accessibilityframeincontainerspace&quot;&gt;&lt;code&gt;accessibilityFrameInContainerSpace&lt;/code&gt;&lt;/a&gt;: the rectangle of the cell&lt;/li&gt;
&lt;li&gt;&lt;code&gt;accessibilityActivate()&lt;/code&gt;: calls &lt;code&gt;onSelect(index)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When the size changes after rotation or Split View, the frames are calculated again. The values are set directly in container coordinates.&lt;/p&gt;
&lt;p&gt;Two things are unverified. I still need to check whether the order in which indexes 0 through 80 are created matches the real VoiceOver swipe order and read-all order. I also have not verified that VoiceOver speaks the new value right away when the focused cell changes.&lt;/p&gt;
&lt;h2&gt;Android: pointerInput has no button meaning&lt;/h2&gt;
&lt;p&gt;On Android, container coordinates are converted to a 9×9 index, and tap and drag use the same hit test. Pointer changes that the board handles are consumed so they do not interfere with outer scrolling.&lt;/p&gt;
&lt;p&gt;The problem is that &lt;code&gt;pointerInput&lt;/code&gt; alone does not make TalkBack treat a cell as a button. The &lt;a href=&quot;https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures&quot;&gt;Compose guide to gesture handling levels&lt;/a&gt; explains that default semantics and focus support shrink as you go down from Button to clickable to pointerInput. So each &lt;code&gt;BoardCell&lt;/code&gt; gets its description, role, and click action by hand.&lt;/p&gt;
&lt;p&gt;These are the semantics attached to each &lt;code&gt;BoardCell&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Modifier.semantics {
    contentDescription = cellDescription
    role = Role.Button
    onClick {
        onSelect(index)
        true
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The board handles continuous drags from ordinary touch, and semantics handles activation of the cell that TalkBack has focused. Putting clickable on each cell would make it compete with the board&apos;s &lt;code&gt;pointerInput&lt;/code&gt; for the same events. This setup avoids that.&lt;/p&gt;
&lt;h2&gt;Where the two platforms still differ&lt;/h2&gt;
&lt;p&gt;Here is the source comparison as a table.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;iOS&lt;/th&gt;
&lt;th&gt;Android&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Each cell exposed separately&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Position and content description&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Button activation&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Separate automation identifier&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User-entered value&lt;/td&gt;
&lt;td&gt;Read as a digit only&lt;/td&gt;
&lt;td&gt;Distinguished as input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Selected state&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.selected&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collection structure&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Android has no selected or stateDescription. The background color of the selected cell changes, but the selected state does not reach TalkBack. Android also does not use &lt;code&gt;collectionInfo&lt;/code&gt; or &lt;code&gt;collectionItemInfo&lt;/code&gt;. The collection information described in the &lt;a href=&quot;https://developer.android.com/develop/ui/compose/accessibility/semantics&quot;&gt;Compose semantics documentation&lt;/a&gt; tells the user the overall size and the current position. For now, both platforms convey the 9-row, 9-column structure only through the description text of each cell.&lt;/p&gt;
&lt;p&gt;I set the next steps in this order. First, add the selected state on Android and listen to the speech output on both platforms. The 9×9 collection information comes after checking that it is not read twice together with the current row and column description. A custom rotor is worth considering only when there is evidence that moving through 81 cells in a line is a real problem.&lt;/p&gt;
&lt;h2&gt;The cells are small&lt;/h2&gt;
&lt;p&gt;The board size has an upper limit: 560pt on iOS and 520dp on Android. On Android, when the width is 840dp or more, the board and the controls sit side by side. In a foldable tabletop posture, the hinge splits the screen into top and bottom areas.&lt;/p&gt;
&lt;p&gt;At any size, the drawing, the hit test, and the accessibility cells must point to the same index. They are recalculated on rotation, window size change, and foldable posture change. While the pause or completion modal is up, the board behind it is removed from navigation. iOS makes the overlay a modal accessibility area, and Android clears the background semantics.&lt;/p&gt;
&lt;p&gt;The concern is cell size. The &lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/accessibility&quot;&gt;accessibility section of the Human Interface Guidelines&lt;/a&gt; gives a default control size of 44×44pt and a minimum of 28×28pt for iOS and iPadOS. The &lt;a href=&quot;https://developer.android.com/develop/ui/compose/accessibility/api-defaults&quot;&gt;Compose documentation on default accessibility behavior&lt;/a&gt; recommends at least 48dp for interactive elements. With nine cells per row, a cell on iOS is narrower than 44pt when the board is narrower than 396pt. On Android, the cell computed from the code under a compact 360dp condition is about 34dp. That number is calculated, not measured on a device.&lt;/p&gt;
&lt;p&gt;Because the Android cells carry hand-written semantics, they do not rely on the automatic target expansion that clickable provides. Still, growing all 81 areas to 44pt or 48dp at once would make them overlap, and it could become unclear which cell was chosen. I can only judge this after measuring focus areas, touch exploration, and finger mis-taps on real devices. The alternatives are enlarging the board, navigating by row or box, or a separate input method. I do not yet have grounds to pick one.&lt;/p&gt;
&lt;p&gt;Text size is also open. Digits and notes on iOS are drawn at a fixed ratio of the cell, so they do not follow Dynamic Type. Where and how to show larger text is left for later verification.&lt;/p&gt;
&lt;h2&gt;What to lock down with tests&lt;/h2&gt;
&lt;p&gt;The existing checks cover cell state projection, coordinate hit testing, drag boundaries, part of the color contrast, and foldable areas. No test locks down the virtual accessibility elements or the cell semantics themselves. These are the items I plan to automate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;81 cells in the playing state&lt;/li&gt;
&lt;li&gt;First and last coordinates (1,1) and (9,9)&lt;/li&gt;
&lt;li&gt;A description for each of given, error, notes, empty, and an ordinary value&lt;/li&gt;
&lt;li&gt;Selected state exposed only on the selected cell&lt;/li&gt;
&lt;li&gt;One &lt;code&gt;selectCell(index)&lt;/code&gt; call per activation&lt;/li&gt;
&lt;li&gt;Areas that match after a resize&lt;/li&gt;
&lt;li&gt;The board excluded after a modal appears&lt;/li&gt;
&lt;li&gt;The 9×9 information and the current row and column in Compose&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The UI tests described in the &lt;a href=&quot;https://developer.android.com/develop/ui/compose/accessibility/testing&quot;&gt;Compose accessibility testing documentation&lt;/a&gt; can check semantics properties and actions. In UIKit, unit tests can check the label, traits, frame, and activation. Being able to find an element with a selector does not prove accessibility.&lt;/p&gt;
&lt;p&gt;I also listed what to check on devices. On an iPhone and an Android phone: the point where a swipe moves to the next row, whether the spoken cell matches the finger position, whether focus stays after a double-tap, and whether changes to digits, notes, and errors are noticed. The last check is to select, enter, correct, and finish without looking at the screen.&lt;/p&gt;
&lt;p&gt;Apple&apos;s &lt;a href=&quot;https://developer.apple.com/documentation/uikit/supporting-voiceover-in-your-app&quot;&gt;guide to supporting VoiceOver&lt;/a&gt; and the &lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/voiceover&quot;&gt;VoiceOver section of the Human Interface Guidelines&lt;/a&gt; recommend turning VoiceOver on and auditing element access, traversal, and tasks that depend on sight. The automated checks introduced in the &lt;a href=&quot;https://developer.android.com/develop/ui/compose/accessibility&quot;&gt;Compose accessibility overview&lt;/a&gt; help find small targets and traversal order problems, but hands-on checking is still needed.&lt;/p&gt;
&lt;p&gt;What the current source supports is this much: both platforms expose the cells separately and route selection to the same function. The selected state on Android, grid information on both platforms, whether the target size is adequate, speech output on state changes, and a full traversal on real devices are not verified. So I do not claim that the two platforms have reached the same level of accessibility.&lt;/p&gt;
</content:encoded><category>it-dev</category><category>Accessibility</category><category>UIKit</category><category>Jetpack Compose</category><category>iOS</category><category>Android</category></item><item><title>A contract for counting ad impressions the same way on three platforms</title><link>https://jaemyeong.com/en/blog/cross-platform-ad-impression-event-taxonomy/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/cross-platform-ad-impression-event-taxonomy/</guid><description>A finished load on iOS, an impression callback on Android, a DOM mount on the web: one name, three meanings. A contract that splits the stages of an ad.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In ad analytics, the event named &lt;code&gt;ad_impression&lt;/code&gt; often gets recorded at a different moment on each platform. One app sends it when the ad finishes loading on iOS, when the SDK reports an impression on Android, and when the ad element is attached to the DOM on the web. The numbers end up in one table, but each platform counts by a different rule. Depending on the platform, the result is an overcount or an undercount.&lt;/p&gt;
&lt;p&gt;Five things get mixed under that one name. They are downloading the ad, attaching it to the view hierarchy, overlapping the viewport, being counted as an impression by the SDK, and receiving a revenue callback. On top of that, adding a manual event to one that is already logged automatically counts the same impression twice.&lt;/p&gt;
&lt;p&gt;This post is a contract I wrote after comparing what each callback means in the Google, Firebase, and web documentation. I reopened and checked the documents on October 4, 2026. It does not give SDK versions, measured logs, or run results. Choosing an ad provider is out of scope.&lt;/p&gt;
&lt;h2&gt;A finished load and a DOM mount are not impressions&lt;/h2&gt;
&lt;p&gt;Receiving an ad does not mean the user saw it. After loading, the ad may never be attached to the screen, and in the meantime the screen can close or the slot can switch to another ad. Being ready is not enough to count an impression.&lt;/p&gt;
&lt;p&gt;Attaching to the DOM on the web is not enough evidence either. &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API&quot;&gt;IntersectionObserver&lt;/a&gt; observes whether the ratio of overlap between the target and the root crosses a threshold. The default threshold is 0, so a notification can arrive as soon as the edges touch. Right after &lt;code&gt;observe()&lt;/code&gt; starts, the first notification can arrive even for a target that is not visible. That notification cannot be treated as the basis an ad network uses for billing. What the documentation describes stops at observing the overlap.&lt;/p&gt;
&lt;h2&gt;Six stages an ad goes through&lt;/h2&gt;
&lt;p&gt;I split the mixed signals into stages, and for each stage I wrote down what is still undecided.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;requested&lt;/code&gt;: the request has started. The response, the display, and the impression are all undecided.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loaded&lt;/code&gt;: a response or creative has arrived. The display and the impression are undecided.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rendered&lt;/code&gt;: the ad is attached to a view or the DOM. Whether it is really visible and whether the SDK counted it are undecided.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;app_visible&lt;/code&gt;: the viewport condition defined by the app is met. Whether the network bills for it is undecided.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sdk_impression&lt;/code&gt;: the SDK counted an impression. Whether revenue data comes and how precise it is are undecided.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;paid_value&lt;/code&gt;: revenue for the impression was observed. The settled amount and its accuracy are undecided.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Platform signals map to these stages as follows.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;bannerViewDidReceiveAd(_:)&lt;/code&gt; on iOS and &lt;code&gt;onAdLoaded()&lt;/code&gt; on Android are &lt;code&gt;loaded&lt;/code&gt;. They are not counted as impressions.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bannerViewDidRecordImpression(_:)&lt;/code&gt; on iOS and &lt;code&gt;onAdImpression()&lt;/code&gt; on Android are &lt;code&gt;sdk_impression&lt;/code&gt;. The app records them only when there is no automatic collection.&lt;/li&gt;
&lt;li&gt;Mounting the ad element on the web is &lt;code&gt;rendered&lt;/code&gt;. It is not counted as an impression.&lt;/li&gt;
&lt;li&gt;Meeting the observer rule is &lt;code&gt;app_visible&lt;/code&gt;. It is used only for in-house ads.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;paidEventHandler&lt;/code&gt; on iOS and &lt;code&gt;OnPaidEventListener&lt;/code&gt; on Android are &lt;code&gt;paid_value&lt;/code&gt;. They do not add to the count.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What counts as one impression&lt;/h2&gt;
&lt;p&gt;The SDK callback comes first as evidence of an impression. For in-house ads with no SDK, the visibility condition is written down, and when it is met the event is recorded with &lt;code&gt;evidence=app_visible&lt;/code&gt;. That group is analyzed separately from the group observed by an SDK. Sharing an event name does not make the two groups directly comparable.&lt;/p&gt;
&lt;p&gt;In this contract, a paid callback is treated as revenue data attached to an impression that was already counted. It does not add one more. Currency and precision are kept as they are. Precision can be unknown or estimated, so the value must not be read as a final settlement. When no revenue is provided, the value is not filled with 0 and is not calculated backward from the count. If a value is put into the Firebase &lt;code&gt;ad_impression&lt;/code&gt; event, the currency has to go with it, and precision is kept in a separate revenue path.&lt;/p&gt;
&lt;p&gt;The failure side is narrowed as well. &lt;code&gt;ad_load_failed&lt;/code&gt; is recorded only when a provider that was actually requested failed to respond. A provider that was skipped because it is not registered or is turned off is not a failure. For the same reason, choosing a provider is not enough to send &lt;code&gt;ad_impression&lt;/code&gt;. If a request was canceled when the screen closed or unmounted and its callback arrives late, it is not treated as a result of the current slot.&lt;/p&gt;
&lt;h2&gt;When events are logged automatically, the app does not send them&lt;/h2&gt;
&lt;p&gt;With AdMob linked to Firebase and automatic logging turned on, the app must not send the same event by hand. Sending the same GA4 event again inside &lt;code&gt;onAdImpression()&lt;/code&gt; turns one impression into two. An SDK with no automatic collection records one event per callback, and an in-house ad records one event each time the visibility condition is met. External web content where only the DOM can be observed needs its own evidence rule.&lt;/p&gt;
&lt;p&gt;Automatic events can lack fields such as &lt;code&gt;slot&lt;/code&gt; or &lt;code&gt;selection_source&lt;/code&gt;. Instead of sending another impression to fill them in, the gap is accepted and the analysis is done per &lt;code&gt;evidence&lt;/code&gt;. Before paid data that goes to a separate server is converted into a GA4 event, the first check is whether automatic logging is on. The contract also states how the filled fields differ between automatic and manual events.&lt;/p&gt;
&lt;h2&gt;Slot, attempt, and ad instance&lt;/h2&gt;
&lt;p&gt;To remove duplicates, the app first has to define what one unit is. A slot is the unit for as long as the UI lives. An attempt is one request to one provider. An ad instance is the unit for as long as one creative lives.&lt;/p&gt;
&lt;p&gt;This diagram shows one slot where a request fails, a second request succeeds, and a refresh produces a second impression.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;slot instance
  ├─ attempt 1 → load failed
  └─ attempt 2 → loaded → impression 1
                            └─ refresh → impression 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A single Boolean per session misses the impression that comes from a refresh. Sending every callback, on the other hand, can produce duplicates on retries and remounts. So a failure is kept once per attempt and an impression once per instance. When a refresh brings a new creative, it is a new instance, and only a repeated callback from the same instance is dropped. In-memory identifiers are enough for this. There is no need to send unique IDs to GA4 and create a dimension with too many distinct values.&lt;/p&gt;
&lt;h2&gt;The shape of the event inside the app&lt;/h2&gt;
&lt;p&gt;Inside the app, the event has few fields and only fixed values. The example below is not a standard GA4 schema. It is an internal example.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;ad_impression&quot;,
  &quot;provider&quot;: &quot;global_network&quot;,
  &quot;format&quot;: &quot;banner&quot;,
  &quot;slot&quot;: &quot;bottom&quot;,
  &quot;evidence&quot;: &quot;sdk_impression&quot;,
  &quot;selection_source&quot;: &quot;normal&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For &lt;code&gt;provider&lt;/code&gt;, &lt;code&gt;format&lt;/code&gt;, and &lt;code&gt;slot&lt;/code&gt;, the mapping to each platform&apos;s enum is written in code. If a provider is added and a mapping is missing, the build or a test fails. The values given for &lt;code&gt;evidence&lt;/code&gt; in the text are &lt;code&gt;sdk_auto&lt;/code&gt;, &lt;code&gt;sdk_callback&lt;/code&gt;, and &lt;code&gt;app_visible&lt;/code&gt;. How &lt;code&gt;sdk_impression&lt;/code&gt; in the example maps to those three values is not defined in this post.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;selection_source&lt;/code&gt; is calculated for the object the event points to. If a forced provider fails and another provider&apos;s ad is shown, the source of the failure and the source of the impression can differ. Copying one source along the chain of requests distorts the meaning.&lt;/p&gt;
&lt;h2&gt;Checking without depending on ad inventory&lt;/h2&gt;
&lt;p&gt;Waiting for a real ad makes the check depend on inventory. Injecting callbacks directly in a test removes that dependency. The list below gives the expected values for such a check. It is not a record of a run that passed.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A successful load alone gives 0 impressions.&lt;/li&gt;
&lt;li&gt;One SDK callback gives 1.&lt;/li&gt;
&lt;li&gt;A repeated callback from the same instance still gives 1.&lt;/li&gt;
&lt;li&gt;After a refresh with a new ad, the total is 2.&lt;/li&gt;
&lt;li&gt;For a provider with automatic collection, manual events are 0.&lt;/li&gt;
&lt;li&gt;A provider skipped before any request gives 0 failures.&lt;/li&gt;
&lt;li&gt;When another provider is shown after a failure, there is 1 failure and 1 impression, and the source is calculated for each.&lt;/li&gt;
&lt;li&gt;A late callback after unmount gives 0.&lt;/li&gt;
&lt;li&gt;The first observer notification while not visible gives 0.&lt;/li&gt;
&lt;li&gt;Two independent slots that are each shown give 2.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Putting the SDK callback first, the units of identity, and the design of the internal enums and source are my reading of the documents. They do not guarantee what a network bills or settles. An in-house visibility rule on the web has to be verified down to the threshold, the dwell time, and the reset when the ad leaves the area, and a single entry at one moment cannot tell how long the ad was visible. &lt;code&gt;trackVisibility&lt;/code&gt; is limited and experimental, so it is left out of the required dependencies. How to link a revenue callback to the earlier impression, and how to handle the order in which callbacks arrive, are not covered in this post.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>iOS</category><category>Android</category><category>Web</category><category>Advertising</category><category>Analytics</category></item><item><title>Daily puzzle scores: which day they belong to and how they get lost</title><link>https://jaemyeong.com/en/blog/daily-leaderboard-date-boundary-submission-reliability/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/daily-leaderboard-date-boundary-submission-reliability/</guid><description>In a game started at 23:50 and finished at 00:05, the puzzle date, the submission time, and the leaderboard window can disagree. How far to guarantee delivery.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When the result of a once-a-day puzzle is sent to both Game Center and Google Play Games, the date becomes a problem for a game that crosses midnight. In a game started at 23:50 and finished at 00:05, the day the puzzle belongs to, the day the SDK received the submission, and the range the leaderboard screen shows can all disagree.&lt;/p&gt;
&lt;p&gt;Delivery is a problem too. The completion record and the statistics can remain on the device while the external score is lost. The opposite problem also exists: asking for sign-in in the middle of completion ties game progress to authentication.&lt;/p&gt;
&lt;p&gt;The basis is the official Apple and Google documentation, which I reopened and checked on October 4, 2026. What those documents state is the behavior of the SDKs and the servers. The submission condition, where the sign-in screen goes, the in-memory slot, and the outbox are app-side design on top of that, not requirements of the documentation. The midnight case is a made-up example. This post does not give measured values, run logs, or SDK versions.&lt;/p&gt;
&lt;h2&gt;Three values that all look like a date&lt;/h2&gt;
&lt;p&gt;Three values look like the same &quot;date&quot; but have different owners. &lt;code&gt;puzzle_day&lt;/code&gt; is the puzzle&apos;s date, decided by the app. &lt;code&gt;submitted_at&lt;/code&gt; is the submission time, decided by the app and the device. &lt;code&gt;leaderboard_window&lt;/code&gt; is the range used for queries, decided by the SDK.&lt;/p&gt;
&lt;p&gt;The puzzle date is set when the game is created and does not change within the session. The submission time can change on every retry. Nothing guarantees that the SDK&apos;s window matches midnight on the device. So the date is not calculated again at completion. The value stored in the session is used.&lt;/p&gt;
&lt;p&gt;This example shows how the three values differ in a game that crosses midnight.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;퍼즐 생성: 2026-08-05 23:50 → puzzle_day = 2026-08-05
게임 완료: 2026-08-06 00:05 → submitted_at = 2026-08-06 00:05
제출 메타데이터                    → 2026-08-05
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The three Korean labels mean &quot;puzzle created,&quot; &quot;game completed,&quot; and &quot;submission metadata.&quot;&lt;/p&gt;
&lt;h2&gt;Extra values do not change the window&lt;/h2&gt;
&lt;p&gt;On Android, the puzzle date in &lt;code&gt;yyyy-MM-dd&lt;/code&gt; form can be carried in &lt;code&gt;scoreTag&lt;/code&gt;. &lt;code&gt;scoreTag&lt;/code&gt; is an optional argument of &lt;code&gt;submitScore&lt;/code&gt; in &lt;a href=&quot;https://developers.google.com/android/reference/com/google/android/gms/games/LeaderboardsClient&quot;&gt;LeaderboardsClient&lt;/a&gt;. It allows only URI-safe characters and up to 64 characters. The documentation defines this value only as optional metadata. Using it to say which puzzle date a score came from is the app&apos;s own choice. It does not prevent duplicates per date and does not filter the screen. Putting 2026-08-05 in the tag does not split the rows of &lt;code&gt;TIME_SPAN_DAILY&lt;/code&gt; by date.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;context&lt;/code&gt; on Apple is the same. The &lt;code&gt;context&lt;/code&gt; of &lt;a href=&quot;https://developer.apple.com/documentation/gamekit/gkleaderboard/submitscore(_:context:player:leaderboardids:completionhandler:)&quot;&gt;submitScore&lt;/a&gt; is an extra value whose meaning the app defines, so how to express a date in it is the app&apos;s responsibility. It is not a way to control &lt;code&gt;timeScope&lt;/code&gt;. If only &lt;code&gt;context: 0&lt;/code&gt; was sent, the puzzle date cannot be worked out from the score data. The fact that this value means different things on the two platforms should be written into the product requirements.&lt;/p&gt;
&lt;h2&gt;The day that the default screens show&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.android.com/games/pgs/leaderboards&quot;&gt;Play Games leaderboards&lt;/a&gt; offer daily, weekly, and all-time, and the daily boundary is midnight UTC-7 all year. Apple&apos;s &lt;a href=&quot;https://developer.apple.com/documentation/gamekit/gkleaderboard/timescope-swift.enum/today&quot;&gt;today scope&lt;/a&gt; is the last 24 hours. When an occurrence of an Apple &lt;a href=&quot;https://developer.apple.com/documentation/gamekit/creating-recurring-leaderboards&quot;&gt;recurring leaderboard&lt;/a&gt; changes depends on the &lt;a href=&quot;https://developer.apple.com/help/app-store-connect/reference/game-center/leaderboards/&quot;&gt;recurrence settings in App Store Connect&lt;/a&gt;. Neither default daily screen guarantees a match with &lt;code&gt;puzzle_day&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Take an app whose puzzle changes at midnight in Seoul. Scores for the new puzzle and for the previous day&apos;s puzzle can fall into one Google daily window. Apple&apos;s today scope can also hold scores from both days. This comes from a different contract for the displayed range, and editing the tag does not solve it.&lt;/p&gt;
&lt;p&gt;There are three paths, depending on the requirement.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If the SDK&apos;s daily screen is enough, use the extra value and the default UI.&lt;/li&gt;
&lt;li&gt;If occurrences need to be told apart on Apple, consider a recurring leaderboard.&lt;/li&gt;
&lt;li&gt;If both platforms must use the same calendar rule and the same query rule, a separate store and a custom UI are needed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The last path brings costs for operations, anti-cheating, and privacy management. For a simple daily competition, I judged that it does not need to be introduced in advance. That judgment is my reading, not something the documentation says.&lt;/p&gt;
&lt;h2&gt;When to submit and when not to&lt;/h2&gt;
&lt;p&gt;The SDKs do not define when to submit. The policy below is an example of what the app decides: a score is submitted only when three conditions are all met.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;submit = won &amp;amp;&amp;amp; daily &amp;amp;&amp;amp; authenticated
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In free mode, after a loss, or without authentication, nothing is sent. When the authentication state is uncertain, only a check that shows no screen is allowed. The sign-in screen belongs on the path where the user presses the leaderboard button. A disabled service, a signed-out user, or a canceled sign-in must not make completion, statistics, or the screen transition fail.&lt;/p&gt;
&lt;p&gt;The device has its own duties at completion. It prevents the terminal transition from being handled twice, deletes the resume data, and updates the statistics and the daily completion record. The external submission is a separate attempt, and its failure does not roll completion back.&lt;/p&gt;
&lt;p&gt;The phrase &quot;local first&quot; needs care here. The phrase alone does not say that the write to disk finishes before the submission. On Android, if the submission starts right after an asynchronous write is scheduled, the order in which the two finish is not defined. If the disk record has to be settled first, the design needs to wait for the save to complete.&lt;/p&gt;
&lt;h2&gt;Holding one score for a short while&lt;/h2&gt;
&lt;p&gt;On Android, a score can arrive in the short moment when no UI host is connected. One design for that case holds at most one score in memory. This is a design on the app side, not a feature of the SDK. If A is held and B arrives, B replaces A. When an Activity connects, the score is taken out, authentication is checked, and it is submitted once.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Activity 없음
  score A 보류
  score B 도착 → A를 B로 교체
Activity 연결
  B를 꺼내 인증 확인 → 제출 시도
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the block, the lines read: no Activity, score A is held, score B arrives and replaces A, an Activity connects, and B is taken out, authentication is checked, and a submission is attempted.&lt;/p&gt;
&lt;p&gt;The in-memory slot exists to reduce short lifecycle races, and its limits are clear. If the process dies, the held score is gone. If authentication is false or the check fails after the score is taken out, it is not put back. If iOS has no equivalent in-memory slot, the chance that a score is delivered also differs between the two platforms.&lt;/p&gt;
&lt;p&gt;Keeping the latest item is a rule inside the device only. Google applies a score only when it is better than the value on the server, and Apple picks the best score or the most recent score depending on the setting. The app choosing the latest item does not mean the server adopts that record.&lt;/p&gt;
&lt;p&gt;The call itself differs too. &lt;code&gt;submitScore&lt;/code&gt; does not report a result, and &lt;code&gt;submitScoreImmediate&lt;/code&gt; returns a &lt;code&gt;Task&lt;/code&gt;. With the call that reports nothing, the app cannot tell whether the submission succeeded. For the one that returns a &lt;code&gt;Task&lt;/code&gt;, too, the documentation says nothing about sending again after the app restarts.&lt;/p&gt;
&lt;h2&gt;How far to guarantee delivery&lt;/h2&gt;
&lt;p&gt;Splitting the delivery level into three is also my own classification. The documentation does not make this distinction.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One direct attempt: send once within the session. It costs the least and fits a feature that can afford to miss a score.&lt;/li&gt;
&lt;li&gt;One slot in memory: keep the latest item while there is no host. The cost is low, and it fits short lifecycle races.&lt;/li&gt;
&lt;li&gt;Durable outbox: the score can be sent again after a restart. The cost is high, and it fits cases where a lost submission affects rewards or contest results.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With a durable outbox, the payload is written to disk first. A stable submission ID, &lt;code&gt;puzzle_day&lt;/code&gt;, the score, and the target leaderboard are stored. Only items with a success response are deleted, and the remaining items are restored on restart. Backoff and success confirmation are part of it. The server keeping an existing better record and the app deciding to stop resending are judged separately. Storing a submission ID does not mean the SDK removes duplicates or processes a score exactly once.&lt;/p&gt;
&lt;p&gt;If loss is accepted, the queue is not extended into a general-purpose one. The conditions under which a score is dropped are written into the documentation and the tests.&lt;/p&gt;
&lt;h2&gt;Expected values to pin with tests&lt;/h2&gt;
&lt;p&gt;The expected values below are a plan: keep the condition and the holding policy as pure functions and pin them with tests. This post does not include results from a run.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A win in free mode or a loss in daily mode gives 0 submissions.&lt;/li&gt;
&lt;li&gt;A daily win without authentication gives 0 submissions and no sign-in screen.&lt;/li&gt;
&lt;li&gt;Playing from 23:50 to 00:05 keeps the session&apos;s puzzle date.&lt;/li&gt;
&lt;li&gt;With no Activity, A followed by B keeps only B.&lt;/li&gt;
&lt;li&gt;If the process dies while a score is held, nothing is submitted after restart.&lt;/li&gt;
&lt;li&gt;If authentication fails after the score is taken out, it is not held again.&lt;/li&gt;
&lt;li&gt;If the remote request fails, the completion record and statistics remain.&lt;/li&gt;
&lt;li&gt;For a latest score lower than the previous one, the device&apos;s latest choice and the server&apos;s best choice are checked separately.&lt;/li&gt;
&lt;li&gt;If the terminal event arrives again, completion is recorded once and submission is attempted once.&lt;/li&gt;
&lt;li&gt;The default daily and today queries use the SDK&apos;s time range, not the extra value.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Unit tests prove only the app&apos;s branches and the payload. They are not evidence that a score actually reached the server. Canceled sign-in, offline use, and display errors of the leaderboard intent and the access point have to be checked separately in an integrated environment. The different meaning of a day on the default screens, the loss of the in-memory score, the call whose result is unknown, and the order in which the disk write completes all remain as limits of this design.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>iOS</category><category>Android</category><category>GameKit</category><category>Google Play Games</category><category>Testing</category></item><item><title>Why a daily puzzle ID needs a generator version</title><link>https://jaemyeong.com/en/blog/daily-puzzle-identity-generator-version/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/daily-puzzle-identity-generator-version/</guid><description>A daily board is made from the date. If the generator changes, one date can give two boards, and a date-only ID cannot tell them apart. Design and proposal.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The board of a daily puzzle is made from the date. That is how the Sudoku app DailySudoku works. With the same day integer and the same core build, entering through the normal daily screen gives the same board on iOS, Android, and the web. But if the way boards are generated changes, the board can differ even when the seed stays the same. When the keys for storage, analytics, and leaderboards hold only the date, that difference is hard to express.&lt;/p&gt;
&lt;p&gt;The baseline is commit 9c99930582be23d2c72d37fb46d77a488b9fe04c on the develop branch, dated August 6, 2026. I describe the current structure and the proposal on top of it separately. The &lt;code&gt;DailyPuzzleIdentity&lt;/code&gt; type, the &lt;code&gt;device-local-v1&lt;/code&gt; day policy, the g1 and g2 IDs, schema v2, and the resolver are all proposals and are not implemented yet.&lt;/p&gt;
&lt;h2&gt;How a daily puzzle is created today&lt;/h2&gt;
&lt;p&gt;Entering the daily puzzle screen decides the board in this order.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;현재 시각
  -&amp;gt; 기기 로컬 시간대의 epochDay
  -&amp;gt; dailySeed(epochDay)
  -&amp;gt; generate(MEDIUM, seed)
  -&amp;gt; puzzleId = &quot;DAILY-&amp;lt;epochDay&amp;gt;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first two lines read &quot;current time&quot; and &quot;epochDay in the device&apos;s local time zone.&quot;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;dailySeed&lt;/code&gt; uses the SplitMix64 finalizer and is computed with 64-bit &lt;a href=&quot;https://doc.rust-lang.org/stable/std/primitive.u64.html#method.wrapping_add&quot;&gt;wrapping addition&lt;/a&gt;, &lt;a href=&quot;https://doc.rust-lang.org/stable/std/primitive.u64.html#method.wrapping_mul&quot;&gt;wrapping multiplication&lt;/a&gt;, and logical shifts. The generator builds a complete board with its own RNG and then removes only the clues that keep the solution unique. This core is written in Rust. iOS and Android call it through UniFFI, and the web calls it through WebAssembly.&lt;/p&gt;
&lt;p&gt;From the normal daily entry point, with the same day integer and the same core build, the fresh board and the solution match on all three platforms. That determinism and the golden fixtures are explained in &lt;a href=&quot;/en/posts/rust-shared-core-07-testing-ci/&quot;&gt;the post on testing and CI for the shared core&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The date is the device&apos;s local date&lt;/h2&gt;
&lt;p&gt;The day integer comes from the device&apos;s local date, not the UTC date.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;localEpochDay = floor((utcMillis + offsetAtInstant) / 86_400_000)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The offset is &quot;local minus UTC&quot; at that instant. The one that returns milliseconds is &lt;a href=&quot;https://developer.android.com/reference/java/util/TimeZone#getOffset(long)&quot;&gt;TimeZone.getOffset&lt;/a&gt; on Android. &lt;a href=&quot;https://developer.apple.com/documentation/foundation/timezone/secondsfromgmt%28for%3A%29&quot;&gt;secondsFromGMT(for:)&lt;/a&gt; on iOS returns seconds, so it is converted to milliseconds. &lt;a href=&quot;https://tc39.es/ecma262/#sec-date.prototype.gettimezoneoffset&quot;&gt;getTimezoneOffset&lt;/a&gt; on the web returns &quot;UTC minus local&quot; in minutes, so the sign is flipped and the value is multiplied by 60_000. Because of daylight saving time, the offset must not be a constant.&lt;/p&gt;
&lt;p&gt;Under this policy, the day integer can differ between Seoul and Los Angeles at the same UTC instant. A time zone change after travel affects the ID on the next launch. A game that has already started keeps its date, and the date is decided again the next time the daily puzzle is entered. There is no guarantee that the whole world gets one board at the same moment. Conversely, devices that pick the same day integer, such as ones in Seoul and Tokyo, get the same board when the core and the difficulty are the same. So the time zone ID can be left out of the identity fields.&lt;/p&gt;
&lt;h2&gt;What a date-only ID misses&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;DAILY-&amp;lt;epochDay&amp;gt;&lt;/code&gt; has neither the difficulty nor the generator version. The &lt;code&gt;version: 1&lt;/code&gt; in the JSON that Rust stores is the number of the serialization format. The reader rejects any other schema that has no migration. That number is not the version of the generation algorithm.&lt;/p&gt;
&lt;p&gt;There is a case where generation really changed. For the Expert difficulty, the maximum number of attempts was raised from 8 to 4,096, and for seed 159 a 24-clue board came out in place of the earlier 25-clue fallback. This is the change history of Expert, and it is not evidence that MEDIUM, which the daily puzzle uses, changed. It should be read only as a case showing that generation rules can change.&lt;/p&gt;
&lt;p&gt;If the generator changes while old and new apps run together, this happens. A new game in the old app is a board made by g1. A new game in the new app is a board made by g2. A restored game is the stored g1 board. All three can use the same &lt;code&gt;DAILY-day&lt;/code&gt;. When the storage format is compatible, the old game can continue by restoring the board and the solution, but the collision of the external ID in analytics remains. Standard Game Center and Play Games have no feature that splits scores by a dynamic puzzle ID. Which version&apos;s scores to accept has to be decided before submission. The limits of &lt;code&gt;context&lt;/code&gt; and the score tag are covered in &lt;a href=&quot;/en/posts/daily-leaderboard-date-boundary-submission-reliability/&quot;&gt;the post on leaderboard date boundaries&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The proposed identity&lt;/h2&gt;
&lt;p&gt;I propose a type that groups the day policy, the day integer, the difficulty, and the generator version.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type DailyPuzzleIdentity = {
  dayPolicy: &quot;device-local-v1&quot;;
  epochDay: number;
  difficulty: &quot;MEDIUM&quot;;
  generatorVersion: number;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each field has its own responsibility. &lt;code&gt;dayPolicy&lt;/code&gt; covers the boundary of a day, &lt;code&gt;epochDay&lt;/code&gt; the chosen date, &lt;code&gt;difficulty&lt;/code&gt; the generation target, and &lt;code&gt;generatorVersion&lt;/code&gt; the seed derivation, the RNG, and the generation rules. With &lt;code&gt;dayPolicy&lt;/code&gt; in place, changing the date rule later does not apply the new rule to old identities. Metadata that records the observed time zone and &lt;code&gt;dayPolicy&lt;/code&gt; have different roles.&lt;/p&gt;
&lt;p&gt;Written as a string ID, the identity looks like this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DAILY-device-local-v1-20671-MEDIUM-g1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The seed can be computed again from &lt;code&gt;epochDay&lt;/code&gt;, so it does not need to be in the ID. It can still be used in stored data or telemetry as a helper value for diagnosis and fixtures.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;generatorVersion&lt;/code&gt; is kept separate from the Cargo package version. A change to the UI or an ad SDK alone does not need a bump. A change to the RNG, the shuffle, the difficulty target, clue removal, or retries bumps it depending on whether results are affected. The meaning of a version that has been issued is never changed. If Rust returns the identity and the board together as one result, a g2 board cannot end up with a g1 label.&lt;/p&gt;
&lt;h2&gt;A unique solution and a stable identity are different questions&lt;/h2&gt;
&lt;p&gt;Each time the generator removes a clue, the solver looks for up to two solutions, and only a removal that leaves exactly one solution is accepted. This is a check on a freshly generated board. Whether an ID such as &lt;code&gt;DAILY-20671&lt;/code&gt; points to exactly one board is a separate matter.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;solver invariant
  -&amp;gt; 이 board에는 해답이 정확히 하나인가

identity invariant
  -&amp;gt; 이 PuzzleIdentity는 배포·복원·분석 전체에서 같은 board를 뜻하는가
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first question asks whether this board has exactly one solution. The second asks whether this PuzzleIdentity means the same board across release, restore, and analytics.&lt;/p&gt;
&lt;p&gt;The solver cannot catch two different uniquely solvable boards sharing an ID, and it cannot catch one board having several IDs.&lt;/p&gt;
&lt;p&gt;Fixtures have limits too. A fixture only tells whether the result changed for the chosen inputs. The mistake of changing generation rules without bumping the version cannot be blocked automatically by the return API alone. If whole-board equality has to be guaranteed, a canonical digest or an archive fills the gap. A hash only says that something changed. It does not say why, or which generator to use to build the board again. So the version contract of the identity comes first, and whether to add a checksum is decided after that.&lt;/p&gt;
&lt;h2&gt;Moving stored data&lt;/h2&gt;
&lt;p&gt;For schema v2, I propose storing the snapshot together with the versioned identity. Existing v1 saves are labeled g1 only when the release history confirms that all of v1 was g1.&lt;/p&gt;
&lt;p&gt;A save whose origin is unknown is isolated as &lt;code&gt;legacy-&amp;lt;digest&amp;gt;&lt;/code&gt;. The digest is a canonical digest of the givens, the solution, and the difficulty. The entries, the history, and the elapsed time are left out. Such a save is excluded from version comparison. A past record that lacks a board or a version stays as &lt;code&gt;legacy-unknown&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A new game uses the active version, and regenerating a past date uses the recorded version.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PuzzleIdentity.generatorVersion == 1 -&amp;gt; g1 또는 저장된 g1 board
PuzzleIdentity.generatorVersion == 2 -&amp;gt; g2 또는 저장된 g2 board
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On each line, the right side reads &quot;that generator, or the stored board of that version.&quot;&lt;/p&gt;
&lt;p&gt;A past version that is not supported is reported as an error and is not quietly built with another version. A product with no replay of past games only needs the board and solution of the save in progress and a path that carries the identity. When there is replay or server-side verification, the old generator or an immutable archive is needed.&lt;/p&gt;
&lt;h2&gt;What to keep when rolling back&lt;/h2&gt;
&lt;p&gt;If g2 has a problem and is rolled back, the version-aware binary stays, and only new issuance moves from g2 to g1. A board already assigned as g2 stays g2.&lt;/p&gt;
&lt;p&gt;The order is fixed as well. First comes a compatible build that reads both v1 and v2 and uses versioned IDs but issues only g1. After that, g2 is turned on by a build or a remote policy. During the rollback period, both g1 generation and reading of g2 snapshots are kept. Going back to an old binary that does not know versions is not allowed.&lt;/p&gt;
&lt;p&gt;This switch cannot be forced at once in the current structure. The generator is inside the client binary, and nothing central picks the version. Even if the server or the web is rolled back, a g2 mobile app that is already installed keeps running g2. Versioning cannot force one board per day in a mixed rollout. If that is required, a single authority has to manage an immutable mapping from the date to the generator version and also operate the handling of old clients. That means the server issues the identity or the board, or the minimum app version is controlled.&lt;/p&gt;
&lt;p&gt;In a structure that ships both generators and picks one through the server or Remote Config, new issuance can be switched to g1. A stored g2 game finishes with its original identity and board. Excluding g2 scores has to happen before submission, and querying by version needs a custom backend or leaderboards that were split in advance.&lt;/p&gt;
&lt;h2&gt;Checks that exist now and checks to add&lt;/h2&gt;
&lt;p&gt;Today the parity fixture pins the seed, the givens, and the solution for the combination of 4 epoch days and 5 difficulties. Overwriting the existing fixture with new results erases the contract of the old version. The fixed 64-bit seed arithmetic is checked with golden values, drift in fresh boards with the Rust tests and parity, and uniqueness with clue removal and tests on selected seeds. Checking 4 by 5 combinations does not prove every input.&lt;/p&gt;
&lt;p&gt;For the switch, I propose leaving the g1 fixture as it is and creating the g2 fixture as a new file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;g1 fixture: 변경 금지
  (device-local-v1, day-before-cutover, MEDIUM, g1) -&amp;gt; board A

g2 fixture: 새 파일
  (device-local-v1, cutover-day, MEDIUM, g2) -&amp;gt; board B

resolver test
  저장된 g1 identity -&amp;gt; g1 또는 저장 snapshot
  새 g2 identity     -&amp;gt; g2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean notes say that the g1 fixture must not be changed and that the g2 fixture is a new file. In the resolver test, a stored g1 identity resolves to g1 or the stored snapshot, and a new g2 identity resolves to g2.&lt;/p&gt;
&lt;p&gt;There are four things to confirm. The g1 board and solution match on supported builds. Different versions give different external IDs. After a rollback, g2 is not treated as g1. The versions of the identity and the board are never mixed. On the Swift, Kotlin, and TypeScript side, the algorithm is not copied for testing. The tests cover storing and restoring the ID and passing it to the analytics and score policies.&lt;/p&gt;
&lt;p&gt;The order of implementation is combining the Rust return value, keeping the identity in schema v2, replacing the analytics key, checking the version at score submission, and fixed fixtures per version. Until a second generator exists, a small switch is enough for dispatch, and there is no need to build a separate factory or a general migration system first.&lt;/p&gt;
&lt;p&gt;Identifying the generator version, the contract for old and new versions living together, and migration and rollback per version are all still unimplemented. The determinism guaranteed today holds only while the input and the code are fixed, and that is different from an identity contract that lasts. The constraint on splitting scores in Game Center and Play Games has to be checked again if the platforms or the configuration change.&lt;/p&gt;
</content:encoded><category>it-dev</category><category>Rust</category><category>Testing</category><category>iOS</category><category>Android</category><category>WebAssembly</category></item><item><title>Ordering ad providers by GeoIP region without the location permission</title><link>https://jaemyeong.com/en/blog/geoip-ad-routing-without-location-permission/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/geoip-ad-routing-without-location-permission/</guid><description>When ad providers differ by region, an app needs a broad region, not coordinates. Consent first, then a server-side region bucket decides the provider order.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When the ad providers an app can use differ by region, the app has to know which region the user is in. The information it needs is not coordinates. A country, or an area broader than that, is enough. Asking for precise location access just to pick an ad is a heavy cost for both the user and the app.&lt;/p&gt;
&lt;p&gt;Below is a design that does not use the OS location permission. It decides the order of providers from a region that the server estimates from the IP address. It explains what each signal means and where it can be wrong. This post has no record of observing or measuring a real failure, and it does not give the versions of the SDKs and services. Only the example function that decides the order was run, and the result is in the section &quot;What running the example function showed.&quot;&lt;/p&gt;
&lt;p&gt;What the official documentation explains directly is how the location permission, the CDN location headers, and the UMP gate each behave. Looking up the region after consent, the per-process cache, the split between the resolver and the loader, the provider chain, and the expected values in tests are not requirements of the documentation. They are my own design choices made on top of it.&lt;/p&gt;
&lt;h2&gt;Four signals that hint at a region&lt;/h2&gt;
&lt;p&gt;Signals that look like a region mean different things.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The app language is a preference for a language that is comfortable to read.&lt;/li&gt;
&lt;li&gt;The country in the Locale is a setting the user chose.&lt;/li&gt;
&lt;li&gt;OS location services give a value measured by the device, and they come with permissions and the handling of sensitive data.&lt;/li&gt;
&lt;li&gt;GeoIP is a region estimated from an IP address observed from outside.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Using Korean is not enough to choose a domestic provider. While traveling, or when the setting was never changed, the Locale and the current place can differ. GeoIP can also be wrong or empty because of a VPN, a relay service, or a carrier gateway. And the server that does the lookup sees the IP address.&lt;/p&gt;
&lt;p&gt;So the signals are considered from the least precise up. The Locale is used only as a default for language and policy, not as the basis for whether a provider is available in a region. GeoIP is requested separately, only when a regional branch is needed, and when there is not enough to decide, the result stays &lt;code&gt;unknown&lt;/code&gt;. For a feature that really needs location, the context and the minimum permission level are weighed against &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services&quot;&gt;Apple&apos;s guide to location authorization&lt;/a&gt; and &lt;a href=&quot;https://developer.android.com/privacy-and-security/minimize-permission-requests&quot;&gt;Android&apos;s guide to minimizing permission requests&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Consent first, region second&lt;/h2&gt;
&lt;p&gt;The order at app start is as follows.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Refresh the stored consent state.&lt;/li&gt;
&lt;li&gt;Handle the consent UI if it is needed.&lt;/li&gt;
&lt;li&gt;Check the gate that says ads may be requested.&lt;/li&gt;
&lt;li&gt;Resolve the region once per process and keep it in memory.&lt;/li&gt;
&lt;li&gt;Build the provider array for the region bucket.&lt;/li&gt;
&lt;li&gt;Request in the order of the array.&lt;/li&gt;
&lt;li&gt;If everything fails, end with in-house content.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;An example of the gate is &lt;code&gt;canRequestAds&lt;/code&gt; in &lt;a href=&quot;https://developers.google.com/admob/ios/privacy&quot;&gt;Google UMP&lt;/a&gt;. Being allowed to request ads and being able to trust the region estimate are separate matters.&lt;/p&gt;
&lt;p&gt;When consent has not been confirmed, or external ads are blocked, the region lookup is not made at all. What to show in that case is decided by the product and its policy, but external ads must not be called through a side path.&lt;/p&gt;
&lt;p&gt;Initialization being called twice has to be prevented as well. Initialization can be entered both right after the consent refresh and from the completion callback of the consent UI. Reusing the region task in progress, or the plan already built, reduces duplicate requests and races.&lt;/p&gt;
&lt;h2&gt;The server returns only a bucket&lt;/h2&gt;
&lt;p&gt;The location headers that a CDN adds are based on the IP address the server observed. &lt;a href=&quot;https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html&quot;&gt;CloudFront request headers&lt;/a&gt; are one example. The city value can be missing, and names that are not ASCII can arrive encoded. If every app parses raw city values and keeps its own policy, the work is duplicated, so the server builds the region bucket and sends that down.&lt;/p&gt;
&lt;p&gt;There are three buckets. &lt;code&gt;regionalMarket&lt;/code&gt; is a candidate for trying the regional provider first, &lt;code&gt;otherMarket&lt;/code&gt; is the target of the global provider, and &lt;code&gt;unknown&lt;/code&gt; means the region could not be classified. The response does not include coordinates, the IP address, or the city name. If branching by city is truly required, only an identifier used by the business rule is sent. A missing header, a decoding failure, or a validation failure gives &lt;code&gt;unknown&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The CDN cache needs attention too. If the response varies by region but the header is not in the cache key, the result for one region can be reused for another. Conversely, splitting the key too finely lowers the cache hit rate. The policy states either that the response is not cached, or that it is cached by the coarse bucket the server built.&lt;/p&gt;
&lt;h2&gt;Separate region resolution from ad loading&lt;/h2&gt;
&lt;p&gt;The region resolver has no dependency on an ad SDK, and the ad loader has no dependency on the IP address or the Locale. The two are connected only through enums.&lt;/p&gt;
&lt;p&gt;This example checks the consent state first and returns an array of provider candidates for the region bucket. The function does not load ads. It only builds the array.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enum ConsentState {
    case pending
    case adsAllowed
    case externalAdsBlocked
}

enum AdRegion {
    case regionalMarket
    case otherMarket
    case unknown
}

enum AdProvider {
    case regional
    case global
    case inHouse
}

func makeAdPlan(
    consent: ConsentState,
    resolveRegion: () async -&amp;gt; AdRegion
) async -&amp;gt; [AdProvider] {
    switch consent {
    case .pending, .externalAdsBlocked:
        return [.inHouse]
    case .adsAllowed:
        break
    }

    switch await resolveRegion() {
    case .regionalMarket:
        return [.regional, .global, .inHouse]
    case .otherMarket, .unknown:
        return [.global, .inHouse]
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The example turns one policy into code. Returning &lt;code&gt;[.inHouse]&lt;/code&gt; when consent is not finished or is blocked, and trying the global provider for &lt;code&gt;unknown&lt;/code&gt;, are not general rules. Whether to try the global provider for &lt;code&gt;unknown&lt;/code&gt; depends on the regions the service supports and on the consent conditions. That choice is not hidden inside the resolver. It is made visible in the chain policy.&lt;/p&gt;
&lt;p&gt;The function itself has no guard against running twice, no network timeout, and no loader. Running once and caching the result is implemented by the caller, or by the resolver reusing its task. The loader tries the array in order and stops at the first success. It does not call a failed provider again right away, and it does not restart the whole chain. &lt;code&gt;inHouse&lt;/code&gt; is the last option that keeps the slot from being empty when every external request fails. When a provider is added, only the chain changes and the resolver is left alone.&lt;/p&gt;
&lt;p&gt;iOS, Android, and the web can share the contract of buckets and priority. The implementation modules do not have to be merged. Each platform uses its own SDK and keeps only the contract &lt;code&gt;ConsentState → AdRegion → [AdProvider]&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;What running the example function showed&lt;/h2&gt;
&lt;p&gt;I compiled &lt;code&gt;makeAdPlan&lt;/code&gt; above as it is and ran every combination of the three consent states and the three region buckets. It ran on macOS 27.0.1 with Swift 6.4, on October 4, 2026. The run also prints how many times the closure that returns the region was called.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pending + regionalMarket -&amp;gt; [geodemo.AdProvider.inHouse] resolveRegion calls=0
pending + otherMarket -&amp;gt; [geodemo.AdProvider.inHouse] resolveRegion calls=0
pending + unknown -&amp;gt; [geodemo.AdProvider.inHouse] resolveRegion calls=0
adsAllowed + regionalMarket -&amp;gt; [geodemo.AdProvider.regional, geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
adsAllowed + otherMarket -&amp;gt; [geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
adsAllowed + unknown -&amp;gt; [geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
externalAdsBlocked + regionalMarket -&amp;gt; [geodemo.AdProvider.inHouse] resolveRegion calls=0
externalAdsBlocked + otherMarket -&amp;gt; [geodemo.AdProvider.inHouse] resolveRegion calls=0
externalAdsBlocked + unknown -&amp;gt; [geodemo.AdProvider.inHouse] resolveRegion calls=0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;geodemo&lt;/code&gt; is the module name of the executable. With consent &lt;code&gt;pending&lt;/code&gt;, or with external ads blocked, the region lookup never happened and only the in-house provider came out. The lookup happened once only when ads were allowed, and the regional provider came first only for the regional bucket.&lt;/p&gt;
&lt;p&gt;What this run confirmed stops at the function that decides the order. A real GeoIP lookup, an ad SDK, the loader that tries providers in order, the timeout, and the cache were not run.&lt;/p&gt;
&lt;h2&gt;Keep going when the lookup fails&lt;/h2&gt;
&lt;p&gt;The network request for the region lookup is restricted.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Only the specified HTTPS target is allowed, and unexpected redirects are blocked.&lt;/li&gt;
&lt;li&gt;The timeout is a few seconds at most, and the request is not repeated while the screen is rendering.&lt;/li&gt;
&lt;li&gt;The response size is limited, and only bucket values on an allow list are accepted.&lt;/li&gt;
&lt;li&gt;A response that does not fit is treated as &lt;code&gt;unknown&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The result and the request in progress are reused within the process.&lt;/li&gt;
&lt;li&gt;The raw IP address and city are not stored on disk and not written to analytics logs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An HTTP error, a timeout, being offline, and a parsing error are all handled as ordinary failure values. Concrete numbers for the timeout and the response size are not set here.&lt;/p&gt;
&lt;p&gt;The in-memory cache has a limit. After the network changes, the previous bucket can still be used. I judged this acceptable for a coarse priority hint, and the region is evaluated again when the process next starts. No measurement that backs this judgment is included here. Until measurement shows that a long-lived cache is needed, permanent storage and expiry handling are not added.&lt;/p&gt;
&lt;h2&gt;No permission does not mean anonymous&lt;/h2&gt;
&lt;p&gt;Without the OS location API, the location permission prompt does not appear. But the GeoIP server and the CDN still process the IP address. Describing &quot;no permission needed&quot; as &quot;anonymous&quot; or &quot;no location data is handled&quot; is not accurate.&lt;/p&gt;
&lt;p&gt;The purpose, the retention, the transfer to third parties, and the relation to consent have to be reviewed against the real data flow. The app receives only the bucket, the server does not keep the IP address and location headers longer than needed, and the value used for routing is not used for a long-term profile. Collecting less and complying with the law or store policy are separate things. &lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/privacy&quot;&gt;Apple&apos;s privacy guidelines&lt;/a&gt; are a reference from this point of view.&lt;/p&gt;
&lt;h2&gt;What to pin with tests&lt;/h2&gt;
&lt;p&gt;Calling the real GeoIP service and ad SDK can give a different result on each run because of VPNs, network state, and ad inventory. Unit tests inject the region function and the loader and pin the expected values below.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If consent is &lt;code&gt;pending&lt;/code&gt;, there are 0 region lookups and in-house is chosen.&lt;/li&gt;
&lt;li&gt;If ads are allowed and the bucket is regional, the regional provider comes first.&lt;/li&gt;
&lt;li&gt;If the regional provider fails, the chain moves to global once.&lt;/li&gt;
&lt;li&gt;On a timeout or a response error, global is chosen through the &lt;code&gt;unknown&lt;/code&gt; path.&lt;/li&gt;
&lt;li&gt;If every external request fails, the chain ends with in-house.&lt;/li&gt;
&lt;li&gt;If the consent completion callback arrives twice, the lookup and the plan are each made once.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In an integrated environment, the conditions to check are offline, a slow response, a missing header, an encoded city value, a VPN, and Private Relay. The pass criteria are that only allowed provider combinations appear, that the flow ends within the time limit, and that raw sensitive values do not show up in logs. How often the real location is guessed correctly is not a pass condition.&lt;/p&gt;
&lt;p&gt;The goal of this design is that the app does not stop even when GeoIP is wrong. The expected values and the integration conditions above are the criteria for checking that goal. Of these, what a run confirmed is that the function that builds the plan produces only allowed combinations. Results that confirm a real lookup ends within the time limit, and how the loader behaves, are not in this post.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>iOS</category><category>Android</category><category>Advertising</category><category>Privacy</category><category>GeoIP</category></item><item><title>Keeping and revoking a one-time remove-ads purchase on iOS and Android</title><link>https://jaemyeong.com/en/blog/one-time-remove-ads-entitlement-storekit-play-billing/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/one-time-remove-ads-entitlement-storekit-play-billing/</guid><description>A remove-ads purchase looks final, but reinstalls, pending payments, and refunds make the purchase result alone unreliable. Read current entitlements instead.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I added a product that removes ads permanently to the iOS and Android versions of DailySudoku, a Sudoku app. At first it looked as if storing whether the purchase succeeded would be enough. But the result right after the purchase turned out to be a poor basis for judging the state later. The app can be reinstalled, a payment can stay pending, and a purchase can be refunded.&lt;/p&gt;
&lt;p&gt;The source baseline is commit 9c999305 on the develop branch as of August 6, 2026. That is separate from anything confirmed in a release on the stores. Below, the behavior of that source, what the Apple and Google documentation says, and what the automated tests cover are kept apart. That is as far as this post goes. Records of paying and refunding on real devices, and run logs, are not in it.&lt;/p&gt;
&lt;h2&gt;What is missed when only the purchase result is stored&lt;/h2&gt;
&lt;p&gt;A purchase notification is only one of the inputs. Ownership is refreshed by reading the current list of entitlements. Storing only the purchase result misses the following.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The user canceled the payment.&lt;/li&gt;
&lt;li&gt;A pending payment completed after the app was closed.&lt;/li&gt;
&lt;li&gt;The purchase was made on another device.&lt;/li&gt;
&lt;li&gt;The data was reset or the app was newly installed.&lt;/li&gt;
&lt;li&gt;A refund, a cancellation, or a revoke happened.&lt;/li&gt;
&lt;li&gt;The connection dropped before the result arrived.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So three inputs are merged into one entitlement state.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;구매 이벤트 ───────┐
앱 시작·복귀 조회 ─┼─&amp;gt; 검증된 현재 권한 ─&amp;gt; 로컬 캐시 ─&amp;gt; 광고 게이트
스토어 외부 변경 ──┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From top to bottom, the three inputs on the left are the purchase event, the query at app start and return, and a change made outside the app in the store. They lead to the verified current entitlement, then the local cache, then the ad gate.&lt;/p&gt;
&lt;p&gt;Each input has its own job. The purchase notification is used to tell the user right away. The query at app start and on return to the foreground recovers missed changes and changes from other devices. Updates carry changes while the app is running. All three end up sharing the same &lt;code&gt;adFree&lt;/code&gt; value.&lt;/p&gt;
&lt;h2&gt;The local value is a cache&lt;/h2&gt;
&lt;p&gt;The Boolean stored on the device is a cache for drawing the screen, not the source of truth for ownership. Both the snapshot and the updates write true and false as they are. After a refund, false goes in and ads are allowed again.&lt;/p&gt;
&lt;p&gt;There is one ambiguity here. &lt;code&gt;adFree == false&lt;/code&gt; can mean &quot;not owned,&quot; and it can also mean &quot;not queried yet.&quot; If the two cases are not told apart, a user who bought the product can see an ad right after launch. So whether the query has finished is marked separately with &lt;code&gt;entitlementsSynced&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The condition that allows ads is computed from three values.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;adsEnabled = entitlementsSynced &amp;amp;&amp;amp; !adFree &amp;amp;&amp;amp; consentAllowsAds
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;While &lt;code&gt;entitlementsSynced == false&lt;/code&gt;, no ad is requested. A user who has not bought enters the ad path after the first restore finishes, and a user who has bought stays blocked because &lt;code&gt;adFree == true&lt;/code&gt;. The settings, home, difficulty selection, game, and statistics screens share this decision.&lt;/p&gt;
&lt;p&gt;How the ad objects are handled differs by platform. When blocked, the Android side does not create the Compose subtree at all and releases the native view that was attached. On iOS, the banner is collapsed and further loads are suppressed. Whether an already loaded provider is torn down on iOS is not verified by automated tests. The ad disappearing from the screen is not enough to say that the SDK has stopped its background work.&lt;/p&gt;
&lt;h2&gt;iOS: what is read from StoreKit 2&lt;/h2&gt;
&lt;p&gt;A purchase is handled according to the result of &lt;a href=&quot;https://developer.apple.com/documentation/storekit/product/purchase(options:)&quot;&gt;Product.purchase()&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For &lt;code&gt;.success(.verified(transaction))&lt;/code&gt;, the entitlement is granted and &lt;a href=&quot;https://developer.apple.com/documentation/storekit/finishing-a-transaction&quot;&gt;the transaction is finished&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;For &lt;code&gt;.success(.unverified(...))&lt;/code&gt;, the entitlement is not granted.&lt;/li&gt;
&lt;li&gt;For &lt;code&gt;.pending&lt;/code&gt; and &lt;code&gt;.userCancelled&lt;/code&gt;, it is not granted either.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Changes while running are observed through &lt;a href=&quot;https://developer.apple.com/documentation/storekit/transaction/updates&quot;&gt;Transaction.updates&lt;/a&gt;. Ask to Buy, a purchase on another device, and a revoke arrive there. At app start and on return to the foreground, &lt;a href=&quot;https://developer.apple.com/documentation/storekit/transaction/currententitlements&quot;&gt;Transaction.currentEntitlements&lt;/a&gt; is checked for the product ID of the remove-ads product and for a verified result. According to the documentation, this list holds the latest entitlement for a non-consumable, and items that were refunded or revoked are left out. If the query finishes with no matching product, false is stored.&lt;/p&gt;
&lt;p&gt;On a reinstall or a new device, the latest transaction is provided automatically, so in normal use the &lt;code&gt;currentEntitlements&lt;/code&gt; query is enough. A manual restore runs only when the user chooses restore in settings. At that point &lt;a href=&quot;https://developer.apple.com/documentation/storekit/appstore/sync()&quot;&gt;AppStore.sync()&lt;/a&gt; is called and the entitlements are queried again. That call can show an authentication prompt, so it is not run automatically at app start.&lt;/p&gt;
&lt;p&gt;The price is refreshed separately. The ownership flow and the consent flow do not need to wait for the price response.&lt;/p&gt;
&lt;h2&gt;Android: what is read from Play Billing&lt;/h2&gt;
&lt;p&gt;The product type on Android is INAPP. &lt;a href=&quot;https://developer.android.com/google/play/billing/one-time-products&quot;&gt;One-time products&lt;/a&gt; have no separate API for non-consumables, so the purchase is acknowledged and not consumed.&lt;/p&gt;
&lt;p&gt;The flow follows &lt;a href=&quot;https://developer.android.com/google/play/billing/integrate&quot;&gt;the Play Billing integration guide&lt;/a&gt;. &lt;code&gt;queryProductDetailsAsync&lt;/code&gt; returns the products that can be sold to that user and the localized price, and &lt;code&gt;launchBillingFlow&lt;/code&gt; is called with the returned &lt;code&gt;ProductDetails&lt;/code&gt; and the offer token. The purchase arrives through &lt;code&gt;PurchasesUpdatedListener&lt;/code&gt; or is found by a later &lt;code&gt;queryPurchasesAsync&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The entitlement is granted only when the product ID and the purchase state are checked and the state is &lt;code&gt;PURCHASED&lt;/code&gt;, and since the purchase is not consumed, it is acknowledged. &lt;code&gt;PENDING&lt;/code&gt; is not a confirmed failure. It is a state waiting for completion, such as a cash payment or an additional approval. Google&apos;s guidance is not to grant the benefit while the state is &lt;code&gt;PENDING&lt;/code&gt; and to handle it after it turns &lt;code&gt;PURCHASED&lt;/code&gt;. The state can change while the app is closed, so &lt;code&gt;queryPurchasesAsync()&lt;/code&gt; in &lt;code&gt;onResume()&lt;/code&gt; recovers missed changes.&lt;/p&gt;
&lt;p&gt;Verification and acknowledgement are different jobs. Verification checks that the purchase is legitimate and has not been handled already. Acknowledgement tells Play that the benefit was delivered. According to the documentation, a normal purchase can be refunded automatically if it is not acknowledged within 3 days after turning &lt;code&gt;PURCHASED&lt;/code&gt;, and for a purchase by a license tester that window is 3 minutes.&lt;/p&gt;
&lt;p&gt;What the current implementation trusts is narrow. It checks &lt;code&gt;PURCHASED&lt;/code&gt; and the product ID as reported by BillingClient, and acknowledges on the client. It does not verify the purchase token on a server. &lt;a href=&quot;https://developer.android.com/google/play/billing/security&quot;&gt;Google&apos;s billing security guide&lt;/a&gt; recommends verifying with the Google Play Developer API on a secure backend before granting the benefit, and acknowledging on the server as well. For now the setup accepts trusting the client, and it cannot be described as having completed server verification.&lt;/p&gt;
&lt;h2&gt;Restore, refunds, and failed queries&lt;/h2&gt;
&lt;p&gt;The purpose of a restore is not to find out whether something was bought in the past. It is to make the cache match the current snapshot. At start and on return, the Android side queries with &lt;code&gt;queryPurchasesAsync(INAPP)&lt;/code&gt;, and changes while running arrive through the listener or are recovered by the query on the next resume. A manual restore also queries the current inventory again.&lt;/p&gt;
&lt;p&gt;The moment a refund takes effect differs by platform. On iOS it is reflected in an update or a later snapshot. On Android the value becomes false when the next inventory query succeeds.&lt;/p&gt;
&lt;p&gt;An empty result and an error have to be told apart. The product not being there and the store being unreachable are different events. Right now, when the connection or the inventory query fails on Android, &lt;code&gt;adFree&lt;/code&gt; is left as it is and &lt;code&gt;entitlementsSynced = true&lt;/code&gt; is set. This policy has a cost in both directions. A buyer who reinstalled has a cache of false and can see ads until the state is recovered. A user who got a refund can stay blocked from ads until a query succeeds.&lt;/p&gt;
&lt;p&gt;The iOS model has a gap too. With no verified matching item the value becomes false, but an unverified case and a temporarily empty false negative are not classified separately. The failure policies of the two platforms differ, and neither is a guarantee of safety.&lt;/p&gt;
&lt;p&gt;The improvement I have in mind is to split the state into &lt;code&gt;unknown&lt;/code&gt;, &lt;code&gt;owned&lt;/code&gt;, and &lt;code&gt;notOwned&lt;/code&gt;, or to tell an error apart from a successful empty result. On top of that, each product decides between fail-open and fail-closed. This is still a proposal, not a fix that has been made.&lt;/p&gt;
&lt;p&gt;Restores that run at the same time need checking as well. Five asynchronous paths write to the cache: purchase, update, start, resume, and manual restore. The issue is the order in which the start query and the foreground query finish, and locking the connection alone does not serialize the whole reconcile. A past snapshot that arrives late can overwrite a newer value.&lt;/p&gt;
&lt;h2&gt;When the price cannot be fetched&lt;/h2&gt;
&lt;p&gt;The price shown is the localized value from the store, and no fixed price is written on the screen. On both platforms, the settings screen hides the remove-ads row when the price is nil. But the manual restore control is on the screen opened from that row.&lt;/p&gt;
&lt;p&gt;As a result, when the price query fails, the entry point for retrying by hand disappears. Automatic recovery at start and resume keeps running. The screen also cannot tell a product that is not registered from a temporary network failure.&lt;/p&gt;
&lt;p&gt;The improvement is to disable only the buy button and show restore separately, and to tell an empty product response from an exception in retries and in monitoring. This too is a proposal.&lt;/p&gt;
&lt;h2&gt;Ways to test and expected results&lt;/h2&gt;
&lt;p&gt;On the Apple side, &lt;a href=&quot;https://developer.apple.com/documentation/storekit/testing-in-app-purchases-in-xcode&quot;&gt;StoreKit configuration in Xcode&lt;/a&gt; allows local tests of transactions, interrupted purchases, refunds, and revokes. Flows that involve an account are checked in Sandbox or TestFlight.&lt;/p&gt;
&lt;p&gt;With a &lt;a href=&quot;https://developer.android.com/google/play/billing/test&quot;&gt;license tester&lt;/a&gt;, the real purchase flow and test payment methods can be exercised on Android. If the package name and the tester account conditions are met, a debug-signed sideloaded build can be tested too, and the test tracks are for checking before release. It is not correct to say flatly that a direct ADB install always fails or that only an install from Play works.&lt;/p&gt;
&lt;p&gt;Below are the results I intend to confirm. They are not a record of runs that passed.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New purchase: ad requests stop, the state holds after a relaunch, and the product cannot be bought again.&lt;/li&gt;
&lt;li&gt;User cancellation: no entitlement and no success message, and the purchase can be tried again.&lt;/li&gt;
&lt;li&gt;Approval after pending: no benefit while pending, and one grant after &lt;code&gt;PURCHASED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Cancellation after pending: no benefit is granted.&lt;/li&gt;
&lt;li&gt;Already owned: ownership is recovered on a new device, after a reinstall, and by manual restore.&lt;/li&gt;
&lt;li&gt;Completed outside the app: recovered by an update or the next resume.&lt;/li&gt;
&lt;li&gt;Lost connection: a successful empty response and a query error are told apart.&lt;/li&gt;
&lt;li&gt;Failed acknowledgement: retried and completed within the allowed window.&lt;/li&gt;
&lt;li&gt;Refund and revoke: the cache becomes false and ads are allowed again.&lt;/li&gt;
&lt;li&gt;Fast app switching: repeated background and foreground does not let a past query overwrite the value.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The automated tests cover little. On iOS they cover storing false on a restore with nothing owned, and the price refresh. A hosted unit test cannot cover the UI path of a real successful purchase. The purchase and restore in &lt;code&gt;PlayBillingProvider&lt;/code&gt;, the pending notice, a failed acknowledgement, the Settings row, and the full truth table of the ad gate are missing from the automated tests on the Android side. Passing local tests is not enough to say that real store payments were confirmed.&lt;/p&gt;
&lt;p&gt;What remains is server verification on Android, the screen for the pending state, the error model, concurrent reconcile, the restore entry point, and checking the store scenarios on real devices. The 3-day and 3-minute acknowledgement windows, the offer token condition, and the eligibility of a debug-signed sideload are what the documentation said on the baseline date, so they have to be checked again in the official documentation when a policy or a library changes.&lt;/p&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>Android</category><category>StoreKit</category><category>Google Play Billing</category><category>In-App Purchase</category></item><item><title>Verifying a verification script with deliberately broken input</title><link>https://jaemyeong.com/en/blog/verification-script-negative-control/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/verification-script-negative-control/</guid><description>A check script that exits with status 0 has not necessarily checked the rule. Break one thing in a copy of a good sample and confirm the expected category.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Automated checks before a release can all pass while the output is still missing a required file, or holds the same content for locales that should differ. This happens with scripts that check the resources and metadata of a cross-platform app. The checks ran to the end, yet the rule they were meant to protect was never examined.&lt;/p&gt;
&lt;p&gt;Whether a check script really catches a defect only shows when it is given input that has one. This post goes through putting a single defect into a copy of a good sample and confirming that the check fails with the expected category. Combinations of platform builds and tests are out of scope. I ran the example with a small validator on Python 3.14.5, and the result is written below the example.&lt;/p&gt;
&lt;h2&gt;What exit status 0 does not tell you&lt;/h2&gt;
&lt;p&gt;There are several ways for a check to skip the rule and still end in success.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A glob does not match, so there are 0 targets.&lt;/li&gt;
&lt;li&gt;A missing tool or environment variable is treated as success.&lt;/li&gt;
&lt;li&gt;The generating side and the checking side share the same wrong default.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In each case the exit status can be 0, so the value 0 alone cannot tell whether the rule was checked. &lt;a href=&quot;https://docs.pytest.org/en/9.0.x/reference/exit-codes.html&quot;&gt;The pytest exit codes&lt;/a&gt; separate these two as well. The code is 0 when all collected tests pass, and 5 when no tests were collected.&lt;/p&gt;
&lt;p&gt;I suggest making the same distinction in a check script you write yourself. Failing to find a required output is a problem with the input contract, and a missing tool or input in a required CI job is a problem with the setup. Skipping an optional check is a separate state with the reason recorded. The candidate states are pass, validation-failure, invalid-setup, and skip. These four are my own design, not something the pytest documentation defines.&lt;/p&gt;
&lt;p&gt;Judging a failure needs more information than the exit status.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;category&lt;/code&gt; or &lt;code&gt;rule_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;The number of inputs processed and the number of required checks&lt;/li&gt;
&lt;li&gt;Exit statuses that separate environment errors from contract violations&lt;/li&gt;
&lt;li&gt;A distinction between an empty target set and an allowed skip&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the only requirement is &quot;it exited abnormally,&quot; a syntax error or a permission problem can be miscounted as catching the defect. For a sample that breaks the image size rule, the expected category is &lt;code&gt;wrong_size&lt;/code&gt;. An &lt;code&gt;internal_error&lt;/code&gt; caused by failing to read the file is not evidence that the rule worked. CI compares against a stable category value, and the descriptive message is for investigating the cause.&lt;/p&gt;
&lt;h2&gt;A sample with exactly one defect&lt;/h2&gt;
&lt;p&gt;In this post, a negative control means a defective sample that has what it takes to be rejected. The term is not carried over as defined in statistics or experimental fields. Put the disallowed value &lt;code&gt;dark&lt;/code&gt; into a copy of the good data and require &lt;code&gt;invalid_value&lt;/code&gt;, and everything from reading the input to reporting the error is checked in one go.&lt;/p&gt;
&lt;p&gt;A sample is made by copying a reviewed fixture to a temporary location, changing it, and discarding it afterwards. The files that actually ship are not touched. If good data is rejected, or defective data is accepted, the check has not met the conditions for being trusted.&lt;/p&gt;
&lt;p&gt;Putting a contract violation into a good sample is what this post calls a mutation. Each run changes one rule only. Examples are inserting the disallowed value &lt;code&gt;dark&lt;/code&gt;, deleting a required file, making the content of two locales identical, and changing the size information of an image. With several defects at once it is hard to tell which one was caught, and a tool that stops at the first failure never reaches the later rules. So one mutation is paired with one expected category.&lt;/p&gt;
&lt;p&gt;Tools such as &lt;a href=&quot;https://stryker-mutator.io/docs/&quot;&gt;Stryker&lt;/a&gt; and PIT also use the word mutation. They change the program code and watch whether the tests react. In &lt;a href=&quot;https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/&quot;&gt;Stryker&apos;s description of states&lt;/a&gt;, a mutant is killed when at least one test fails and survived when all tests pass. &lt;a href=&quot;https://pitest.org/quickstart/basic_concepts/&quot;&gt;PIT&apos;s basic concepts&lt;/a&gt; describe equivalent mutations, changes that do not alter behavior. Tests cannot detect those. The same holds when input is changed by hand, so the cases chosen have to really break the contract. Up to here the content is from the documentation, and carrying the idea over to checks on input files is my own reading. The idea of trusting a test because it was seen to fail is also in &lt;a href=&quot;https://testing.googleblog.com/2007/04/tott-refactoring-tests-in-red.html&quot;&gt;a Google Testing Blog post&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;An example that confirms the baseline first&lt;/h2&gt;
&lt;p&gt;Before any defective sample goes in, the good sample has to pass. The exit status must be 0 with 0 failures. Without this condition, an abnormal exit caused by a defect that was already there can be counted as catching the new one.&lt;/p&gt;
&lt;p&gt;The example assumes that the JSON result of the validator contains the &lt;code&gt;category&lt;/code&gt; of each entry in &lt;code&gt;failures&lt;/code&gt;, and &lt;code&gt;checked&lt;/code&gt;. Exit status 0 is a pass, 1 is a contract violation, and anything else is a setup or internal problem. There are 4 input contracts. The first part is the function that runs the validator and reads the result, followed by the check of the good sample.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json
import shutil
import subprocess
import tempfile
from pathlib import Path

FIXTURE = Path(&quot;validator-fixture&quot;)
PASS = 0
VALIDATION_FAILED = 1
REQUIRED_CHECKS = 4


def validate(root: Path) -&amp;gt; tuple[int, set[str], int]:
    result = subprocess.run(
        [&quot;python&quot;, &quot;validate.py&quot;, str(root), &quot;--json&quot;],
        check=False,
        capture_output=True,
        text=True,
    )
    report = json.loads(result.stdout)
    failures = {item[&quot;category&quot;] for item in report[&quot;failures&quot;]}
    return result.returncode, failures, report[&quot;checked&quot;]


baseline_code, baseline_failures, baseline_checked = validate(FIXTURE)
if baseline_code != PASS or baseline_failures or baseline_checked != REQUIRED_CHECKS:
    raise SystemExit(
        f&quot;invalid baseline: checked={baseline_checked}, &quot;
        f&quot;failures={sorted(baseline_failures)}&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The next part applies each of the four mutations to a copy in a fresh temporary directory. It uses the names from the block above.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cases = [
    (&quot;invalid-value&quot;, &quot;invalid_value&quot;),
    (&quot;missing-file&quot;, &quot;missing_file&quot;),
    (&quot;duplicate-content&quot;, &quot;duplicate_content&quot;),
    (&quot;wrong-size&quot;, &quot;wrong_size&quot;),
]
if not cases:
    raise SystemExit(&quot;no mutation cases&quot;)

for mutation, expected_category in cases:
    with tempfile.TemporaryDirectory() as temp:
        root = Path(temp) / &quot;fixture&quot;
        shutil.copytree(FIXTURE, root)
        subprocess.run([&quot;python&quot;, &quot;mutate.py&quot;, mutation, str(root)], check=True)

        code, failures, checked = validate(root)
        introduced_failures = failures - baseline_failures

        if code != VALIDATION_FAILED or checked == 0 or expected_category not in introduced_failures:
            raise SystemExit(
                f&quot;survived mutation: {mutation}, checked={checked}, &quot;
                f&quot;failures={sorted(failures)}&quot;
            )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the list of mutation cases is empty, the script fails before it enters the loop. The failures after the mutation, minus the failures of the baseline, are the newly introduced failures. A case passes when the exit status is 1, the number of checks is not 0, and the introduced failures contain the expected category. In this example the baseline failures have to be empty to get this far, so the set difference equals the set of failures after the mutation. A case still passes when other categories appear alongside the expected one.&lt;/p&gt;
&lt;p&gt;When a condition is not met, the script raises &lt;code&gt;SystemExit&lt;/code&gt;. It does not use &lt;code&gt;assert&lt;/code&gt;, because &lt;a href=&quot;https://docs.python.org/3/using/cmdline.html#cmdoption-O&quot;&gt;Python&apos;s &lt;code&gt;-O&lt;/code&gt; option&lt;/a&gt; removes assert statements. The CI decision is made with explicit condition branches.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;validate.py&lt;/code&gt;, &lt;code&gt;mutate.py&lt;/code&gt;, and the fixture itself are not in this post. For the run I wrote a small separate validator with four rules. With the normal validator, all four mutations were detected. When I changed the validator to report the expected category while performing 0 checks on a mutated copy, the version without the &lt;code&gt;checked == 0&lt;/code&gt; condition ended as a pass, and the version with it ended with &lt;code&gt;survived mutation&lt;/code&gt;. That condition and the check for an empty list were added to the original example. Handling of JSON that cannot be parsed, and separate handling of environment errors, are not in the example either.&lt;/p&gt;
&lt;h2&gt;Keeping the judgment apart from the generator&lt;/h2&gt;
&lt;p&gt;If the generating code and the checking code use the same decision function, or the fixture is built only from the latest generated output, the same error can be hidden on both sides. Sharing a neutral schema is fine. The criteria for allowed values, sizes, and uniqueness come from the written contract and from material a person has reviewed.&lt;/p&gt;
&lt;p&gt;In CI, I suggest keeping one defective sample per core rule and running them when the validator or the discovery code changes. The order is this.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Confirm that the tool and the fixture exist.&lt;/li&gt;
&lt;li&gt;Confirm the state of the good sample and the minimum number of checks.&lt;/li&gt;
&lt;li&gt;Apply one mutation to each copy.&lt;/li&gt;
&lt;li&gt;Confirm that the expected category was detected.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A required CI job fails when a tool or input is missing, when the number of tests is 0, when the number of checks is 0, or when there are 0 mutation cases. Only skipping an optional item is allowed, with a reason. When a defect goes undetected, look at how files are discovered, how errors are reported, and how the expected category is set. Keeping the baseline result and the expected and observed categories as CI artifacts makes that investigation easier.&lt;/p&gt;
&lt;p&gt;A few reviewed samples are enough to start. The example compares only the set of &lt;code&gt;category&lt;/code&gt; values, though, so it cannot tell apart several failures of the same category in different places. When the location of a failure matters, use the combination of &lt;code&gt;rule_id&lt;/code&gt; and a normalized relative path. The CI procedure looks at whether the number of checks is at least a minimum, while the example checks that the number for the good sample equals 4. The number of checks on a mutated copy is only tested for being 0 and is not compared with a minimum. A change that does not alter behavior cannot be detected by this method either.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>Testing</category><category>CI-CD</category><category>Automation</category><category>Troubleshooting</category></item><item><title>Who watches the watcher: finding a scheduled job that vanished</title><link>https://jaemyeong.com/en/blog/watch-the-watcher-retry-cron-reconciliation/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/watch-the-watcher-retry-cron-reconciliation/</guid><description>A deleted scheduled job leaves no error. Compare the expected roles with what the scheduler really holds, and alert on the time of the last success.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;While checking the automation of an app, I once found the records and reality out of step. The job ledger said a retry job had been registered, but querying the scheduler returned only one gate poller. The gate kept doing its work, so the missing retry did not show. There was not enough evidence to pin down when or why it had been deleted.&lt;/p&gt;
&lt;p&gt;A scheduled job that has been deleted cannot report its own failure. This post goes through a state comparison for finding that kind of gap, and a setup in which the monitoring does not stop together with what it monitors. Cron here is not only the Unix crontab. It also covers the GitHub Actions schedule and the Kubernetes CronJob. The original query output and logs, and measurements after a repair, are not included. The versions of the scheduler and tools used at the time, and the environment they ran in, were not recorded either.&lt;/p&gt;
&lt;h2&gt;Zero errors is not zero runs&lt;/h2&gt;
&lt;p&gt;Different kinds of failure leave different traces.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An error during a run leaves a start record, the error, and a failure count, and the job can retry from the inside.&lt;/li&gt;
&lt;li&gt;A run that stalls partway shows up as a start record with a stale heartbeat. Whether it can recover from the inside depends on conditions.&lt;/li&gt;
&lt;li&gt;An object that was made inactive is still in the scheduler but cannot recover from the inside.&lt;/li&gt;
&lt;li&gt;A deleted object, or a stopped scheduler, sends no report from the inside at all, and cannot recover from the inside either.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So zero errors alone cannot be read as healthy. The possibility of zero runs has to be ruled out first.&lt;/p&gt;
&lt;h2&gt;Roles in the ledger, real objects in the query result&lt;/h2&gt;
&lt;p&gt;An ID written in the ledger is only a reference for looking things up. It is not evidence that the object is registered now. The desired state holds the role, the schedule, and the command, and the actual state holds the objects that were returned and whether they are active. Recreating an object can change its ID, so the comparison uses role keys such as &lt;code&gt;retry&lt;/code&gt; and &lt;code&gt;gate&lt;/code&gt;. Putting a version on the schedule and on the run template also helps detect outdated settings. The pseudocode for comparing the sets of roles is this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;expected = desired_jobs_by_role
actual = scheduler.list()

missing    = expected.roles - actual.roles
duplicates = actual.group_by(role).where(count &amp;gt; 1)
mismatched = actual.where(spec_version != expected.spec_version)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It states what the comparison means, and a result of running it is not in this post. The pseudocode alone misses two cases. First, comparing only the version does not catch someone editing the schedule or the command without bumping the version. The actual schedule and command, or a hash of the whole desired specification, have to be compared for each role. Second, an object whose role is not in the expected list is neither missing nor a duplicate, so it would stay. &lt;code&gt;actual.roles - expected.roles&lt;/code&gt; needs its own category, with an alert to investigate ownership and no automatic deletion.&lt;/p&gt;
&lt;p&gt;One desired state is matched against four observed stages.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;desired: the expected roles in a versioned specification&lt;/li&gt;
&lt;li&gt;registered: whether the real object exists, and its enabled or suspend state&lt;/li&gt;
&lt;li&gt;executed: the last start time and the run history&lt;/li&gt;
&lt;li&gt;succeeded: a last success within the time limit&lt;/li&gt;
&lt;li&gt;progressed: a checkpoint, the queue depth, the completion time&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each stage tells you something different. According to &lt;a href=&quot;https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/&quot;&gt;the Kubernetes CronJob documentation&lt;/a&gt;, when &lt;code&gt;.spec.suspend&lt;/code&gt; is true, later runs stop even though the object exists. A run can also stall or fail after it starts. Even when the success time is recent, only a checkpoint tells whether the input was empty or a condition was wrong. I suggest obtaining the list of real objects and the last success time first, and widening to business metrics when the need comes up.&lt;/p&gt;
&lt;h2&gt;What monitoring inside the same scheduler cannot see&lt;/h2&gt;
&lt;p&gt;The structure in the case I first saw had a flaw. The relation ran one way, with retry repairing the other poller, while that poller only did its own work. If retry is deleted, nothing is left to do the repair. Making the two check each other does not help against a failure that hits both in the same scheduler.&lt;/p&gt;
&lt;p&gt;Comparing when a session restarts or when settings change provides a basic safeguard. It leaves a gap between one check and the next, though. When detection has to be continuous, an outside dead-man monitor checks the time of the last success. The outside monitor and the delivery of its alerts are things to check as well. The aim is to run the monitoring and what it monitors on separate foundations, so that one stopped scheduler does not stop both.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://sre.google/sre-book/monitoring-distributed-systems/&quot;&gt;The monitoring chapter of the Google SRE book&lt;/a&gt; calls monitoring based on metrics exposed by the internals of a system white-box, and testing externally visible behavior as a user would see it black-box. Seen this way, the internal list, the run history, and the heartbeat are white-box. My reading is that it is black-box only when a separate system directly checks the end result from the user&apos;s point of view, and that installing the monitor outside does not by itself make it black-box.&lt;/p&gt;
&lt;h2&gt;The order for comparing and repairing&lt;/h2&gt;
&lt;p&gt;The repair is split into five steps.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;observe → classify → re-read → repair → verify
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Query the list and the active state, then classify the result as missing, duplicate, inactive, drift, or unexpected, which is a role that is not in the expected list. Query once more to filter out races and temporary delays. After that, create only what is missing from a version-controlled literal template, check the role, the schedule, the command, and the spec version again, and update the ID in the ledger. The goal is one object per expected role. Several objects with the same role raise a duplicate alert, and a role that is not in the expected list raises an unexpected alert. Either way, an object whose ownership is unknown is not deleted.&lt;/p&gt;
&lt;p&gt;Automatic repair is limited to missing objects whose ownership and specification are certain. The other states are handled like this.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;inactive: check the change history, then activate it explicitly.&lt;/li&gt;
&lt;li&gt;duplicate: alert on the risk of concurrent runs, and have a person look into the ownership.&lt;/li&gt;
&lt;li&gt;drift: check the expected version and who made the change.&lt;/li&gt;
&lt;li&gt;unexpected: have a person find out who created it and why. It is not deleted automatically.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An inactive state or a drift that someone made on purpose must not be overwritten. This comparison runs when a process starts or resumes, and right after settings change. When detection has to be faster, add a periodic check from outside.&lt;/p&gt;
&lt;h2&gt;Alerting on the time of the last success&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://prometheus.io/docs/practices/instrumentation/&quot;&gt;The Prometheus instrumentation guide&lt;/a&gt; says the key metric of a batch job is the last time it succeeded. The same document says that to track the time since something happened, you export the Unix timestamp at which it happened, not the time since. The elapsed time is calculated at query time. The condition for whether the time since the last success of retry has passed the threshold is this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;time() - job_last_success_timestamp_seconds{job=&quot;retry&quot;} &amp;gt; stale_after_seconds
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As a starting threshold I suggest twice the run interval plus the longest expected run time. &lt;a href=&quot;https://prometheus.io/docs/practices/alerting/&quot;&gt;The Prometheus alerting guide&lt;/a&gt; also says to page when a batch job has not succeeded recently enough, and that this should generally be at least enough time for 2 full runs. The value is not a number settled by measurement. It is adjusted by how much the run time varies and how much detection delay is acceptable. A normal success that comes one interval late has its alert held back within a grace window, and a run that passes its timeout after starting is classified as a stale execution or a hang.&lt;/p&gt;
&lt;p&gt;The case where the series does not exist at all is looked at separately. &lt;a href=&quot;https://prometheus.io/docs/prometheus/latest/querying/functions/&quot;&gt;The PromQL functions reference&lt;/a&gt; has &lt;code&gt;absent()&lt;/code&gt; and &lt;code&gt;absent_over_time()&lt;/code&gt;. According to &lt;a href=&quot;https://prometheus.io/docs/practices/pushing/&quot;&gt;the Pushgateway guide&lt;/a&gt;, the Pushgateway does not remove the series it received on its own. It keeps exposing them until they are deleted by hand, so a stale success value can remain. That is why a missing series and a stale success time are told apart.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When the object is missing, look at registration, the comparison process, and the real list.&lt;/li&gt;
&lt;li&gt;When the success metric is missing, look at instrumentation, collection, the first run, and scrape or push.&lt;/li&gt;
&lt;li&gt;When the success time is stale, look for delay, failure, or a stall in the history and logs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Where to look also depends on the observed stage.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;With no recent start, look at the trigger and the scheduler.&lt;/li&gt;
&lt;li&gt;With a start and no success, look at the logs and the timeout.&lt;/li&gt;
&lt;li&gt;With a recent success and a checkpoint that has stopped moving, look at the input and the business conditions.&lt;/li&gt;
&lt;li&gt;With no heartbeat from the watcher, look at the outside probe and the alerts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What a scheduler guarantees also differs by product. According to the Kubernetes documentation, a CronJob creates a Job about once per scheduled time. In some circumstances two Jobs are created, or none, so Jobs should be idempotent. &lt;code&gt;startingDeadlineSeconds&lt;/code&gt; and &lt;code&gt;concurrencyPolicy&lt;/code&gt; are settings that control delay and concurrent runs, and they do not guarantee exactly-once execution. According to &lt;a href=&quot;https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule&quot;&gt;the GitHub Actions schedule documentation&lt;/a&gt;, the schedule event can be delayed during periods of high load and queued jobs may be dropped, and it runs only when the workflow file is on the default branch, and only on that branch. Whatever the product, the things to look at in common are the object, the run history, and the success time.&lt;/p&gt;
&lt;p&gt;The part that compares and classifies sets can be checked with unit tests. Real delays, duplicates, and gaps are things to check in staging or in an isolated production check. Results of running them are not in this post. Why the first gap happened was not settled either. Comparing intermittently leaves a gap in detection, and the outside monitor and its alerts can fail too. There can be a delay before a newly created object shows up in a query. Querying again right after creation can still show it as missing, and a second object would then be created. To avoid a duplicate, wait with a bounded retry until the object is visible, or make creation idempotent by using the role key as a unique name.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>CI-CD</category><category>Automation</category><category>Observability</category><category>Reliability</category><category>Cron</category></item><item><title>Rust shared core, part 7: splitting tests and CI by layer</title><link>https://jaemyeong.com/en/blog/rust-shared-core-07-testing-ci/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/rust-shared-core-07-testing-ci/</guid><description>Passing Rust tests still leaves iOS linking, Android release ABIs, and Wasm loading in web builds to break. Test the domain in Rust and build fresh in CI.</description><pubDate>Sat, 25 Jul 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The goal of checking the common rules in Rust and having three clients consume the result is not only less code. The results have to match. Yet each platform has places that can still break after the Rust tests pass.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;iOS: the Rust tests pass, and linking fails.&lt;/li&gt;
&lt;li&gt;Android: the debug build works, and an ABI is missing from the release AAB.&lt;/li&gt;
&lt;li&gt;Web: the dev server works, and wasm loading fails in the production build.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These three are cases that can happen, not a record of incidents I went through. This part sorts out which layer&apos;s checks should catch such failures.&lt;/p&gt;
&lt;h2&gt;Domain checks belong in Rust&lt;/h2&gt;
&lt;p&gt;I recommend concentrating the checks on the rules in the Rust engine. For Sudoku, that covers puzzle generation, the correctness of the solver, invalid number input, the reducer for toggling notes, the reproducibility of the daily seed, difficulty classification, the serialization round trip, boundary values, and error conversion. I recommend setting them up to run with &lt;code&gt;cargo test&lt;/code&gt;, with no mobile simulator or emulator.&lt;/p&gt;
&lt;p&gt;It is good to pin the result for the same seed and the same sequence of actions. What can make results differ is the time, the locale, the source of randomness, and the time zone slipping in implicitly. So I suggest taking them as input: a timestamp parameter in place of reading the current time, a specified seed for randomness, and a locale code received from outside.&lt;/p&gt;
&lt;h2&gt;Shared fixtures and the responsibility of each layer&lt;/h2&gt;
&lt;p&gt;I suggest using JSON fixtures that hold input and expected values as the contract. These are examples of files that could be there.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fixtures/
  start-game-easy-seed-001.json
  apply-action-set-value-valid.json
  apply-action-set-value-conflict.json
  daily-puzzle-2026-06-24.json
  completed-game-score.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The list above gives example file names. Two of them were actually written in an example, and their content is in the section &quot;Fixtures read from both sides in an example.&quot; In this design the Rust tests and the tests on each platform read the same fixture files directly. In the example, the two sides that actually read them are Rust and Node. A passing fixture means that range of input was checked.&lt;/p&gt;
&lt;p&gt;Each layer checks something different.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Rust test
  -&amp;gt; 도메인 규칙이 맞는가

iOS test
  -&amp;gt; Swift adapter가 Rust 결과를 맞게 해석하는가

Android test
  -&amp;gt; Kotlin adapter와 native library 연결이 맞는가

Web test
  -&amp;gt; wasm package와 TypeScript wrapper가 맞는가
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In order, the Korean questions read: are the domain rules right, does the Swift adapter interpret the Rust result correctly, is the connection between the Kotlin adapter and the native library right, and do the wasm package and the TypeScript wrapper match.&lt;/p&gt;
&lt;p&gt;iOS checks how Swift interprets the DTOs. Android checks the Kotlin conversion, the serialization settings, and the native linking. The web checks how TypeScript converts the values returned by wasm, and the package linking. I do not think each client needs to test the whole algorithm of the engine again. This division is not set by the &lt;a href=&quot;https://mozilla.github.io/uniffi-rs/latest/&quot;&gt;UniFFI&lt;/a&gt; documentation or that of any other tool. It is my own design choice.&lt;/p&gt;
&lt;h2&gt;Fixtures read from both sides in an example&lt;/h2&gt;
&lt;p&gt;In a small example I wrote two fixtures and had a Rust test and a web-side test read the same files. It is not a real app, only a minimal workspace for checking the structure. The tools were cargo 1.96.0, wasm-pack 0.15.0, and Node 24.14.1, and the date was October 4, 2026. The fixture for an input that conflicts looks like this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;request&quot;: { &quot;seed&quot;: 0 },
  &quot;action&quot;: { &quot;type&quot;: &quot;set_value&quot;, &quot;row&quot;: 0, &quot;col&quot;: 2, &quot;value&quot;: 5 },
  &quot;expected&quot;: { &quot;ok&quot;: false, &quot;cell_index&quot;: 2, &quot;cell&quot;: &quot;0&quot;, &quot;error_code&quot;: &quot;conflict&quot; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Rust test reads every file in the directory and compares the result with the expected values. It also checks the number of files, so a missing fixture fails the test. This is &lt;code&gt;fixtures.rs&lt;/code&gt; without its imports and its other tests.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const ENV: &amp;amp;str = r#&quot;{&quot;locale&quot;:&quot;ko-KR&quot;}&quot;#;

// 저장소의 fixtures/ 를 그대로 읽는다. Web 쪽 테스트도 같은 파일을 읽는다.
#[test]
fn fixtures_match_expected() {
    let dir = Path::new(env!(&quot;CARGO_MANIFEST_DIR&quot;)).join(&quot;../../fixtures&quot;);
    let mut checked = 0;
    for entry in fs::read_dir(dir).unwrap() {
        let path = entry.unwrap().path();
        let fixture: Value = serde_json::from_str(&amp;amp;fs::read_to_string(&amp;amp;path).unwrap()).unwrap();
        let snapshot = sudoku_core::start_game_json(&amp;amp;fixture[&quot;request&quot;].to_string());
        let result: Value =
            serde_json::from_str(&amp;amp;sudoku_core::apply_action_json(&amp;amp;snapshot, &amp;amp;fixture[&quot;action&quot;].to_string(), ENV)).unwrap();
        let expected = &amp;amp;fixture[&quot;expected&quot;];
        assert_eq!(result[&quot;ok&quot;], expected[&quot;ok&quot;], &quot;{path:?}&quot;);
        assert_eq!(result[&quot;error&quot;][&quot;code&quot;], expected[&quot;error_code&quot;], &quot;{path:?}&quot;);
        if result[&quot;ok&quot;] == true {
            let next: Value = serde_json::from_str(result[&quot;snapshot&quot;].as_str().unwrap()).unwrap();
            let index = expected[&quot;cell_index&quot;].as_u64().unwrap() as usize;
            assert_eq!(&amp;amp;next[&quot;cells&quot;].as_str().unwrap()[index..index + 1], expected[&quot;cell&quot;].as_str().unwrap(), &quot;{path:?}&quot;);
        }
        checked += 1;
    }
    assert_eq!(checked, 2, &quot;fixture 수가 달라졌다&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean comment says that the test reads the repository&apos;s &lt;code&gt;fixtures/&lt;/code&gt; as is and that the web-side test reads the same files. The message in the last assertion says the number of fixtures has changed.&lt;/p&gt;
&lt;p&gt;The web side loads the package built by wasm-pack in Node and reads the same directory.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Rust 테스트와 같은 fixtures/ 를 읽어 wasm 패키지의 결과를 대조한다.
import { readdirSync, readFileSync } from &apos;node:fs&apos;;
import { createRequire } from &apos;node:module&apos;;
import assert from &apos;node:assert/strict&apos;;
import test from &apos;node:test&apos;;

const wasm = createRequire(import.meta.url)(&apos;./wasm-node/sudoku_wasm.js&apos;);
const dir = new URL(&apos;../fixtures/&apos;, import.meta.url);

for (const name of readdirSync(dir)) {
  test(name, () =&amp;gt; {
    const fixture = JSON.parse(readFileSync(new URL(name, dir), &apos;utf8&apos;));
    const snapshot = wasm.start_game(JSON.stringify(fixture.request));
    const result = JSON.parse(wasm.apply_action(snapshot, JSON.stringify(fixture.action), &apos;{&quot;locale&quot;:&quot;ko-KR&quot;}&apos;));
    assert.equal(result.ok, fixture.expected.ok);
    assert.equal(result.error?.code ?? null, fixture.expected.error_code);
    if (result.ok) assert.equal(JSON.parse(result.snapshot).cells[fixture.expected.cell_index], fixture.expected.cell);
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The comment on the first line says it reads the same &lt;code&gt;fixtures/&lt;/code&gt; as the Rust test and compares the result of the wasm package.&lt;/p&gt;
&lt;p&gt;These are the results from both sides, taking only the test result part of each run&apos;s output. &lt;code&gt;cargo test -p sudoku-core&lt;/code&gt; is above, and &lt;code&gt;node --test&lt;/code&gt; is below.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;running 5 tests
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

✔ apply-action-set-value-conflict.json (2.652125ms)
✔ apply-action-set-value-valid.json (0.324083ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One of the five Rust tests is the fixture test. Adapter tests for iOS and Android were not written. Reading the same fixtures from Swift and Kotlin is a part that was not confirmed.&lt;/p&gt;
&lt;h2&gt;CI builds the artifacts from a fresh checkout&lt;/h2&gt;
&lt;p&gt;The goal of CI is to reproduce building the artifacts from a fresh checkout. The steps can be split into five groups.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Rust
  cargo fmt
  cargo clippy
  cargo test

UniFFI
  generate Swift binding
  generate Kotlin binding
  generated output validation

Apple
  build Rust static library
  create XCFramework
  xcodebuild build/test

Android
  build Rust .so per ABI
  generate Kotlin binding
  Gradle unit test
  assemble release or debug
  native library packaging check

Web
  wasm-pack build
  typecheck
  test
  production build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The block above lists the names of steps and is not a CI configuration file. No CI configuration file was written. In the example, the tests, binding generation, the XCFramework, the &lt;code&gt;.so&lt;/code&gt; files for Android, and the Wasm build are tied together in one script. I copied only the sources to a fresh directory, ran it from the start, and it ended with exit code 0. That was a single run, and not on a CI server.&lt;/p&gt;
&lt;p&gt;Stale output has to be caught too. Be wary of relying on generated code and binaries left on a local machine, and I suggest checking that there is no diff before and after regenerating in CI.&lt;/p&gt;
&lt;p&gt;Splitting jobs makes it easier to decide where to look first when something fails. Examples of job names are &lt;code&gt;rust-core&lt;/code&gt;, &lt;code&gt;uniffi-bindings&lt;/code&gt;, &lt;code&gt;apple-xcframework&lt;/code&gt;, &lt;code&gt;android-native&lt;/code&gt;, &lt;code&gt;web-wasm&lt;/code&gt;, and &lt;code&gt;contract-fixtures&lt;/code&gt;. If a shared fixture fails, look at the domain contract first, and if only iOS fails, look at the Apple package setup first. The candidates to look at on each platform are these.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;iOS: the slices of the &lt;a href=&quot;https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle&quot;&gt;XCFramework&lt;/a&gt;, a mismatch in the Swift bindings, differences in Xcode, and the target difference between a device and the simulator&lt;/li&gt;
&lt;li&gt;Android: the ABI, the name of the &lt;code&gt;.so&lt;/code&gt;, the NDK, &lt;a href=&quot;https://developer.android.com/guide/practices/page-sizes&quot;&gt;16 KB pages&lt;/a&gt;, and the Gradle packaging settings&lt;/li&gt;
&lt;li&gt;Web: how wasm is started, the bundler, the point where it meets SSR, and a mismatch in the TypeScript declarations&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What to check before release, and how much to run&lt;/h2&gt;
&lt;p&gt;Six items are checked before a release.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Whether the app reflects the version of the Rust core&lt;/li&gt;
&lt;li&gt;Whether the Rust API and the UniFFI output match&lt;/li&gt;
&lt;li&gt;Whether the XCFramework contains the device and simulator variants&lt;/li&gt;
&lt;li&gt;Whether the Android release has the required ABIs and supports 16 KB&lt;/li&gt;
&lt;li&gt;Whether wasm starts in the web production build&lt;/li&gt;
&lt;li&gt;Whether the expected values of the fixtures match on every platform&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I recommend moving what a machine can check into CI and leaving only what a person has to look at on the release list.&lt;/p&gt;
&lt;p&gt;What to run and when is for the team to decide. The team decides whether a PR that changes the core also requires the platform app builds, and whether the full combination runs on a nightly or on the release branch. Running everything on every PR can raise the cost. The compromise I suggest is this.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A PR that changes the core: Rust tests, binding generation, and fixture checks&lt;/li&gt;
&lt;li&gt;A platform integration PR: the build and tests of that platform&lt;/li&gt;
&lt;li&gt;A release candidate: all of iOS, Android, and the web&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This setup is a recommendation. How many times it succeeded after being applied in CI, how long a run takes, and a CI configuration file are not here. The only passing record is the local run of the example. The cost of running the full combination remains, and items that are hard to automate and need a person stay on the release list. A passing &lt;code&gt;cargo test&lt;/code&gt; alone cannot guarantee the state of the adapters, the native packages, or the web release.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>CI-CD</category><category>Testing</category><category>UniFFI</category><category>FFI</category></item><item><title>Rust shared core, part 6: using it on the web as a Wasm package</title><link>https://jaemyeong.com/en/blog/rust-shared-core-06-web-wasm/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/rust-shared-core-06-web-wasm/</guid><description>To use the Rust rules from mobile on the web, compile them to WebAssembly and ship a package. Roles, where to initialize in Next.js, and the call contract.</description><pubDate>Thu, 23 Jul 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Applying the rules of the Rust core used on iOS and Android to the web as well needs a way to run the same code in a browser. &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/WebAssembly&quot;&gt;WebAssembly&lt;/a&gt; is that way. It is a low-level binary format for browsers, Rust can compile to it with the web as the target, and it is used together with JavaScript.&lt;/p&gt;
&lt;p&gt;That does not mean writing the whole web app in Rust. Part 6 uses Sudoku to explain what Rust and the web each take on, and when and where a Next.js app initializes Wasm.&lt;/p&gt;
&lt;h2&gt;What Rust takes and what the web takes&lt;/h2&gt;
&lt;p&gt;The candidates on the Rust side are the things whose results and rules have to be the same across platforms: puzzle generation, the solver, validation, the action reducer, score calculation, and the seed of the daily puzzle.&lt;/p&gt;
&lt;p&gt;What I recommend leaving on the web side is the React components, routing, localStorage and IndexedDB, keyboard shortcuts, pointer interaction, analytics, and the service worker. The DOM, fetch, hydration, accessibility, and browser storage are also better handled by TypeScript and the web framework. The web builds its UI state from the results of calling Rust.&lt;/p&gt;
&lt;h2&gt;Two tools and a directory layout&lt;/h2&gt;
&lt;p&gt;Two tools share the work. &lt;a href=&quot;https://wasm-bindgen.github.io/wasm-bindgen/&quot;&gt;wasm-bindgen&lt;/a&gt; exposes Rust functions to JavaScript and generates the wrapper and TypeScript declarations. &lt;a href=&quot;https://wasm-bindgen.github.io/wasm-pack/book/&quot;&gt;wasm-pack&lt;/a&gt; builds the crate and bundles the WebAssembly binary, the JavaScript glue code, and the package metadata into a form that can be consumed from npm. Up to here the content comes from the documentation of the two tools. The directory layout and the initialization approach below are my own design choices.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;core/sudoku-rs/crates/
  sudoku-core/      # 순수 Rust 로직
  sudoku-wasm/      # wasm-bindgen 공개 API

packages/
  sudoku-wasm/      # wasm-pack output 또는 wrapper package

apps/web/
  app/              # Next.js / React 앱
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean comments in the block mean, in order: &quot;pure Rust logic&quot; on the &lt;code&gt;sudoku-core/&lt;/code&gt; line, &quot;the public wasm-bindgen API&quot; on the &lt;code&gt;sudoku-wasm/&lt;/code&gt; line under &lt;code&gt;crates/&lt;/code&gt;, &quot;wasm-pack output or a wrapper package&quot; on the &lt;code&gt;sudoku-wasm/&lt;/code&gt; line under &lt;code&gt;packages/&lt;/code&gt;, and &quot;the Next.js / React app&quot; on the &lt;code&gt;app/&lt;/code&gt; line.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sudoku-wasm&lt;/code&gt; calls &lt;code&gt;sudoku-core&lt;/code&gt;. The web app imports &lt;code&gt;@app/sudoku-wasm&lt;/code&gt; and does not see Rust&apos;s internal model directly. Managing it as a package makes the dependencies and the location of the artifacts visible. The versions and the target option I used in an example are in the section &quot;A Wasm package built from an example.&quot; The current wasm-pack documentation says it may describe features that are not in a released version, so check that it matches the version in use.&lt;/p&gt;
&lt;h2&gt;Where to initialize in Next.js&lt;/h2&gt;
&lt;p&gt;In Next.js, code runs on the server and in the browser. Running a browser-only module in a server component or during a static build can cause an error. So I suggest fixing the place and the time of initialization.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Client Component
  -&amp;gt; dynamic import(&quot;@app/sudoku-wasm&quot;)
  -&amp;gt; init wasm
  -&amp;gt; create SudokuEngineClient
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A Client Component does a dynamic import, initializes Wasm, and then creates the &lt;code&gt;SudokuEngineClient&lt;/code&gt;. Another option is lazy initialization in the state layer, on first use.&lt;/p&gt;
&lt;p&gt;The UI has to be able to tell whether the engine is ready. I suggest managing five states.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;wasm loading&lt;/li&gt;
&lt;li&gt;wasm ready&lt;/li&gt;
&lt;li&gt;wasm failed&lt;/li&gt;
&lt;li&gt;engine action pending&lt;/li&gt;
&lt;li&gt;engine action failed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Without showing these states, the user can get a blank screen or a button that does nothing. Apart from whether the core is stable, the initialization process affects how stable the app feels. Real error messages and steps to reproduce are not included in this post.&lt;/p&gt;
&lt;h2&gt;One call per UI event&lt;/h2&gt;
&lt;p&gt;I recommend avoiding calls to Rust per cell inside React render, or on every pointer move. It is better to pass the snapshot and the action once per UI event and receive the next snapshot and the effects.&lt;/p&gt;
&lt;p&gt;The contract the app sees has two methods.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export interface SudokuEngineClient {
  startGame(request: StartGameRequest): Promise&amp;lt;GameSnapshot&amp;gt;;
  applyAction(snapshot: GameSnapshot, action: GameAction): Promise&amp;lt;Transition&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this structure React uses only this client, and the generated raw functions are called only inside the client implementation. The &lt;code&gt;SudokuEngineClient&lt;/code&gt; on iOS, Android, and the web can differ in implementation, but the meaning in the domain and the contract the app sees stay the same. Conceptually it sits in the same place as the adapter on mobile.&lt;/p&gt;
&lt;p&gt;The types may need one more step. The generated TypeScript declarations alone may not cover all the domain types of the app. So a separate wrapper is a good idea. That wrapper converts what Wasm returns into the app&apos;s types and does JSON decoding, schema validation, and error code mapping.&lt;/p&gt;
&lt;h2&gt;A Wasm package built from an example&lt;/h2&gt;
&lt;p&gt;I built this structure as a small example and loaded it from Node. No real web app is involved. It is a minimal workspace that checks the structure. The tools were wasm-pack 0.15.0, wasm-bindgen 0.2.129, and Node 24.14.1, and the date was October 4, 2026. The binding crate only wraps the functions of the core. Keeping the JSON-facing functions in the core as well is where the example departs from the design in part 2, to stay small.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn start_game(request_json: &amp;amp;str) -&amp;gt; String {
    sudoku_core::start_game_json(request_json)
}

#[wasm_bindgen]
pub fn apply_action(snapshot_json: &amp;amp;str, action_json: &amp;amp;str, environment_json: &amp;amp;str) -&amp;gt; String {
    sudoku_core::apply_action_json(snapshot_json, action_json, environment_json)
}
// … validate_snapshot and generate_daily_puzzle have the same shape.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is one build command. The target is &lt;code&gt;nodejs&lt;/code&gt;, because the run was in Node and not in a browser. The &lt;code&gt;grep&lt;/code&gt; and &lt;code&gt;sed&lt;/code&gt; after it keep only the completion lines of the output and shorten the absolute path. Run on its own, this line can end as a success even when the build fails, because of the last command in the pipe. The script that was run has &lt;code&gt;set -euo pipefail&lt;/code&gt; at its top so that such a failure is not missed.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wasm-pack build crates/sudoku-wasm --target nodejs --out-dir ../../out/wasm-node 2&amp;gt;&amp;amp;1 | grep -E &quot;Done|ready&quot; | sed &apos;s#at .*/out/#at out/#&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The output directory got &lt;code&gt;sudoku_wasm_bg.wasm&lt;/code&gt;, &lt;code&gt;sudoku_wasm.js&lt;/code&gt;, &lt;code&gt;sudoku_wasm.d.ts&lt;/code&gt;, &lt;code&gt;sudoku_wasm_bg.wasm.d.ts&lt;/code&gt;, and &lt;code&gt;package.json&lt;/code&gt;. The &lt;code&gt;.wasm&lt;/code&gt; file was 142,894 bytes. These are the TypeScript declarations generated in &lt;code&gt;sudoku_wasm.d.ts&lt;/code&gt;. The two lint comment lines at the top of the file are left out.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function apply_action(snapshot_json: string, action_json: string, environment_json: string): string;

export function generate_daily_puzzle(request_json: string): string;

export function start_game(request_json: string): string;

export function validate_snapshot(snapshot_json: string): string;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every argument and return value is a &lt;code&gt;string&lt;/code&gt;. As described earlier, turning that string into the app&apos;s types and validating it is work a wrapper has to do separately. I loaded this package in Node with &lt;code&gt;require&lt;/code&gt; and called the functions, and two tests that read the shared fixtures passed. Those tests are in part 7.&lt;/p&gt;
&lt;p&gt;What was not done is worth writing down too. No build was made for the &lt;code&gt;web&lt;/code&gt; or &lt;code&gt;bundler&lt;/code&gt; target. Initialization in a browser, the dynamic import in Next.js, and a client and wrapper that return a &lt;code&gt;Promise&lt;/code&gt; were not built.&lt;/p&gt;
&lt;h2&gt;Both sides read the same fixtures&lt;/h2&gt;
&lt;p&gt;I suggest that the Rust tests and the web tests use the same fixtures. The Rust side checks the output of the engine, and the web side checks that the wrapper interprets the same result correctly. The aim is to find the case where the engine is right and the type conversion is wrong.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fixtures/
  daily-seed-2026-06-24.json
  apply-action-note-toggle.json
  invalid-move-conflict.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The date in the file name is an identifier in the example. How to set up tests and CI is covered in the next part.&lt;/p&gt;
&lt;p&gt;What this setup emphasizes is not speed. It is that the three platforms use the same domain rules. Performance numbers and an implementation that handles initialization failure are not here. Testing went as far as running two fixtures in Node. The interface that returns a &lt;code&gt;Promise&lt;/code&gt; is an example of the contract the app sees, and this post has no definition of the fields of &lt;code&gt;Transition&lt;/code&gt;.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>WebAssembly</category><category>wasm-bindgen</category><category>wasm-pack</category><category>TypeScript</category></item><item><title>Rust shared core, part 5: what to check when shipping a .so on Android</title><link>https://jaemyeong.com/en/blog/rust-shared-core-05-android-native-packaging/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/rust-shared-core-05-android-native-packaging/</guid><description>Generating Kotlin bindings does not finish Android integration. Per-ABI .so placement, wiring Rust into Gradle, the 16 KB page rule, and release checks.</description><pubDate>Sat, 04 Jul 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When a Rust module shared by several platforms goes into Android, generating the Kotlin bindings does not finish the integration. If an ABI is missing or in the wrong place, or if a platform or store condition is not met, the build, the install, or the run can fail somewhere.&lt;/p&gt;
&lt;p&gt;Part 5 covers the build wiring and the compatibility checks for putting a Rust binary into an Android release. The versions and policies in it are the values in the Android documentation as I rechecked it on October 4, 2026. The environment in which I built an example is recorded separately in the section &quot;The alignment of a .so built from an example.&quot;&lt;/p&gt;
&lt;h2&gt;Each ABI needs its own .so&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.android.com/ndk/guides/abis&quot;&gt;The ABIs the NDK supports&lt;/a&gt; are four: &lt;code&gt;armeabi-v7a&lt;/code&gt;, &lt;code&gt;arm64-v8a&lt;/code&gt;, &lt;code&gt;x86&lt;/code&gt;, and &lt;code&gt;x86_64&lt;/code&gt;. Which of them to ship is decided by the app&apos;s policy. For each chosen ABI, a &lt;code&gt;.so&lt;/code&gt; has to be built with the matching Rust target.&lt;/p&gt;
&lt;p&gt;The output goes under &lt;code&gt;jniLibs&lt;/code&gt; in the app module, in a directory per ABI. Below is an example with three ABIs chosen.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apps/android/
  app/
    src/main/
      jniLibs/
        arm64-v8a/libdomain_core.so
        armeabi-v7a/libdomain_core.so
        x86_64/libdomain_core.so
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This layout makes the file for each ABI easy to find. Copying the files by hand carries a risk. After the Rust code changes, the app can still contain the old binary.&lt;/p&gt;
&lt;h2&gt;Wiring the Rust build into Gradle&lt;/h2&gt;
&lt;p&gt;The ABIs, the page size, and the compatible versions come from the documentation. The Gradle task setup in this section and the checklists further down are my own design choices built on that, not requirements of the documentation.&lt;/p&gt;
&lt;p&gt;To avoid copying by hand, connect it to the build. This setup makes &lt;code&gt;preBuild&lt;/code&gt; depend on a task that builds Rust.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;preBuild
  dependsOn buildRustCoreForAndroid

buildRustCoreForAndroid
  -&amp;gt; ABI별 cargo build
  -&amp;gt; UniFFI Kotlin binding 생성
  -&amp;gt; jniLibs / generated source 경로에 output 배치
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The three arrows read: build with cargo for each ABI, generate the UniFFI Kotlin binding, and place the output in the &lt;code&gt;jniLibs&lt;/code&gt; and generated source paths.&lt;/p&gt;
&lt;p&gt;The block above draws the relation between tasks and is not a Gradle configuration to use as written. The order is to compile with cargo, generate Kotlin code with &lt;a href=&quot;https://mozilla.github.io/uniffi-rs/latest/&quot;&gt;UniFFI&lt;/a&gt;, and put the results in the &lt;code&gt;jniLibs&lt;/code&gt; and generated source paths.&lt;/p&gt;
&lt;p&gt;Without inputs and outputs declared on the Gradle task, unnecessary rebuilds can happen, or a change can go undetected. The boundary of the task has to be set with both local speed and reproducibility in CI in mind.&lt;/p&gt;
&lt;h2&gt;Hide generated types behind a Kotlin interface&lt;/h2&gt;
&lt;p&gt;Exposing generated types across ViewModels, Composables, and repositories increases coupling. A Kotlin adapter and the &lt;code&gt;SudokuEngineClient&lt;/code&gt; contract separate the internal model. This is the shape of the interface I propose.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface SudokuEngineClient {
    fun startGame(request: StartGameRequest): GameSnapshot
    fun applyAction(snapshot: GameSnapshot, action: GameAction): Transition
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The implementation calls UniFFI, and the ViewModel uses only this contract. Tests can put in a fake. Using the same Rust module on the web as a WebAssembly package is left to part 6.&lt;/p&gt;
&lt;p&gt;Heavy Rust computation is handled off the main thread. I recommend separating the choice of dispatcher in the ViewModel scope from the update of UI state, and not exposing the Rust dependency to Compose UI. Measured cost and the specific dispatcher are not given in this post.&lt;/p&gt;
&lt;h2&gt;The 16 KB page rule&lt;/h2&gt;
&lt;p&gt;According to &lt;a href=&quot;https://developer.android.com/guide/practices/page-sizes&quot;&gt;the Android guide to 16 KB page sizes&lt;/a&gt;, apps on Google Play that target Android 15 (API level 35) or later have to support 16 KB pages on 64-bit devices. From February 1, 2027, updates that do not support 16 KB cannot be released. The earlier version of this post gave November 1, 2025 as the start date, and that date is not in the document as rechecked on October 4, 2026. Native code brought in by SDKs is in scope, so a Rust &lt;code&gt;.so&lt;/code&gt; is included.&lt;/p&gt;
&lt;p&gt;Per the documentation, NDK r28 and later use 16 KB alignment by default. Even so, every library in the release has to be checked without relying on the default. An old binary brought in by an ad or analytics SDK can affect the compatibility of the whole app.&lt;/p&gt;
&lt;p&gt;Four items need checking.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The ELF alignment of every &lt;code&gt;.so&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Behavior on a 16 KB emulator or device&lt;/li&gt;
&lt;li&gt;Whether third-party SDKs contain native code&lt;/li&gt;
&lt;li&gt;Whether the inspection used the release APK or AAB, not a debug build&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The alignment of an APK is checked with &lt;code&gt;zipalign&lt;/code&gt;, and the page size of a connected device is read with &lt;code&gt;adb&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;zipalign -c -P 16 -v 4 app-release.apk
adb shell getconf PAGE_SIZE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These two commands look at the alignment of the APK packaging and at the condition of the device. They do not by themselves confirm the ELF alignment of every &lt;code&gt;.so&lt;/code&gt; or that the app works. A project that ships mainly as an AAB adds the Play Console pre-launch report, or an APK built from the bundle, to what is checked. I could not run these two commands. No APK could be built, for the reason given in the next section.&lt;/p&gt;
&lt;h2&gt;The alignment of a .so built from an example&lt;/h2&gt;
&lt;p&gt;I built a small Rust example for three ABIs and checked the ELF alignment myself. The workspace is not a real app. It is a minimal one made to check the structure. I ran it on October 4, 2026, on macOS 27.0.1 with cargo 1.96.0 and the NDK installed on this Mac, 30.0.15729638-beta2. That is a different version from the NDK 28.2 in the section &quot;Pin the tool versions&quot; below, and it is a beta. The three Android targets of Rust were already installed.&lt;/p&gt;
&lt;p&gt;For each target, cargo is told to use the NDK&apos;s clang as the linker. The alignment is read from the LOAD lines of &lt;code&gt;llvm-objdump -p&lt;/code&gt;, which is the method the Android guide gives. These lines are taken from the build script. Two things are simplified because it is an example. The first line picks the highest version among the installed NDKs, so installing another NDK can change the result. To reproduce it, give the NDK path or version explicitly. The &lt;code&gt;24&lt;/code&gt; in the linker names stands for &lt;code&gt;minSdkVersion&lt;/code&gt;. If the app&apos;s &lt;code&gt;minSdk&lt;/code&gt; is lower than 24, it has to match that value.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NDK=$(ls -d &quot;$HOME&quot;/Library/Android/sdk/ndk/* | sort -V | tail -1)
TC=&quot;$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin&quot;
# …
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=&quot;$TC/aarch64-linux-android24-clang&quot;
export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=&quot;$TC/armv7a-linux-androideabi24-clang&quot;
export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=&quot;$TC/x86_64-linux-android24-clang&quot;
for target in aarch64-linux-android armv7-linux-androideabi x86_64-linux-android; do
  cargo build -q -p sudoku-uniffi --release --target &quot;$target&quot;
  so=&quot;target/$target/release/libsudoku_uniffi.so&quot;
  echo &quot;$target: $(stat -f %z &quot;$so&quot;) bytes&quot;
  # …
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The alignment was read with one more script. &lt;code&gt;sort -u&lt;/code&gt; collapses lines with the same value into one.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;OBJDUMP=&quot;$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objdump&quot;
for target in aarch64-linux-android armv7-linux-androideabi x86_64-linux-android; do
  echo &quot;$target&quot;
  &quot;$OBJDUMP&quot; -p &quot;target/$target/release/libsudoku_uniffi.so&quot; | awk &apos;/LOAD/ {print &quot;  LOAD &quot; $(NF-1) &quot; &quot; $NF}&apos; | sort -u
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two scripts printed the following. The file size per ABI is above, and the alignment on the LOAD lines is below.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;aarch64-linux-android: 716632 bytes
armv7-linux-androideabi: 505140 bytes
x86_64-linux-android: 686000 bytes

aarch64-linux-android
  LOAD align 2**14
armv7-linux-androideabi
  LOAD align 2**12
x86_64-linux-android
  LOAD align 2**14
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For the 64-bit ABIs, &lt;code&gt;arm64-v8a&lt;/code&gt; and &lt;code&gt;x86_64&lt;/code&gt;, every LOAD segment was 2&lt;strong&gt;14, which is 16 KB. The guide says the load segments must not have values less than 2&lt;/strong&gt;14. For the 32-bit &lt;code&gt;armeabi-v7a&lt;/code&gt;, the value was 2**12. The 16 KB rule is about 64-bit devices. No linker option was passed separately.&lt;/p&gt;
&lt;p&gt;More was left unchecked than checked. Gradle is not on this Mac, so no APK or AAB could be built. That means the &lt;code&gt;zipalign&lt;/code&gt; check, the &lt;code&gt;jniLibs&lt;/code&gt; placement, a release build, and loading on a device or an emulator were not tried. The Kotlin bindings were generated and not compiled.&lt;/p&gt;
&lt;h2&gt;Pin the tool versions&lt;/h2&gt;
&lt;p&gt;In the compatibility table of &lt;a href=&quot;https://developer.android.com/build/releases/agp-9-1-0-release-notes&quot;&gt;the AGP 9.1 release notes&lt;/a&gt; as rechecked on October 4, 2026, the values for AGP 9.1.1 are Gradle 9.3.1, JDK 17, and NDK 28.2.13676358. From &lt;a href=&quot;https://developer.android.com/build/releases/agp-9-0-0-release-notes&quot;&gt;AGP 9.0&lt;/a&gt; on, the default NDK is in the r28 line, and 9.1.1 uses the same default. Different NDK versions among developers can be a cause of different alignment and linker behavior.&lt;/p&gt;
&lt;p&gt;So the following six are pinned in one place and managed consistently.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AGP version
Gradle version
JDK version
NDK version
Rust toolchain
Android Rust targets
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A new configuration file is not needed. The version catalog, the Gradle wrapper, the CI image, and the README that are already maintained can serve as the reference. It is also worth confirming that &lt;code&gt;./gradlew assembleRelease&lt;/code&gt; from a fresh clone, or the release job in CI, succeeds including the generation of the Rust output. For using the NDK with a build system other than Gradle, see &lt;a href=&quot;https://developer.android.com/ndk/guides/other_build_systems&quot;&gt;the NDK guide to other build systems&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;A working debug build does not cover release&lt;/h2&gt;
&lt;p&gt;Because of R8, minify, packagingOptions, ABI splits, and App Bundle processing, the result of a debug build is not enough to judge a release. Five things are checked on the release output.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Whether every required ABI is included&lt;/li&gt;
&lt;li&gt;Whether the name the binding looks for matches the name of the binary&lt;/li&gt;
&lt;li&gt;Whether &lt;code&gt;System.loadLibrary&lt;/code&gt; succeeds&lt;/li&gt;
&lt;li&gt;Whether the 16 KB rule is met&lt;/li&gt;
&lt;li&gt;Whether a Rust call succeeds right after a fresh install&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I recommend checking automatically in CI. When that is hard, a manual checklist for each release candidate is the minimum alternative. The basic concepts of native calls are in &lt;a href=&quot;https://developer.android.com/ndk/guides/jni-tips&quot;&gt;the Android JNI tips&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;At the release stage, a Rust module is one more native binary dependency. Which ABIs to support, how to design incremental builds for the Gradle task, which thread to run on, whether third-party code meets the rule, and whether the final release really works all have to be confirmed separately in each project. The policy date and the version values are those in the documentation as rechecked on October 4, 2026.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>Android</category><category>Kotlin</category><category>Gradle</category><category>NDK</category></item><item><title>Rust shared core, part 4: delivering it to iOS as an XCFramework</title><link>https://jaemyeong.com/en/blog/rust-shared-core-04-apple-xcframework/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/rust-shared-core-04-apple-xcframework/</guid><description>Passing Rust tests does not mean an iOS app links. Bundle device and simulator static libraries into an XCFramework, ship it as a Swift package, and align CI.</description><pubDate>Tue, 30 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Using a shared Rust core in an iOS app takes more than passing tests on the Rust side. That is not a guarantee that the app links. Results can differ between the simulator and a device, or between a development machine and CI, and the binary checksum of a Swift package can fail to match.&lt;/p&gt;
&lt;p&gt;Part 4 goes through delivering the Rust binary and the Swift calling layer to an Apple app, based on the Apple Developer, Rust Reference, rustc, and UniFFI documentation. I reopened and checked these documents on October 4, 2026.&lt;/p&gt;
&lt;h2&gt;Bundle the static libraries into an XCFramework&lt;/h2&gt;
&lt;p&gt;The crate types for Rust output that another language links against include &lt;code&gt;staticlib&lt;/code&gt; and &lt;code&gt;cdylib&lt;/code&gt; (&lt;a href=&quot;https://doc.rust-lang.org/reference/linkage.html&quot;&gt;linkage in the Rust Reference&lt;/a&gt;). On iOS, the approach is to package a static library as an XCFramework.&lt;/p&gt;
&lt;p&gt;The device build and the simulator build are made separately, the headers and the module map are prepared, and &lt;code&gt;xcodebuild -create-xcframework&lt;/code&gt; combines them. Written out conceptually, the flow is this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cargo build --target aarch64-apple-ios
cargo build --target aarch64-apple-ios-sim

libdomain_core.a
headers/
module.modulemap

-&amp;gt; DomainCore.xcframework
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The supported targets are listed in &lt;a href=&quot;https://doc.rust-lang.org/rustc/platform-support/apple-ios.html&quot;&gt;rustc&apos;s Apple iOS platform notes&lt;/a&gt;. The block above is not commands that were run with their output. It is a picture of how the inputs relate to the artifact.&lt;/p&gt;
&lt;p&gt;An &lt;a href=&quot;https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle&quot;&gt;XCFramework&lt;/a&gt; can hold a variant per platform and architecture in one bundle. iOS devices, the simulator, macOS, and visionOS are examples. When it is used as a binary dependency of an Xcode project or a Swift package, Xcode can choose the slice that matches the build destination.&lt;/p&gt;
&lt;p&gt;Reproducing the build needs matching conditions. I recommend making the installed targets, the toolchain version, the profile, the output location, and the place where headers are generated the same on development machines and in CI. I also recommend putting target installation and packaging into a script. The values of the environment in which I built the example are in the section &quot;An XCFramework bundled from an example.&quot;&lt;/p&gt;
&lt;p&gt;The headers, the module map, and the generated Swift bindings have to match each other. If the code UniFFI produced and the Rust library are from different versions, linking can fail or symbol problems can appear at run time. So I suggest managing the two generation steps as one release unit.&lt;/p&gt;
&lt;h2&gt;An XCFramework bundled from an example&lt;/h2&gt;
&lt;p&gt;I ran the flow above for real with a small example. The example is deliberately small. Its job is to check the structure, and it is not a real app. The run was on October 4, 2026, on macOS 27.0.1 with Xcode 27.0, cargo 1.96.0, and uniffi 0.29.5. The Rust targets &lt;code&gt;aarch64-apple-ios&lt;/code&gt; and &lt;code&gt;aarch64-apple-ios-sim&lt;/code&gt; were already installed. The &lt;code&gt;crate-type&lt;/code&gt; of the binding crate includes &lt;code&gt;staticlib&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For the header and the module map, I used what UniFFI generated together with the Swift bindings. The generated &lt;code&gt;sudoku_uniffiFFI.modulemap&lt;/code&gt; was copied under the name &lt;code&gt;module.modulemap&lt;/code&gt;. Below are the lines of the script that was run which belong to this step. The &lt;code&gt;sed&lt;/code&gt; at the end shortens the absolute path printed in the output, and &lt;code&gt;out/host&lt;/code&gt; is the directory used for the Swift call in part 3.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p out/headers out/host
cp out/swift/sudoku_uniffiFFI.h out/headers/
cp out/swift/sudoku_uniffiFFI.modulemap out/headers/module.modulemap
# …
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios-sim
xcodebuild -create-xcframework \
  -library target/aarch64-apple-ios/release/libsudoku_uniffi.a -headers out/headers \
  -library target/aarch64-apple-ios-sim/release/libsudoku_uniffi.a -headers out/headers \
  -output out/SudokuCore.xcframework | sed &apos;s#: .*/out/#: out/#&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the output and the directory that was produced.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;xcframework successfully written out to: out/SudokuCore.xcframework
SudokuCore.xcframework
SudokuCore.xcframework/Info.plist
SudokuCore.xcframework/ios-arm64
SudokuCore.xcframework/ios-arm64-simulator
SudokuCore.xcframework/ios-arm64-simulator/Headers
SudokuCore.xcframework/ios-arm64-simulator/libsudoku_uniffi.a
SudokuCore.xcframework/ios-arm64/Headers
SudokuCore.xcframework/ios-arm64/libsudoku_uniffi.a
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Info.plist&lt;/code&gt; held two entries, &lt;code&gt;ios-arm64&lt;/code&gt; and &lt;code&gt;ios-arm64-simulator&lt;/code&gt;. Each static library was about 31 MB. That is the size of a release build that was not stripped.&lt;/p&gt;
&lt;p&gt;That is as far as the check went. I went one step further to see whether this XCFramework can really be consumed. I made a Swift package that points at the XCFramework with a &lt;code&gt;binaryTarget&lt;/code&gt; and puts the generated Swift bindings in a target, and built it with &lt;code&gt;xcodebuild build&lt;/code&gt; for the iOS simulator and for a device. Both succeeded. The module map generated by UniFFI was used unchanged. That is the result on Xcode 27.0, and other Xcode versions need their own check. Running an app in the simulator and &lt;code&gt;xcodebuild test&lt;/code&gt; were not tried. Calling Rust through the generated Swift bindings was confirmed on macOS only, and that record is in part 3.&lt;/p&gt;
&lt;h2&gt;Delivering through a Swift package&lt;/h2&gt;
&lt;p&gt;What an XCFramework holds, how &lt;code&gt;xcodebuild -create-xcframework&lt;/code&gt; is used, and Rust&apos;s crate types and targets are documented. Preparing a module map, Xcode choosing a slice, and the symptoms of a version mismatch were not found in the documents checked this time. The scripting and the single release unit recommended in the previous section, and the package layout and CI practice from this section on, are not decided by the documentation. They are my own design choices.&lt;/p&gt;
&lt;p&gt;With a single app, the XCFramework can be added to the project directly. With several apps, or with samples and test targets, I suggest using a &lt;a href=&quot;https://developer.apple.com/documentation/xcode/distributing-binary-frameworks-as-swift-packages&quot;&gt;Swift package&lt;/a&gt;. The binary target and the target for the Swift adapter can be separated.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DomainEnginePackage/
  Package.swift
  Sources/
    DomainEngine/
      DomainEngineClient.swift
      UniFFIAdapter.swift
      GeneratedBindings.swift
  Artifacts/
    DomainCore.xcframework
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The point of contact exposed to the app is &lt;code&gt;DomainEngineClient&lt;/code&gt;. The generated code and the binary are treated as internal implementation. That reduces what the app has to know about the Rust connection, and the dependencies are clearer than piling up scripts in the project&apos;s build phases.&lt;/p&gt;
&lt;p&gt;Distribution has decisions of its own: where to keep the artifact, how to manage the checksum of a remote ZIP, and how to match the version of the source package to the version of the binary. For internal use only, one way is to start with the artifact inside the repository and move it to release files once things are stable.&lt;/p&gt;
&lt;h2&gt;Making local builds and CI follow the same order&lt;/h2&gt;
&lt;p&gt;For integration on the Apple side, I think managing the Swift layer, the package, the build phases, and CI takes more of the work than calling Rust functions does.&lt;/p&gt;
&lt;p&gt;A local machine can still have targets that were installed earlier and artifacts that were built before. CI is assumed to start from a fresh clone. Different paths cause problems as well. So I recommend managing the inputs and the order in one place.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;install/check rust targets
cargo build for ios device
cargo build for ios simulator
generate UniFFI Swift bindings
generate headers/module map
create XCFramework
run xcodebuild test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This list names the steps to put into automation, not a shell script that runs as written.&lt;/p&gt;
&lt;p&gt;Compiling Rust in a build phase every time can lengthen the edit-and-run cycle during development. I recommend setting inputs, outputs, and a cache locally, and running CI mainly as clean builds. A measured time for how much longer it gets is not in this post.&lt;/p&gt;
&lt;p&gt;A setup that does not commit generated files adds one more condition. The required artifacts have to be generated automatically in the process of cloning fresh, opening the Xcode project, and building. Otherwise the first build of a newcomer fails.&lt;/p&gt;
&lt;h2&gt;Put the UniFFI implementation behind a Swift protocol&lt;/h2&gt;
&lt;p&gt;Using the API generated by UniFFI directly in the app is an easy start. Over time, the range the app depends on can widen. So the UniFFI implementation goes behind a Swift protocol. This is the shape of the protocol I propose.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;protocol SudokuEngineClient {
    func startGame(request: StartGameRequest) throws -&amp;gt; GameSnapshot
    func apply(_ action: GameAction, to snapshot: GameSnapshot) throws -&amp;gt; Transition
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ViewModel depends on this protocol, and the generated API is used only inside the adapter. Heavy Rust work can be wrapped in an async API in the adapter, with UI updates handled on the MainActor. With a fake client, the ViewModel can be tested by itself. Delivering the same structure to Android as a &lt;code&gt;.so&lt;/code&gt; (ABI, Gradle, the NDK, and the 16 KB page size) is in part 5.&lt;/p&gt;
&lt;p&gt;Three things are worth confirming. Build for both a device and the simulator. See that local builds and CI use the same commands. See that an Xcode build works from a fresh clone. Of these, the example covered building for a device and for the simulator. I also copied only the sources to a fresh directory and ran the same script from the start, and it passed to the end. Running in CI and building an Xcode project were not checked.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>iOS</category><category>Xcode</category><category>SwiftPM</category><category>FFI</category></item><item><title>Xcode UI tests and what automationmodetool does and does not cover</title><link>https://jaemyeong.com/en/blog/xcode-ui-automation-automationmodetool/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/xcode-ui-automation-automationmodetool/</guid><description>When Xcode UI tests stall at an authentication prompt on an unattended Mac, automationmodetool covers one part and TCC permissions another. Here is how to tell.</description><pubDate>Tue, 30 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Running Xcode UI tests in CI can stall on an authentication dialog before the run starts. On a Mac with a person in front of it, typing the password is enough. A self-hosted runner or a Mac in a test lab has nobody to type it, so the tests do not proceed.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;automationmodetool&lt;/code&gt; is the tool that configures this authentication requirement. It does not solve every permission problem of UI tests, though. This post separates what the tool covers from what has to be handled on its own, such as TCC permissions, and goes through the order for preparing a machine. I ran the status query myself on October 4, 2026, on macOS 27.0.1 with Xcode 27.0. The commands that change the setting, and UI tests, were not run.&lt;/p&gt;
&lt;h2&gt;With no argument it reports the status&lt;/h2&gt;
&lt;p&gt;According to &lt;a href=&quot;https://keith.github.io/xcode-man-pages/automationmodetool.1.html&quot;&gt;the man page&lt;/a&gt;, the tool configures a device so that Automation Mode for UI testing can be enabled without user authentication. It says this is for preparing CI machines and lab machines whose users do not have administrator privileges. With no argument, it prints whether Automation Mode is enabled and whether authentication is required, and exits. The man page installed on my Mac says the same.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;automationmodetool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the output from running it on my Mac.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Automation Mode is disabled.
This device DOES NOT REQUIRE user authentication to enable Automation Mode.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two lines say different things. The disabled on the first line is the current state: Automation Mode is off right now. The second line is the setting for whether authentication is needed when the mode is turned on. When it says DOES NOT REQUIRE, XCTest and testmanagerd can switch the mode during a run without a password being entered.&lt;/p&gt;
&lt;p&gt;This command allows enabling it without authentication.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo automationmodetool enable-automationmode-without-authentication
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This one goes back to requiring authentication.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo automationmodetool disable-automationmode-without-authentication
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Changing the setting itself needs administrator authentication. So I recommend doing it when a machine is first prepared, not on every PR.&lt;/p&gt;
&lt;h2&gt;The authentication setting and TCC permissions are separate problems&lt;/h2&gt;
&lt;p&gt;Xcode Helper or the runner asking for Accessibility, AppleEvents, or Post Event is a TCC matter. In &lt;a href=&quot;https://support.apple.com/guide/deployment/privacy-preferences-policy-control-payload-dep38df53c2a/web&quot;&gt;Apple&apos;s PPPC settings guide&lt;/a&gt;, these three correspond to controlling the Mac via Accessibility APIs, sending a restricted AppleEvent to another process, and sending events with CoreGraphics APIs. &lt;code&gt;automationmodetool&lt;/code&gt; cannot stand in for a TCC grant, an installed PPPC profile, or an Accessibility grant.&lt;/p&gt;
&lt;p&gt;The wording in the log tells the two apart. A denial such as &lt;code&gt;Xcode Helper does not have permission to use Accessibility.&lt;/code&gt; points to the Privacy &amp;amp; Security settings or the PPPC settings in MDM. &lt;code&gt;Timed out while enabling automation mode.&lt;/code&gt; may be a problem at the authentication step, so query the status first. A timeout alone does not settle that it is an authentication problem, and the two failures can happen together. The prompt on screen is also a hint. A prompt asking for a password is a candidate for the mode authentication side, and a prompt asking for consent to control an app, or for Accessibility, is a candidate for the TCC and PPPC side.&lt;/p&gt;
&lt;p&gt;Which process is asking for the permission can be seen in the TCC log. The command is given in the same PPPC guide.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;log stream --debug --predicate &apos;subsystem == &quot;com.apple.TCC&quot; AND eventMessage BEGINSWITH &quot;AttributionChain&quot;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I recommend confirming the responsible process in this log before allowing it, and not adding Xcode.app or the app under test to the allow list on a guess.&lt;/p&gt;
&lt;h2&gt;Configure when preparing the machine, and only query in the job&lt;/h2&gt;
&lt;p&gt;Here is an example order for preparing a Mac for unattended UI tests. Select the Xcode developer directory, accept the license, run the first-launch tasks, change the authentication setting, and query the status.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch
sudo automationmodetool enable-automationmode-without-authentication
automationmodetool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On a self-hosted runner, be careful about putting this change into a workflow. A step like the one below is an example.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Enable automation mode
  run: sudo automationmodetool enable-automationmode-without-authentication
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Allowing sudo in a workflow where arbitrary code runs creates a risk of privilege escalation. I recommend a split in which the owner of the machine does the preparation and the job only builds and tests. At the start of the job, record the status without changing the setting.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Show UI automation state
  run: |
    automationmodetool || true
    xcodebuild -version
    xcode-select -p
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;|| true&lt;/code&gt; keeps the job going when the query fails, so that the record is still left. This way of operating is my own reading, not something the man page prescribes.&lt;/p&gt;
&lt;p&gt;There is an exception. Hosted CI where the service manages sudo is a different situation. &lt;a href=&quot;https://support.bitrise.io/en/articles/9676606-enabling-ui-automation-for-macos-tests&quot;&gt;The Bitrise guide&lt;/a&gt; says to run the change command in a Script Step when this timeout appears.&lt;/p&gt;
&lt;p&gt;The problem has been reported several times. &lt;a href=&quot;https://github.com/actions/runner-images/issues/5410&quot;&gt;Issue 5410&lt;/a&gt; reports UI tests failing with the timeout on the macOS 12 Monterey runner of GitHub Actions, and &lt;a href=&quot;https://github.com/actions/runner-images/issues/7778&quot;&gt;issue 7778&lt;/a&gt; covers the same error on macOS 13 with Xcode 14.3.1. &lt;a href=&quot;https://github.com/flutter/flutter/issues/166011&quot;&gt;Flutter issue 166011&lt;/a&gt; says that integration tests through XCUITest need this setting, and that the trouble is the setting command asking for a password. That issue quotes an approach that types the password with &lt;code&gt;expect&lt;/code&gt;. That is fragile, so I recommend preparing the runner account and the machine in advance. Recording and replaying UI automation is the subject of &lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2025/344/&quot;&gt;WWDC25 session 344&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The per-device mode setting is separate from TCC and keychain permissions, which depend on the user, the process, and the signing. The signing keychain, Developer Tools Access, and DevToolsSecurity are also areas to look into on their own. With several Xcode versions or several runner accounts, work out which setting applies to which scope. The earlier version of this post was written against macOS 26.5.1 and Xcode 26.6, and this time only the status query was checked again. Whether the setting survives a reboot, an update, or an image replacement, and whether UI tests really pass after it is set, were not checked.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>macOS</category><category>Xcode</category><category>XCTest</category><category>CI-CD</category><category>Automation</category></item><item><title>Rust shared core, part 3: keep UniFFI generated code behind an adapter</title><link>https://jaemyeong.com/en/blog/rust-shared-core-03-uniffi-mobile/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/rust-shared-core-03-uniffi-mobile/</guid><description>UniFFI generates Swift and Kotlin calling code from Rust. What to expose and how far generated types spread is up to the app. An adapter keeps them contained.</description><pubDate>Mon, 29 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Calling common functionality written in Rust from iOS and Android needs a connecting layer on the Swift side and on the Kotlin side. One way is to build a C ABI and a wrapper per platform by hand. That is a reasonable choice for a small experiment, but I expect the maintenance burden to grow as the app gets larger and the API grows.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://mozilla.github.io/uniffi-rs/latest/&quot;&gt;UniFFI&lt;/a&gt; is a tool that generates this layer. The tool does the generating, but it does not decide where the generated code sits inside the app. Part 3 is about choosing that place.&lt;/p&gt;
&lt;h2&gt;What UniFFI does and does not do&lt;/h2&gt;
&lt;p&gt;What UniFFI generates, as described in this section, comes from its documentation. The judgment that the risk of drift goes down, the account of what the tool leaves undecided, and the adapter structure and handling of generated files in the later sections are my own reading and design choices.&lt;/p&gt;
&lt;p&gt;UniFFI produces Swift and Kotlin calling code from the Rust functions and types chosen for exposure. The generated code reaches the Rust library through a C-compatible FFI. After the API is changed, the code can be generated again, so a wrapper per language does not have to be maintained by hand. Both platforms end up using the same domain functions, so I expect the risk of rules drifting apart to go down. The Swift output and the Kotlin output are separate artifacts that come from the same Rust API.&lt;/p&gt;
&lt;p&gt;The tool does not decide everything. Which functions to expose, what shape the DTOs take, how errors are represented, and how far the generated types are visible in the app are decided by the people building the app. Exposing Rust&apos;s internal model as it is can make it awkward to use on mobile.&lt;/p&gt;
&lt;h2&gt;Generated code goes behind an adapter&lt;/h2&gt;
&lt;p&gt;If a ViewModel or a ViewController calls the generated API directly and the generated types spread through the whole app, every change to the Rust API may require changes to the UI on both platforms. So each platform gets a thin adapter.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;iOS
  SudokuEngineClient protocol
    -&amp;gt; UniFFISudokuEngineClient
      -&amp;gt; generated Swift binding

Android
  SudokuEngineClient interface
    -&amp;gt; UniFFISudokuEngineClient
      -&amp;gt; generated Kotlin binding
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The app uses only the protocol on iOS and the interface on Android. Only &lt;code&gt;UniFFISudokuEngineClient&lt;/code&gt; touches the generated code. Generated types are not passed to the ViewModel. Swift passes its own DTOs or domain types, and Kotlin passes data classes.&lt;/p&gt;
&lt;p&gt;With this structure, a fake &lt;code&gt;SudokuEngineClient&lt;/code&gt; can be put into the unit tests of a ViewModel. Integration tests that really call Rust are set up separately.&lt;/p&gt;
&lt;h2&gt;Call size and types&lt;/h2&gt;
&lt;p&gt;I recommend handling one call per user action over calling many times on every render. The current state and the action go in, and the changed state comes back in one piece.&lt;/p&gt;
&lt;p&gt;The API below is an example written with JSON strings going in and out.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;start_game(request_json: String) -&amp;gt; String
apply_action(snapshot_json: String, action_json: String) -&amp;gt; String
validate_snapshot(snapshot_json: String) -&amp;gt; String
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JSON is not mandatory. UniFFI records, enums, and sequences are an option too. As the exposed types get more complex, though, keeping the meaning identical in the two languages can become hard.&lt;/p&gt;
&lt;p&gt;I prefer to start with simple DTOs and fixtures. The types are split more finely after measurement shows a performance problem. Making the FFI design complex first does not help if the real bottleneck is somewhere else. Numbers comparing the performance of the two approaches are not in this post.&lt;/p&gt;
&lt;h2&gt;What to look after on each platform&lt;/h2&gt;
&lt;p&gt;Using UniFFI does not make the two apps structurally the same. How things run inside the adapter is chosen to fit the runtime of that platform.&lt;/p&gt;
&lt;p&gt;On iOS, Swift concurrency, MainActor, and state updates in UIKit and SwiftUI are taken into account. Calls that use a lot of CPU are better handled off the main thread. The adapter manages a background queue or an async boundary, and screen updates happen back on the main actor. Packaging the Rust output as an XCFramework comes in part 4.&lt;/p&gt;
&lt;p&gt;ViewModel scope, the coroutine dispatcher, state updates in Compose, and the order of loading native libraries are the things to check on Android. If the Rust &lt;code&gt;.so&lt;/code&gt; fails to load, an error can occur at app start or on the first call. Whether it is packaged correctly for each ABI has to be confirmed too. For the concepts of connecting Kotlin and native code, see &lt;a href=&quot;https://developer.android.com/ndk/guides/jni-tips&quot;&gt;the Android JNI tips&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;What I generated and called in an example&lt;/h2&gt;
&lt;p&gt;I built this structure as a small example, and on October 4, 2026 I generated the bindings and called them. The workspace is small and only checks the structure. No real app is behind it. The environment is macOS 27.0.1, cargo 1.96.0, uniffi 0.29.5, and Swift 6.4. The binding crate only wraps and exports the functions of the core. Keeping the functions that parse and serialize JSON in the core crate as well is where the example departs from the design in part 2, to stay small.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uniffi::setup_scaffolding!();

#[uniffi::export]
pub fn start_game(request_json: String) -&amp;gt; String {
    sudoku_core::start_game_json(&amp;amp;request_json)
}

#[uniffi::export]
pub fn apply_action(snapshot_json: String, action_json: String, environment_json: String) -&amp;gt; String {
    sudoku_core::apply_action_json(&amp;amp;snapshot_json, &amp;amp;action_json, &amp;amp;environment_json)
}
// … validate_snapshot and generate_daily_puzzle have the same shape.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Unlike the API example above, &lt;code&gt;apply_action&lt;/code&gt; takes three arguments. &lt;code&gt;environment_json&lt;/code&gt; was added, following the contract in part 2. The bindings are generated from the built library. &lt;code&gt;uniffi-bindgen&lt;/code&gt; is not installed separately. It is a binary in the same crate.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cargo build -q -p sudoku-uniffi
# …
cargo run -q -p sudoku-uniffi --bin uniffi-bindgen -- generate \
  --library target/debug/libsudoku_uniffi.dylib --language swift --out-dir out/swift
cargo run -q -p sudoku-uniffi --bin uniffi-bindgen -- generate \
  --library target/debug/libsudoku_uniffi.dylib --language kotlin --no-format --out-dir out/kotlin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--no-format&lt;/code&gt; is on the Kotlin side because ktlint is not on this Mac. Generating without the option printed a warning that ktlint could not be found. &lt;code&gt;-q&lt;/code&gt; is the cargo option that reduces output. Four files were generated.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kotlin/uniffi/sudoku_uniffi/sudoku_uniffi.kt
swift/sudoku_uniffi.swift
swift/sudoku_uniffiFFI.h
swift/sudoku_uniffiFFI.modulemap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Swift file was 514 lines and the Kotlin file 1107 lines. In Swift the function names become &lt;code&gt;startGame(requestJson:)&lt;/code&gt;, &lt;code&gt;applyAction(snapshotJson:actionJson:environmentJson:)&lt;/code&gt;, and so on. I compiled the generated Swift file together with the calling code using &lt;code&gt;swiftc&lt;/code&gt;, linked the Rust library, and ran it on macOS. This is part of the calling code.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let env = #&quot;{&quot;locale&quot;:&quot;ko-KR&quot;}&quot;#
let snapshot = startGame(requestJson: #&quot;{&quot;seed&quot;:0}&quot;#)
// …
print(&quot;validate:&quot;, validateSnapshot(snapshotJson: snapshot))
// …
let conflict = applyAction(snapshotJson: snapshot, actionJson: #&quot;{&quot;type&quot;:&quot;set_value&quot;,&quot;row&quot;:0,&quot;col&quot;:2,&quot;value&quot;:5}&quot;#, environmentJson: env)
print(&quot;conflict:&quot;, conflict)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the output of that part.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;validate: {&quot;valid&quot;:true,&quot;errors&quot;:[]}
conflict: {&quot;ok&quot;:false,&quot;snapshot&quot;:null,&quot;effects&quot;:[],&quot;error&quot;:{&quot;code&quot;:&quot;conflict&quot;,&quot;message&quot;:&quot;the value conflicts with a row, column, or box&quot;}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That confirms a function called from Swift returning the result from Rust as a string. The run was on the macOS host, not on iOS, and no adapter or protocol was built.&lt;/p&gt;
&lt;h2&gt;Whether to commit the generated files&lt;/h2&gt;
&lt;p&gt;There are two options.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Commit the generated Swift and Kotlin files. They are easy to browse in the IDE, and a PR shows how the API changed. The large diff of generated code can be a burden in review.&lt;/li&gt;
&lt;li&gt;Generate at build time. Rust stays the single source. The generation steps have to be reproduced on a fresh clone and in CI, and the build can become unstable when the UniFFI CLI or Rust toolchain versions differ across development environments.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Early in an app project, especially while the connection between iOS and Xcode has not settled, committing is worth considering. In that case it is good to record the generation command and have CI check that regenerating produces no diff. For a project centered on a library, or when the amount of change is a burden, generating at build time is worth considering. Neither is a policy that fits every project. Whichever is chosen, I recommend reproducing binding generation in CI.&lt;/p&gt;
&lt;p&gt;How to manage generated files, along with the API boundary, the types, and the error representation, has to be decided per project. The options in this post are recommendations. What the example confirmed is generation and a Swift call on macOS. Compiling the Kotlin bindings and calling them on Android, and running on an iOS device, were not checked.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>iOS</category><category>Android</category><category>UniFFI</category><category>FFI</category></item><item><title>DevToolsSecurity on macOS and debugger authorization on unattended CI</title><link>https://jaemyeong.com/en/blog/macos-devtoolssecurity-enable-developer-mode/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/macos-devtoolssecurity-enable-developer-mode/</guid><description>Attaching a debugger on a Mac can show a password prompt, and unattended CI stalls on it. What DevToolsSecurity changes, read directly on macOS 27.0.1.</description><pubDate>Thu, 25 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When a debugger attaches to a program built in Xcode or the terminal on a Mac, or when Instruments starts, a &quot;Developer Tools Access&quot; prompt can appear and ask whether one process may control another. On my own Mac, typing the password ends it. On an unattended test or a headless CI/CD runner, the job stalls while that prompt waits for input.&lt;/p&gt;
&lt;p&gt;The command that removes the prompt ahead of time is &lt;code&gt;sudo DevToolsSecurity -enable&lt;/code&gt;. This post covers what the command changes and where to look when the prompt remains after enabling. What I confirmed with read-only commands on macOS 27.0.1 on October 4, 2026 is marked as such. I did not run &lt;code&gt;-enable&lt;/code&gt; or the group command again this time.&lt;/p&gt;
&lt;h2&gt;Why a debugger asks for authorization&lt;/h2&gt;
&lt;p&gt;macOS isolates the memory of each process. Stopping another process, reading or writing its memory, and injecting code into it are restricted.&lt;/p&gt;
&lt;p&gt;lldb and Instruments need access to the target process to analyze it. They get the target&apos;s Mach task port through &lt;code&gt;task_for_pid&lt;/code&gt; in XNU, and use that port to step line by line or dump memory. Without enough privilege, the request is denied or goes through an administrator password prompt. &lt;a href=&quot;https://developer.apple.com/documentation/security/authorization-services&quot;&gt;Authorization Services&lt;/a&gt; handles that authorization, and the rules are stored in &lt;code&gt;/var/db/auth.db&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;The rule that DevToolsSecurity changes&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;/usr/sbin/DevToolsSecurity&lt;/code&gt; changes the rule of a right named &lt;code&gt;system.privilege.taskport&lt;/code&gt;. The earlier version of this post explained that before enabling, the rule asks for the login password or root credentials, and that after enabling, it is tied to the &lt;code&gt;_developer&lt;/code&gt; group. Of these, I could not check the behavior before enabling this time.&lt;/p&gt;
&lt;p&gt;I read the rule in the enabled state with &lt;code&gt;security authorizationdb read system.privilege.taskport&lt;/code&gt;. These are the values I read (macOS 27.0.1).&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;class&lt;/code&gt; is &lt;code&gt;user&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;group&lt;/code&gt; is &lt;code&gt;_developer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;authenticate-user&lt;/code&gt; is true, and &lt;code&gt;allow-root&lt;/code&gt; is false.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;timeout&lt;/code&gt; is 36000.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An earlier version of this post said the class after enabling is &lt;code&gt;rule&lt;/code&gt;, but the value I read this time was &lt;code&gt;user&lt;/code&gt;. The comment on the rule says this right is used by &lt;code&gt;task_for_pid&lt;/code&gt; and applies only when the requesting program and the target program are run by the same user. It does not authorize access to another user&apos;s program. I could not check the values before enabling, because the Mac I checked already had it enabled.&lt;/p&gt;
&lt;h2&gt;Use -status to check the state&lt;/h2&gt;
&lt;p&gt;Running &lt;code&gt;/usr/sbin/DevToolsSecurity&lt;/code&gt; with no argument does not show the state. According to the usage text, the default action is &lt;code&gt;-enable&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is the usage text, printed by passing an option that does not exist.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ /usr/sbin/DevToolsSecurity -help
DevToolsSecurity: unrecognized option `-help&apos;
Changes the security authorization policies for developer systems
Usage: /usr/sbin/DevToolsSecurity [-verbose] [-enable | -disable | -status]
    -enable  :: (default) enable developer mode policies
    -disable :: return policies to system default
    -status  :: prints out whether or not developer mode policies are in effect
    -verbose :: makes output more verbose
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To see only the state, use &lt;code&gt;-status&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ /usr/sbin/DevToolsSecurity -status
Developer mode is currently enabled.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The earlier version said to run the command with no argument and look for &lt;code&gt;enabled&lt;/code&gt;. That explanation was wrong, and it is corrected as shown above.&lt;/p&gt;
&lt;h2&gt;When the prompt remains after enabling&lt;/h2&gt;
&lt;p&gt;After &lt;code&gt;-enable&lt;/code&gt;, lldb in the terminal or C++ debugging in VS Code can still show the prompt or fail with &lt;code&gt;attach failed&lt;/code&gt;. The place to look is whether the account is in the &lt;code&gt;_developer&lt;/code&gt; group.&lt;/p&gt;
&lt;p&gt;This command adds the currently logged-in account to the group.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 현재 로그인된 계정을 _developer 그룹에 강제 멤버십 추가
sudo dscl . append /Groups/_developer GroupMembership $(whoami)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean comment says the command adds the currently logged-in account to the &lt;code&gt;_developer&lt;/code&gt; group.&lt;/p&gt;
&lt;p&gt;After adding, reopening the terminal or logging out and back in is recommended. The members of the group can be listed with &lt;code&gt;dscl . read /Groups/_developer GroupMembership&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I did not compare causes myself for this part. On the Mac I checked, the &lt;code&gt;_developer&lt;/code&gt; group had no &lt;code&gt;GroupMembership&lt;/code&gt; key at all.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ dscl . read /Groups/_developer GroupMembership
No such key: GroupMembership
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I did not check how attaching a debugger behaves on this Mac, where the &lt;code&gt;GroupMembership&lt;/code&gt; key was absent, or under exactly which condition group membership is needed. There is also no basis for saying that joining the group resolves every &lt;code&gt;attach failed&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;What happens on an unattended runner&lt;/h2&gt;
&lt;p&gt;Mobile release automation uses a Mac mini or a macOS virtual machine in the cloud as a runner. The problem case is when this setup step is omitted after a fresh Xcode install or an OS upgrade.&lt;/p&gt;
&lt;p&gt;When Appium or an Xcode UI test installs the test app and tries to attach for debugging, the authorization request appears in the GUI in the background. Nobody is at the runner to answer. The job ends only after the configured time limit passes, and a runner with no time limit keeps waiting. A result from reproducing this flow and timing it is not included here.&lt;/p&gt;
&lt;p&gt;So the script that prepares a runner runs two things together.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 개발자 도구 액세스 허용 활성화
sudo /usr/sbin/DevToolsSecurity -enable

# 빌드 실행 에이전트 계정을 개발자 그룹에 병합
sudo dscl . append /Groups/_developer GroupMembership build_agent_user
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first comment says &quot;enable developer tools access,&quot; and the second says &quot;add the build agent account to the developer group.&quot;&lt;/p&gt;
&lt;p&gt;Replace &lt;code&gt;build_agent_user&lt;/code&gt; with the account name the runner uses. Putting the enable command into the runner setup guide as well keeps it from being missed on a new machine. There is no basis for saying that every server needs both commands. I also did not measure how installing a test app on a device or simulator connects to task port authorization on the Mac.&lt;/p&gt;
&lt;h2&gt;What is given up in security&lt;/h2&gt;
&lt;p&gt;This setting removes one authorization step. To that extent, developer tools can handle the memory of other processes run by the same user more widely. Access to the machine should be controlled, and binaries or code of unknown origin should not run on it.&lt;/p&gt;
&lt;p&gt;Developer Mode on an iPhone or iPad targets something else. &lt;a href=&quot;https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device&quot;&gt;Developer Mode on a device&lt;/a&gt; allows locally installed apps, such as an app built and run from Xcode, to run on that device. It does not affect apps installed from the App Store or TestFlight. The setting in this post is the permission for tools such as lldb to control other processes on a Mac.&lt;/p&gt;
&lt;p&gt;The values read this time come from one Mac running macOS 27.0.1, and because it was already enabled, the values before enabling could not be checked. A comparison across macOS versions is not included here either. I also could not confirm in the documentation exactly which signature the condition &quot;only signed developer tools are allowed&quot; refers to.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>macOS</category><category>Xcode</category><category>DeveloperMode</category><category>Debugging</category><category>CI-CD</category></item><item><title>Why Hangul file names fall apart into jamo, and how NFC fixes it</title><link>https://jaemyeong.com/en/blog/macos-hangul-jaso-nfd-nfc/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/macos-hangul-jaso-nfd-nfc/</guid><description>A Hangul file name made on macOS can look decomposed or count as a different name elsewhere. The cause is the Unicode normalization form, checked with code.</description><pubDate>Thu, 25 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When a file with a Hangul name made on macOS is sent to Windows or Linux, the jamo in the name (the letters that make up each Hangul syllable) can appear separated. It is not only a display problem. Git can detect a file whose content did not change as renamed, and in CI the resource path the code looks for may not match the actual file name. The same can happen when a file travels as a mail attachment, through a messenger, in a ZIP, or through cloud storage.&lt;/p&gt;
&lt;p&gt;The cause is that the same character has two Unicode representations. This post confirms the difference between the two with code points, and covers how to bring file names and strings to one of them.&lt;/p&gt;
&lt;p&gt;Errors in the code points and the examples were present in the earlier version of this post. I corrected them by running string normalization myself on October 4, 2026, on macOS 27.0.1, with Python 3.14.5, Swift 6.4, and Node.js 24.14.1. What I checked is string normalization, plus the &lt;code&gt;convmv&lt;/code&gt; dry run and rename on one file in a temporary folder. The &lt;code&gt;convmv&lt;/code&gt; result is in the section below. I did not run Automator or Git, and I did not compare file systems. The definition of the normalization forms comes from &lt;a href=&quot;https://www.unicode.org/reports/tr15/&quot;&gt;UAX #15&lt;/a&gt;, and the advice on where to normalize is my own reading.&lt;/p&gt;
&lt;h2&gt;Two representations of the same character&lt;/h2&gt;
&lt;p&gt;NFD is the canonically decomposed form of a character, and NFC is the canonically composed form. For the syllable 한 it looks like this.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;NFC: the single code point U+D55C (HANGUL SYLLABLE HAN).&lt;/li&gt;
&lt;li&gt;NFD: the three code points U+1112, U+1161, and U+11AB. In order, they are HANGUL CHOSEONG HIEUH, HANGUL JUNGSEONG A, and HANGUL JONGSEONG NIEUN.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is the Python code I used to check, with its output.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import unicodedata as u

print([f&quot;U+{ord(c):04X}&quot; for c in u.normalize(&quot;NFD&quot;, &quot;한&quot;)])
print(u.normalize(&quot;NFC&quot;, &quot;\u1112\u1161\u11ab&quot;))
print(u.normalize(&quot;NFC&quot;, &quot;\u1106\u1161\u11ab&quot;))
print(u.normalize(&quot;NFC&quot;, &quot;ㅎㅏㄴ&quot;), u.normalize(&quot;NFD&quot;, &quot;ㅎㅏㄴ&quot;))
print([f&quot;U+{ord(c):04X}&quot; for c in &quot;ㅎㅏㄴ&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Running the Python code prints the following.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[&apos;U+1112&apos;, &apos;U+1161&apos;, &apos;U+11AB&apos;]
한
만
ㅎㅏㄴ ㅎㅏㄴ
[&apos;U+314E&apos;, &apos;U+314F&apos;, &apos;U+3134&apos;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Hangul strings in the code are the syllable 한 and the three separately typed letters that are explained in the next section.&lt;/p&gt;
&lt;p&gt;The earlier version gave the initial consonant as U+1106. U+1106 is HANGUL CHOSEONG MIEUM. As the third line of the output shows, composing U+1106, U+1161, and U+11AB gives 만 (U+B9CC), not 한.&lt;/p&gt;
&lt;h2&gt;The visible separated letters are not the decomposed form&lt;/h2&gt;
&lt;p&gt;To explain the decomposed form, it is tempting to type the letters separately and show them that way. The earlier version did so. But the letters typed on a keyboard are U+314E, U+314F, and U+3134, which are compatibility jamo, a different set of characters. They are not the same characters as U+1112, U+1161, and U+11AB used in the decomposed form.&lt;/p&gt;
&lt;p&gt;The fourth line of the output above shows the difference. Applying NFC to the three compatibility letters does not give 한, and applying NFD leaves them as they are. The file name that the earlier version used as a decomposed example, &lt;code&gt;ㄱㅓㅁㅅㅐㄱㅎㅘㅁㅕㄴ.png&lt;/code&gt;, is also written in compatibility jamo, so normalization does not turn it into &lt;code&gt;검색화면.png&lt;/code&gt;. The real decomposed form of &lt;code&gt;검색화면.png&lt;/code&gt; contains final-consonant jamo such as U+11B7, U+11A8, and U+11AB.&lt;/p&gt;
&lt;p&gt;For that reason, this post writes the decomposed form as code points and escapes, not as visible letters.&lt;/p&gt;
&lt;h2&gt;Where the problem shows up&lt;/h2&gt;
&lt;p&gt;The earlier version explained that macOS stores file names in the decomposed form and that Windows and Linux use the composed form. That explanation can vary with the file system, the program that draws the screen, and the transfer tool, and I did not check it this time. The two cases below are not reproduced from real logs either. They describe paths by which the problem can happen.&lt;/p&gt;
&lt;p&gt;With Git, the case to think about is this. A file named &lt;code&gt;검색화면.png&lt;/code&gt; is added and pushed on macOS and pulled on Windows. If the normalization form changes along the way, a file with the same content can be detected as renamed. Then diffs appear with no change in content, unnecessary commits pile up, and conflicts can become more likely. The outcome can differ with the Git configuration and with whether a conversion happens in transfer.&lt;/p&gt;
&lt;p&gt;In builds, image and Markdown paths on the web or in iOS are affected. In a Linux container such as GitHub Actions, a path string written in the composed form in code and a file name stored in the decomposed form have different bytes. So the file may not be found.&lt;/p&gt;
&lt;h2&gt;Renaming files to NFC with convmv&lt;/h2&gt;
&lt;p&gt;Existing file names can be changed in bulk with &lt;a href=&quot;https://www.j3e.de/linux/convmv/&quot;&gt;convmv&lt;/a&gt;. I ran the dry-run command and the apply command below on October 4, 2026 with convmv 2.06, in a temporary folder that held one &lt;code&gt;.txt&lt;/code&gt; file with a decomposed name. convmv was already installed, so I did not run the install command again.&lt;/p&gt;
&lt;p&gt;Install it with &lt;a href=&quot;https://formulae.brew.sh/formula/convmv&quot;&gt;Homebrew&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brew install convmv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;First preview what would change. This command keeps the encoding as UTF-8, converts to NFC, and searches subdirectories.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;convmv -f utf8 -t utf8 --nfc -r [변경을_원하는_디렉토리_경로]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bracketed Korean placeholder stands for the path of the directory to change.&lt;/p&gt;
&lt;p&gt;Nothing is renamed at this step. The tool shows each planned rename in the form &lt;code&gt;mv &quot;old name&quot; &quot;new name&quot;&lt;/code&gt; and prints &lt;code&gt;No changes to your files done. Would have converted 1 files in 0 seconds.&lt;/code&gt; and &lt;code&gt;Use --notest to finally rename the files.&lt;/code&gt; That differs from the wording in the earlier version of this post. Checking the file name after the run showed it was still decomposed. After checking the list, add &lt;code&gt;--notest&lt;/code&gt; to apply it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;convmv -f utf8 -t utf8 --nfc -r --notest [변경을_원하는_디렉토리_경로]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Running it with &lt;code&gt;--notest&lt;/code&gt; printed &lt;code&gt;Ready! I converted 1 files in 0 seconds.&lt;/code&gt;, and I confirmed that the file name had changed to the composed form (NFC). The name went from 10 code points to 6.&lt;/p&gt;
&lt;h3&gt;Adding it to the Finder context menu&lt;/h3&gt;
&lt;p&gt;For frequent use, a Quick Action can be made in Automator.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a new Quick Action in Automator.&lt;/li&gt;
&lt;li&gt;Set the input to files and folders, and the application to Finder.&lt;/li&gt;
&lt;li&gt;Add a Run Shell Script action and set Pass input to as arguments.&lt;/li&gt;
&lt;li&gt;Put in the script below.&lt;/li&gt;
&lt;li&gt;Save it under a name such as &quot;Compose Hangul jamo,&quot; and it appears in the Finder context menu.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;for i in &quot;$@&quot;; do
    /opt/homebrew/bin/convmv -f utf-8 -t utf-8 --nfc --notest &quot;$i&quot;
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/opt/homebrew&lt;/code&gt; is the Homebrew path on Apple Silicon Macs from the M1 on. If the install location differs, the path has to be changed. This script has no &lt;code&gt;-r&lt;/code&gt;, so unlike the command above it does not process the items inside a selected folder.&lt;/p&gt;
&lt;h2&gt;Normalizing in code&lt;/h2&gt;
&lt;p&gt;If the form is unified at the boundary where file names or user input go into a database or storage, later comparisons become simple.&lt;/p&gt;
&lt;p&gt;Swift has &lt;a href=&quot;https://developer.apple.com/documentation/foundation/nsstring/precomposedstringwithcanonicalmapping&quot;&gt;precomposedStringWithCanonicalMapping&lt;/a&gt; for the composed form and &lt;code&gt;decomposedStringWithCanonicalMapping&lt;/code&gt; for the decomposed form. This is an example I ran, with the decomposed form written as escapes.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import Foundation

let decomposed = &quot;\u{1112}\u{1161}\u{11AB}&quot;   // NFD: U+1112 U+1161 U+11AB
let composed = decomposed.precomposedStringWithCanonicalMapping
let back = composed.decomposedStringWithCanonicalMapping

func scalars(_ s: String) -&amp;gt; String {
    s.unicodeScalars.map { String(format: &quot;U+%04X&quot;, $0.value) }.joined(separator: &quot; &quot;)
}

print(scalars(decomposed))
print(scalars(composed))
print(scalars(back))
print(decomposed == composed)
print(Array(decomposed.utf8) == Array(composed.utf8))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Swift code prints these code points.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;U+1112 U+1161 U+11AB
U+D55C
U+1112 U+1161 U+11AB
true
false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fourth and fifth lines deserve attention. A Swift &lt;code&gt;String&lt;/code&gt; compared with &lt;code&gt;==&lt;/code&gt; treats the decomposed and composed forms as equal. Their UTF-8 bytes, however, differ. The difference stays hidden while comparing inside Swift, and appears at the moment the string is written out as bytes and used as a file name or a key.&lt;/p&gt;
&lt;p&gt;In JavaScript, use &lt;code&gt;normalize(&quot;NFC&quot;)&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const decomposed = &quot;\u1112\u1161\u11AB&quot;; // NFD: U+1112 U+1161 U+11AB
const composed = decomposed.normalize(&quot;NFC&quot;);

const codePoints = (s) =&amp;gt; [...s].map((c) =&amp;gt; &quot;U+&quot; + c.codePointAt(0).toString(16).toUpperCase().padStart(4, &quot;0&quot;)).join(&quot; &quot;);

console.log(codePoints(decomposed));
console.log(codePoints(composed));
console.log(decomposed === composed);
console.log(decomposed.normalize(&quot;NFC&quot;) === composed);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Node.js code prints this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;U+1112 U+1161 U+11AB
U+D55C
false
true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;===&lt;/code&gt; operator in JavaScript treats the decomposed and composed forms as different. They become equal only after normalization.&lt;/p&gt;
&lt;p&gt;In Python it is &lt;code&gt;unicodedata.normalize&lt;/code&gt;. This function only returns a changed string. It does not rename a file on disk.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
import sys
import unicodedata

def sanitize_nfc(path):
    return unicodedata.normalize(&apos;NFC&apos;, path)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Places to check&lt;/h2&gt;
&lt;p&gt;Using only English file names avoids the problem, but for a project that uses Hangul names, it is better to check the following.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Whether decomposed names have entered a repository that several people use.&lt;/li&gt;
&lt;li&gt;Whether a build on Windows or Linux finds resources at Hangul paths.&lt;/li&gt;
&lt;li&gt;How Git detects renames. &lt;code&gt;core.ignorecase&lt;/code&gt; is a setting about letter case, and there is no basis for saying it solves normalization.&lt;/li&gt;
&lt;li&gt;Whether the boundary that receives uploads normalizes and validates names.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Whether &lt;code&gt;convmv&lt;/code&gt; converts every file, and whether names stay intact in transfer after the conversion, was not checked this time. The same goes for what happens when a converted name collides with an existing file, for permission problems, and for differences between file systems. What I confirmed by running is string normalization in Python, Swift, and JavaScript, plus the &lt;code&gt;convmv&lt;/code&gt; dry run and rename on one file in a temporary folder.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>macOS</category><category>Unicode</category><category>Troubleshooting</category><category>Git</category></item><item><title>Rust shared core, part 2: keep the API across the boundary coarse</title><link>https://jaemyeong.com/en/blog/rust-shared-core-02-ffi-api-design/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/rust-shared-core-02-ffi-api-design/</guid><description>Types that suit a Rust engine differ from types that are easy to use from Swift, Kotlin, and TypeScript. Expose coarse action-level functions under a contract.</description><pubDate>Thu, 25 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When a calculation engine shared by several platforms is written in Rust, the data types that suit the inside of the engine differ from the types that are easy to handle in an app. Following the convenience of each platform can also pull the design of the core toward outside concerns.&lt;/p&gt;
&lt;p&gt;Part 2 asks two questions: how large the externally called functions should be, and which data types the values crossing the boundary should use. Sudoku is the running example.&lt;/p&gt;
&lt;h2&gt;The inside model and the outside model&lt;/h2&gt;
&lt;p&gt;Inside Rust, the rules are expressed with types such as &lt;code&gt;Grid&lt;/code&gt;, &lt;code&gt;Cell&lt;/code&gt;, &lt;code&gt;CandidateSet&lt;/code&gt;, &lt;code&gt;PuzzleSeed&lt;/code&gt;, &lt;code&gt;Difficulty&lt;/code&gt;, &lt;code&gt;Move&lt;/code&gt;, &lt;code&gt;GameState&lt;/code&gt;, and &lt;code&gt;ValidationError&lt;/code&gt;. Enums, pattern matching, ownership, and traits can be used as they are.&lt;/p&gt;
&lt;p&gt;Crossing the boundary changes the picture. Generics, lifetimes, nested enums, complex collections, and borrowed references are represented differently when passed to another language. Even when the generator supports something, whether the API is easy to understand and convenient to test and debug has to be judged separately. How bindings are made is in the &lt;a href=&quot;https://mozilla.github.io/uniffi-rs/latest/&quot;&gt;UniFFI&lt;/a&gt; and &lt;a href=&quot;https://wasm-bindgen.github.io/wasm-bindgen/&quot;&gt;wasm-bindgen&lt;/a&gt; documentation. Sizing the calls and the contract as shown below is my own design choice, not a requirement of that documentation. An example that implements this contract and was actually run, with its environment, is in the section &quot;The contract, checked with an example.&quot;&lt;/p&gt;
&lt;p&gt;So the roles are split in two.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudoku-core
  - Rust 내부 도메인 모델
  - Rust 테스트
  - 순수 계산 로직

sudoku-uniffi / sudoku-wasm
  - 외부 공개 DTO
  - serialization
  - error 변환
  - coarse-grained 함수
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean lines under &lt;code&gt;sudoku-core&lt;/code&gt; read &quot;internal Rust domain model,&quot; &quot;Rust tests,&quot; and &quot;pure calculation logic.&quot; Under the binding crates, they read &quot;externally exposed DTOs&quot; and &quot;error conversion.&quot;&lt;/p&gt;
&lt;p&gt;The core is designed so that it does not depend on a binding tool, and the domain logic is kept as independent as possible. I recommend putting DTOs, serialization, error conversion, and coarse functions in the binding crates.&lt;/p&gt;
&lt;h2&gt;A few large functions over many small ones&lt;/h2&gt;
&lt;p&gt;Exposing functions that each handle one cell gives this shape.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;get_cell(row, col)
set_value(row, col, value)
toggle_note(row, col, digit)
is_conflict(row, col)
get_candidates(row, col)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That increases the number of trips across the boundary. The state the app holds and the state the engine holds can drift apart, and calling per cell for rendering makes the flow hard to trace.&lt;/p&gt;
&lt;p&gt;The alternative is to expose units of work.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;start_game(request) -&amp;gt; GameSnapshot
apply_action(snapshot, action) -&amp;gt; TransitionResult
validate_snapshot(snapshot) -&amp;gt; ValidationReport
generate_daily_puzzle(request) -&amp;gt; PuzzleEnvelope
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The engine computes how the state changes for an action, and the app converts the returned value into state for the screen. I expect fewer calls and a clearer scope for tests. An experiment comparing the two approaches is not part of this post.&lt;/p&gt;
&lt;p&gt;This structure is the same as a reducer.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;현재 상태 + 사용자 액션 + 설정
  -&amp;gt; 다음 상태 + 효과 + 에러 또는 경고
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The block reads: current state plus user action plus settings gives the next state plus effects plus an error or a warning.&lt;/p&gt;
&lt;p&gt;This model fits the ViewModel on iOS and Android and the state layer on the web. Each platform can convert the result into UI state.&lt;/p&gt;
&lt;h2&gt;Data types that cross the boundary&lt;/h2&gt;
&lt;p&gt;The candidates for DTOs are simple ones: string, integer, boolean, a flat array, an enum with clear cases, an optional value, and a JSON string when needed.&lt;/p&gt;
&lt;p&gt;JSON has a benefit and a cost. It is convenient for passing state or actions in large units. In return it has a serialization cost, and type safety at compile time can get weaker. Calls made per frame for rendering and calls made when the user acts are judged separately. For a unit that applies one user action, the gain from a simpler boundary can outweigh the serialization cost. Numbers that measure how large each cost is are not in this post.&lt;/p&gt;
&lt;p&gt;A contract built on JSON has this shape.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apply_action(
  snapshot_json: String,
  action_json: String,
  environment_json: String
) -&amp;gt; transition_json: String
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the app side, the shape of that JSON can be expressed with Codable, kotlinx.serialization, Zod, or TypeScript types, and the Rust side handles it with serde. I suggest that the team keep a JSON schema and fixtures together. In my view, fixtures can be a more accurate reference than documents.&lt;/p&gt;
&lt;h2&gt;Errors come back as values&lt;/h2&gt;
&lt;p&gt;When Rust&apos;s &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt; is connected to the outside, its representation has to be decided early. Putting error handling off makes it more expensive later. The options are a thrown error, a result object, a nullable, and an error in a callback. For a domain engine, I prefer returning an explicit error object.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TransitionResult
  - ok: Boolean
  - snapshot: String?
  - effects: [Effect]
  - error: EngineError?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The roles are divided like this. Expressing why something failed is the job of the core. Whether to ignore the action, show an alert, give haptic feedback, or send an analytics event is chosen by the app.&lt;/p&gt;
&lt;p&gt;Messages are divided as well. Codes such as &lt;code&gt;INVALID_MOVE&lt;/code&gt;, &lt;code&gt;PUZZLE_ALREADY_COMPLETED&lt;/code&gt;, and &lt;code&gt;SEED_OUT_OF_RANGE&lt;/code&gt; are stable machine-readable codes, and they are separated from the text people read. Korean and English translation, and localization, belong to the app.&lt;/p&gt;
&lt;h2&gt;The contract, checked with an example&lt;/h2&gt;
&lt;p&gt;On October 4, 2026, I implemented this contract in a small example and ran it. This small workspace exists to check the structure and is not the code of a real app. The environment is cargo 1.96.0, serde 1.0.229, and serde_json 1.0.151. The function at the boundary takes three strings and returns one. In the example this function sits inside the &lt;code&gt;sudoku-core&lt;/code&gt; crate. Under the structure laid out earlier, serialization and error conversion belong to the binding side, so this is a place where the example departs from the design to stay small. A proper split would move the JSON-facing functions into the binding crates, or into a separate crate shared by the two bindings.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pub fn apply_action_json(snapshot_json: &amp;amp;str, action_json: &amp;amp;str, environment_json: &amp;amp;str) -&amp;gt; String {
    // 이 예제의 규칙은 environment 를 쓰지 않는다. 형식만 확인한다.
    if let Err(e) = serde_json::from_str::&amp;lt;Environment&amp;gt;(environment_json) {
        return to_json(&amp;amp;fail(&quot;invalid_environment&quot;, &amp;amp;e.to_string()));
    }
    let result = match (serde_json::from_str(snapshot_json), serde_json::from_str(action_json)) {
        (Ok(snapshot), Ok(action)) =&amp;gt; apply_action(&amp;amp;snapshot, &amp;amp;action),
        (Err(e), _) =&amp;gt; fail(&quot;invalid_snapshot&quot;, &amp;amp;e.to_string()),
        (_, Err(e)) =&amp;gt; fail(&quot;invalid_action&quot;, &amp;amp;e.to_string()),
    };
    to_json(&amp;amp;result)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Korean comment says that the rules of this example do not use the environment and only its format is checked. When the input cannot be parsed, the function does not throw. It returns a result of the same shape. &lt;code&gt;fail&lt;/code&gt; builds a result with &lt;code&gt;ok&lt;/code&gt; set to false and a code and message in &lt;code&gt;error&lt;/code&gt;, and &lt;code&gt;to_json&lt;/code&gt; serializes with serde_json. Below is the output of five calls through the Wasm package from Node. In order they are an action that succeeds, an action that conflicts, an action on a given cell, an action that cannot be parsed, and the validation of a broken snapshot. Long lines are cut at 150 characters and marked with &lt;code&gt;…&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;valid   : {&quot;ok&quot;:true,&quot;snapshot&quot;:&quot;{\&quot;version\&quot;:1,\&quot;seed\&quot;:0,\&quot;givens\&quot;:\&quot;530070000600195000098000060800060003400803001700020006060000280000419005000080079\&quot;,\&quot;ce …
conflict: {&quot;ok&quot;:false,&quot;snapshot&quot;:null,&quot;effects&quot;:[],&quot;error&quot;:{&quot;code&quot;:&quot;conflict&quot;,&quot;message&quot;:&quot;the value conflicts with a row, column, or box&quot;}}
given   : {&quot;ok&quot;:false,&quot;snapshot&quot;:null,&quot;effects&quot;:[],&quot;error&quot;:{&quot;code&quot;:&quot;given_cell&quot;,&quot;message&quot;:&quot;a given cell cannot be changed&quot;}}
bad json: {&quot;ok&quot;:false,&quot;snapshot&quot;:null,&quot;effects&quot;:[],&quot;error&quot;:{&quot;code&quot;:&quot;invalid_action&quot;,&quot;message&quot;:&quot;unknown variant `fly`, expected `set_value` or `clear` at line 1  …
validate: {&quot;valid&quot;:false,&quot;errors&quot;:[{&quot;code&quot;:&quot;invalid_snapshot&quot;,&quot;message&quot;:&quot;missing field `version` at line 1 column 2&quot;}]}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this example, when &lt;code&gt;ok&lt;/code&gt; is true, &lt;code&gt;snapshot&lt;/code&gt; holds the JSON string of the next state and &lt;code&gt;error&lt;/code&gt; is null. When &lt;code&gt;ok&lt;/code&gt; is false, &lt;code&gt;snapshot&lt;/code&gt; is null and &lt;code&gt;error&lt;/code&gt; is present. The error codes are lowercase, such as &lt;code&gt;conflict&lt;/code&gt;, &lt;code&gt;given_cell&lt;/code&gt;, and &lt;code&gt;out_of_range&lt;/code&gt;, and the message exists in English only. No translation was added.&lt;/p&gt;
&lt;h2&gt;Keeping the contract with versions and fixtures&lt;/h2&gt;
&lt;p&gt;After an app ships on the App Store and the Play Store, different releases stay on different devices. The web and mobile also update at different speeds. So I recommend putting a version in the request and the response, and keeping fixtures per version.&lt;/p&gt;
&lt;p&gt;When there is a breaking change, the Rust core, the Swift adapter, the Kotlin adapter, and the web package are checked in the same PR. The diff of generated bindings can be large and hard to read. I recommend confirming the contract from the wrapper or the schema in review, not from that diff alone. Two items can go into the PR template: a rule that a fixture is added when the public API changes, and a question on whether the tests of the Swift, Kotlin, and TypeScript adapters changed. The request and response fixtures serve directly as contract tests.&lt;/p&gt;
&lt;p&gt;I think a simple outside contract also helps the work of connecting UniFFI, wasm-bindgen, XCFramework, and the Android &lt;code&gt;.so&lt;/code&gt;. Calling this API from iOS and Android through UniFFI is the subject of part 3.&lt;/p&gt;
&lt;p&gt;That is the shape of the design, and some things remain undecided. The fixture tests were run in the example from both Rust and Node. Five Rust tests and two Node tests passed. Adapter tests in Swift and Kotlin were not written. The combinations of &lt;code&gt;ok&lt;/code&gt;, &lt;code&gt;snapshot&lt;/code&gt;, and &lt;code&gt;error&lt;/code&gt; are what I observed in the example, and they are not written down as a contract document.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>iOS</category><category>Android</category><category>WebAssembly</category><category>FFI</category></item><item><title>Rust shared core, part 1: what to share and where to draw the line</title><link>https://jaemyeong.com/en/blog/rust-shared-core-01-architecture-boundary/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/rust-shared-core-01-architecture-boundary/</guid><description>Building for iOS, Android, and the web means writing the same rules three times. Narrow what Rust shares to rules that must give the same result, in 3 layers.</description><pubDate>Tue, 23 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Building for three platforms (iOS, Android, and the web) produces three copies of the logic. It is natural for the UI, the storage method, and the lifecycle to differ by platform. The trouble starts when puzzle rules, state changes, offline decisions, price calculation, cryptography, and sync conflict handling are also implemented three times. Then defects have to be managed per platform as well.&lt;/p&gt;
&lt;p&gt;I consider sharing logic through Rust a practical choice as of 2026. The focus is not on merging the whole app. It is on extracting an engine that owns the common rules. As the first part of a series, this post covers what to share and where to draw the boundary.&lt;/p&gt;
&lt;p&gt;What is written here is a design proposal. Sudoku is an example for explanation, and this post does not include measured results or benchmarks from a specific app. Whether this structure really builds is something I did check with a small example on October 4, 2026, and the result and the tool versions are in the section &quot;The structure, built for real.&quot; The Rust &lt;a href=&quot;https://doc.rust-lang.org/reference/linkage.html&quot;&gt;linkage&lt;/a&gt;, &lt;a href=&quot;https://mozilla.github.io/uniffi-rs/latest/&quot;&gt;UniFFI&lt;/a&gt;, and &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/WebAssembly&quot;&gt;WebAssembly&lt;/a&gt; documentation explains what each tool does. How to split the layers and the criterion for sharing are my own design choices.&lt;/p&gt;
&lt;h2&gt;Why it gets complicated when Rust owns everything&lt;/h2&gt;
&lt;p&gt;Each platform has constraints to follow.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;iOS: the UIKit and SwiftUI lifecycle, MainActor, App Extensions, and App Store review&lt;/li&gt;
&lt;li&gt;Android: Activity, ViewModel, Compose state, Gradle packaging, and Play policy&lt;/li&gt;
&lt;li&gt;Web: routing, hydration, browser storage, the bundler, and the deployment target&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the Rust side takes on UI state, analytics events, storage I/O, networking, and permissions, it takes on these constraints along with them. So what to share is decided by how the maintenance responsibility is divided.&lt;/p&gt;
&lt;h2&gt;What goes into the engine and what stays in the app&lt;/h2&gt;
&lt;p&gt;With Sudoku as the example, the candidates for the engine are these.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Puzzle generation and the solver&lt;/li&gt;
&lt;li&gt;Judging user input&lt;/li&gt;
&lt;li&gt;Changes to notes and values&lt;/li&gt;
&lt;li&gt;Computing the seed of the daily puzzle&lt;/li&gt;
&lt;li&gt;Rating the difficulty&lt;/li&gt;
&lt;li&gt;Computing score and streak&lt;/li&gt;
&lt;li&gt;A reducer that can replay actions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What stays in the app is rendering, accessibility, ads, payments, push notifications, the analytics SDK, the choice of storage, network retries, and the setup for review and release.&lt;/p&gt;
&lt;p&gt;The criterion is not whether the code looks similar. It is whether the result has to be the same. The valid and invalid judgment for the same board and the same input has to match across platforms, and the daily puzzle that comes from the same seed has to match.&lt;/p&gt;
&lt;p&gt;The presentation can differ. On iOS a UIKit collection view may feel natural, and on Android Compose state may fit well. Keyboard shortcuts and pointer interaction on the web have to be designed separately. Sharing too much can cost the interaction that fits each platform.&lt;/p&gt;
&lt;p&gt;Candidates for the core are filtered by four conditions.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The result is determined by the input alone.&lt;/li&gt;
&lt;li&gt;It is not tied directly to the current time, the locale, storage, or the network.&lt;/li&gt;
&lt;li&gt;It knows nothing about a UI framework or a platform SDK.&lt;/li&gt;
&lt;li&gt;It can be tested with Rust alone.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A candidate that does not meet the conditions is reconsidered. How to pass a time value for time-related candidates such as the daily seed or the streak is not covered in this post.&lt;/p&gt;
&lt;h2&gt;A structure in three layers&lt;/h2&gt;
&lt;p&gt;I propose three layers: domain, binding, and app.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;core/
  sudoku-rs/
    crates/
      sudoku-core/      # 순수 Rust 도메인 로직
      sudoku-uniffi/    # iOS/Android 바인딩 표면
      sudoku-wasm/      # WebAssembly 바인딩 표면

apps/
  ios/                  # Swift/UIKit 또는 SwiftUI 앱
  android/              # Kotlin/Compose 앱
  web/                  # TypeScript/React 앱
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sudoku-core&lt;/code&gt; is centered on Rust&apos;s own internal model. It keeps out the representation constraints of Swift, Kotlin, TypeScript, and FFI as far as possible, and the tests are concentrated here.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sudoku-uniffi&lt;/code&gt; is the conversion boundary for mobile. It manages the DTOs shaped for Swift and Kotlin, error mapping, serialization, and the names of public functions.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sudoku-wasm&lt;/code&gt; is the calling boundary on the web side, using wasm-bindgen or wasm-pack. Its goal is to provide a TypeScript API and a package that the app imports.&lt;/p&gt;
&lt;p&gt;With this split, the core is less tied to the binding tools. Even when the way UniFFI generates code or the web bundler setup changes, I expect the domain rules to stay relatively stable.&lt;/p&gt;
&lt;h2&gt;The app does not call generated code directly&lt;/h2&gt;
&lt;p&gt;If a ViewController, a ViewModel, a Composable, or a React component is tied directly to generated code, a change in a function name or a DTO affects the whole UI. So the app has a layer in between.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;iOS ViewModel
  -&amp;gt; SudokuEngineClient protocol
    -&amp;gt; UniFFI generated binding
      -&amp;gt; Rust core

Android ViewModel
  -&amp;gt; SudokuEngineClient interface
    -&amp;gt; UniFFI generated binding
      -&amp;gt; Rust core

Web state layer
  -&amp;gt; SudokuEngineClient
    -&amp;gt; wasm package
      -&amp;gt; Rust core
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SudokuEngineClient&lt;/code&gt; is a protocol on iOS and an interface on Android, and on the web it is a client that sits between the state layer and the wasm package. The values exposed to the UI are types of the language that app uses. In tests, the adapter can be swapped for a fake. Which layers are allowed to use the generated binding needs to be written down as a team rule.&lt;/p&gt;
&lt;h2&gt;The structure, built for real&lt;/h2&gt;
&lt;p&gt;I built the three layers above as a Cargo workspace. It is a minimal example made to check the structure, not the code of a real app. &lt;code&gt;sudoku-core&lt;/code&gt; holds four rules (start, apply an action, validate, generate the daily puzzle), and &lt;code&gt;sudoku-uniffi&lt;/code&gt; and &lt;code&gt;sudoku-wasm&lt;/code&gt; only wrap those functions. The environment is macOS 27.0.1, Xcode 27.0, and cargo 1.96.0.&lt;/p&gt;
&lt;p&gt;Listing the direct dependencies of each crate with &lt;code&gt;cargo tree --depth 1&lt;/code&gt; gives this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudoku-core v0.1.0
├── serde v1.0.229
└── serde_json v1.0.151

sudoku-uniffi v0.1.0
├── sudoku-core v0.1.0
└── uniffi v0.29.5

sudoku-wasm v0.1.0
├── sudoku-core v0.1.0
└── wasm-bindgen v0.2.129
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The core depends only on serde and serde_json, and the two binding crates call the core. This output confirms that no dependency runs from the core toward the bindings. The core of the example does take on JSON serialization (serde_json) as well, though. What was assigned to the binding layer above is kept in the core in the example, to keep it short.&lt;/p&gt;
&lt;p&gt;The tests of the core run with &lt;code&gt;cargo test -p sudoku-core&lt;/code&gt;. They finish with no simulator and no browser. Only the part of the output where the five tests ran is shown, and the lines before and after it that report 0 tests are left out.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;running 5 tests
test same_seed_gives_same_snapshot ... ok
test given_cell_is_rejected ... ok
test snapshot_validation_reports_errors_as_values ... ok
test daily_puzzle_depends_only_on_the_date ... ok
test fixtures_match_expected ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The five tests check that the same seed gives the same result, that an action on a given cell is rejected, and that the daily puzzle depends only on the date string. How this workspace was turned into artifacts for iOS, Android, and the web is spread over parts 3 to 7 of this series.&lt;/p&gt;
&lt;h2&gt;The order of moving code&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;List the domain logic that is duplicated across platforms.&lt;/li&gt;
&lt;li&gt;From that list, pick the candidates whose results have to match.&lt;/li&gt;
&lt;li&gt;Keep the core from depending on UI, storage, network, analytics, or payments.&lt;/li&gt;
&lt;li&gt;Separate the internal model from the binding model.&lt;/li&gt;
&lt;li&gt;Put an adapter on each platform and set the rule for access to generated code.&lt;/li&gt;
&lt;li&gt;Move small, deterministic logic first.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Good first candidates are the daily seed calculation, puzzle validation, and the reducer. Before setting up a Cargo workspace, decide whether sharing is really needed. Moving every candidate at once makes the scope large.&lt;/p&gt;
&lt;p&gt;As a way to check, I suggest tests that run with Rust alone and a fake for the app adapter. The Rust tests were run in the example above. A fake for the app adapter was not built, and performance numbers are not part of this post.&lt;/p&gt;
&lt;p&gt;Even with this structure, the responsibility for platform development, review, and release stays where it was. If the team does not keep the rules, dependencies can come back between the separated layers. Measurements that back the expectation of easier and more stable adoption are not included in this post. Once the boundary is set, the next step is to look at how to use UniFFI, XCFramework, the Android &lt;code&gt;.so&lt;/code&gt;, and WebAssembly, and the next part covers the design of the FFI API that is called from outside.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>iOS</category><category>Android</category><category>WebAssembly</category></item><item><title>What to decide when registering fonts at runtime on iOS</title><link>https://jaemyeong.com/en/blog/ios-runtime-font-registration/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/ios-runtime-font-registration/</guid><description>Bundled fonts need only an Info.plist entry, but downloaded or packaged fonts may suit runtime registration. This covers names, a registry, and file lifetime.</description><pubDate>Wed, 17 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A font that always ships inside the app never needs runtime registration. Add the file in Xcode and declare it under &lt;a href=&quot;https://developer.apple.com/documentation/bundleresources/information-property-list/uiappfonts&quot;&gt;UIAppFonts&lt;/a&gt; in &lt;code&gt;Info.plist&lt;/code&gt;, and the system loads it at launch. &lt;a href=&quot;https://developer.apple.com/documentation/uikit/adding-a-custom-font-to-your-app&quot;&gt;The guide to adding a custom font to your app&lt;/a&gt; explains this method.&lt;/p&gt;
&lt;p&gt;Runtime registration is worth considering in other cases: a font downloaded from a server, a font chosen by theme, language, or content, a font inside a Swift Package, a framework, or a separate bundle, a font list that changes after the build, and a font swapped in for tests and previews. The two methods are used together, depending on what is required.&lt;/p&gt;
&lt;p&gt;What follows covers how the API behaves and what has to be decided in operation, with example code. How the API behaves comes from the Apple documentation. Managing registration in one place, the ownership of the file and the order of deletion, and the advice to organize everything around the file URL are not requirements of the documentation. They are my own design choices. I compiled and ran the example code on macOS on October 4, 2026, and the result is in the section &quot;What running it on macOS showed.&quot; Results from running it in an iOS app and applying the font on screen are not in this post.&lt;/p&gt;
&lt;h2&gt;Registration lasts only within the process&lt;/h2&gt;
&lt;p&gt;Passing a file URL to &lt;a href=&quot;https://developer.apple.com/documentation/coretext/ctfontmanagerregisterfontsforurl%28_%3A_%3A_%3A%29&quot;&gt;CTFontManagerRegisterFontsForURL&lt;/a&gt; puts the fonts in that file into descriptor matching. After that they can be used with &lt;code&gt;UIFont(name:size:)&lt;/code&gt; or &lt;code&gt;Font.custom(_:size:)&lt;/code&gt;. For a font used only inside the app, the scope is &lt;a href=&quot;https://developer.apple.com/documentation/coretext/ctfontmanagerscope/process&quot;&gt;CTFontManagerScope.process&lt;/a&gt;. This registration disappears when the process ends, so it has to be done again on the next launch.&lt;/p&gt;
&lt;p&gt;This is the simplest registration code.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import CoreText

func registerFont(at url: URL) throws {
    var error: Unmanaged&amp;lt;CFError&amp;gt;?

    let success = CTFontManagerRegisterFontsForURL(
        url as CFURL,
        .process,
        &amp;amp;error
    )

    if !success {
        if let error = error?.takeRetainedValue() {
            throw error
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things are missing from this code. It does not check that the file exists, it does not find out the name of the font, and it has no handling for registering the same file twice.&lt;/p&gt;
&lt;h2&gt;A file name is not a font name&lt;/h2&gt;
&lt;p&gt;Registering a file named &lt;code&gt;Pretendard-Regular.otf&lt;/code&gt; does not guarantee that the font can be called by the name &lt;code&gt;Pretendard-Regular&lt;/code&gt;. What &lt;a href=&quot;https://developer.apple.com/documentation/uikit/uifont/init%28name%3Asize%3A%29&quot;&gt;UIFont(name:size:)&lt;/a&gt; takes is a fully specified name.&lt;/p&gt;
&lt;p&gt;The name is read from the file itself. &lt;a href=&quot;https://developer.apple.com/documentation/coretext/ctfontmanagercreatefontdescriptorsfromurl%28_%3A%29&quot;&gt;CTFontManagerCreateFontDescriptorsFromURL&lt;/a&gt; returns the descriptors, and &lt;a href=&quot;https://developer.apple.com/documentation/coretext/kctfontnameattribute&quot;&gt;kCTFontNameAttribute&lt;/a&gt; gives the PostScript name. One file can hold several faces, as in a &lt;code&gt;.ttc&lt;/code&gt; file, so the result is an array.&lt;/p&gt;
&lt;p&gt;This function collects the PostScript names from the descriptors in a file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import CoreText

func postScriptNames(in url: URL) -&amp;gt; [String] {
    guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else {
        return []
    }

    return descriptors.compactMap {
        CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;During development, logging the names that were read makes it easy to confirm which name to call.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let names = postScriptNames(in: fontURL)
print(&quot;Font PostScript names:&quot;, names)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The output of an actual run is in the section &quot;What running it on macOS showed.&quot; The names that were read are gathered in one place, such as a design system or a registry. Writing the string directly in each view makes it hard to replace a font or trace an error.&lt;/p&gt;
&lt;h2&gt;An example that manages registration state in one place&lt;/h2&gt;
&lt;p&gt;Calling registration every time a cell is created, every time a SwiftUI &lt;code&gt;body&lt;/code&gt; is evaluated, or every time a screen appears can cause duplicate registration errors and unnecessary cost. So one object is put in charge of registration. &lt;code&gt;RuntimeFontRegistry.shared&lt;/code&gt; handles the file check, the name collection, and the record of registrations per URL.&lt;/p&gt;
&lt;p&gt;First comes the part that holds the state. It stores the URL as the key and the array of names read from that file as the value. The three blocks that follow are the rest of this class. To combine them, remove the &lt;code&gt;// …&lt;/code&gt; line and the closing brace below it from this block, then append the three blocks in order. The last block closes the class and declares the error type outside it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import Foundation
import CoreText

final class RuntimeFontRegistry {
    static let shared = RuntimeFontRegistry()

    private var registeredURLs: [URL: [String]] = [:]
    private let lock = NSLock()

    private init() {}

    // …
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The register method normalizes the key with &lt;code&gt;standardizedFileURL&lt;/code&gt;, checks that the file exists, and checks that the descriptors and names are valid. It then looks up the cache under an &lt;code&gt;NSLock&lt;/code&gt;. If the URL is already registered, it returns the stored names. If not, it registers with &lt;code&gt;.process&lt;/code&gt; and puts the array of names into the cache.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    @discardableResult
    func registerFont(at url: URL) throws -&amp;gt; [String] {
        let url = url.standardizedFileURL

        guard FileManager.default.fileExists(atPath: url.path) else {
            throw FontRegistrationError.fileNotFound(url)
        }

        let names = try Self.readPostScriptNames(from: url)

        lock.lock()
        if let cached = registeredURLs[url] {
            lock.unlock()
            return cached
        }
        lock.unlock()

        var error: Unmanaged&amp;lt;CFError&amp;gt;?
        let success = CTFontManagerRegisterFontsForURL(
            url as CFURL,
            .process,
            &amp;amp;error
        )

        if !success {
            if let error = error?.takeRetainedValue() as Error? {
                throw error
            }
        }

        lock.lock()
        registeredURLs[url] = names
        lock.unlock()

        return names
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The unregister method calls CoreText with the same scope used for registration and removes the entry from the cache.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    func unregisterFont(at url: URL) throws {
        let url = url.standardizedFileURL

        var error: Unmanaged&amp;lt;CFError&amp;gt;?
        let success = CTFontManagerUnregisterFontsForURL(
            url as CFURL,
            .process,
            &amp;amp;error
        )

        if !success {
            if let error = error?.takeRetainedValue() as Error? {
                throw error
            }
        }

        lock.lock()
        registeredURLs.removeValue(forKey: url)
        lock.unlock()
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the part that reads the names, and the error type. It tells apart a missing file, a file that is not a font, and a file with no PostScript name.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    private static func readPostScriptNames(from url: URL) throws -&amp;gt; [String] {
        guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor],
              !descriptors.isEmpty else {
            throw FontRegistrationError.invalidFontFile(url)
        }

        let names = descriptors.compactMap {
            CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
        }

        guard !names.isEmpty else {
            throw FontRegistrationError.missingPostScriptName(url)
        }

        return names
    }
}

enum FontRegistrationError: Error {
    case fileNotFound(URL)
    case invalidFontFile(URL)
    case missingPostScriptName(URL)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Some things are not handled in this example. Interpreting the error codes that CoreText returns is extra work. I recommend treating a repeated request for the same URL as success, and treating a different file with the same PostScript name as a conflict. A wrong or damaged file has to be handled separately from a duplicate or a name conflict.&lt;/p&gt;
&lt;p&gt;Reading the code also shows some limits. &lt;code&gt;NSLock&lt;/code&gt; is held only while the cache is looked up and while it is stored, and the CoreText call is outside the lock. So two simultaneous requests for the same URL can both enter registration. Also, when &lt;code&gt;success&lt;/code&gt; is false but &lt;code&gt;error&lt;/code&gt; is nil, the code does not throw and moves on to the next line. I read these two points from the code. I did not reproduce them by running it.&lt;/p&gt;
&lt;h2&gt;A downloaded font needs a decision about the file&apos;s lifetime&lt;/h2&gt;
&lt;p&gt;If a file in a temporary location is registered, there is a risk that the system keeps a reference after the file is deleted. So the file is moved to a location the app manages, and registered after that.&lt;/p&gt;
&lt;p&gt;This function creates a &lt;code&gt;RuntimeFonts&lt;/code&gt; directory under &lt;code&gt;Application Support&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func fontStorageDirectory() throws -&amp;gt; URL {
    let baseURL = try FileManager.default.url(
        for: .applicationSupportDirectory,
        in: .userDomainMask,
        appropriateFor: nil,
        create: true
    )

    let directory = baseURL.appendingPathComponent(&quot;RuntimeFonts&quot;, isDirectory: true)

    if !FileManager.default.fileExists(atPath: directory.path) {
        try FileManager.default.createDirectory(
            at: directory,
            withIntermediateDirectories: true
        )
    }

    return directory
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This function copies the downloaded file into that directory and registers it with the registry. The file name is the last path component of &lt;code&gt;temporaryURL&lt;/code&gt; as it is, and if a file with the same name exists, it is deleted before the copy.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func installDownloadedFont(from temporaryURL: URL) throws -&amp;gt; [String] {
    let directory = try fontStorageDirectory()
    let destinationURL = directory.appendingPathComponent(temporaryURL.lastPathComponent)

    if FileManager.default.fileExists(atPath: destinationURL.path) {
        try FileManager.default.removeItem(at: destinationURL)
    }

    try FileManager.default.copyItem(at: temporaryURL, to: destinationURL)

    return try RuntimeFontRegistry.shared.registerFont(at: destinationURL)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This install example does not unregister before deleting the existing file. That does not match the advice below to unregister before deleting. If the file at an already registered URL is replaced, the registry can also return the names it cached earlier. This is another limit read from the code. The descriptor check happens inside the registry after the copy, and there is no code that checks the integrity of the download, the license, or a name conflict.&lt;/p&gt;
&lt;p&gt;What has to be decided in operation lies outside this example: when to delete the file, how to register on the next launch, the fallback and what the user sees when a download fails, the server response and distribution, cache invalidation, the relation to the app version, and the license review.&lt;/p&gt;
&lt;h2&gt;What running it on macOS showed&lt;/h2&gt;
&lt;p&gt;I joined the Swift code of this post as it is, compiled it, and ran it. The run used Swift 6.4 on macOS 27.0.1. CoreText exists on macOS too, so registering and unregistering could be checked with no iOS device. There is no UIKit there, though, so the name was checked with &lt;code&gt;CTFontCreateWithName&lt;/code&gt; in place of &lt;code&gt;UIFont(name:size:)&lt;/code&gt;. The font was &lt;code&gt;PretendardStd-Bold.otf&lt;/code&gt;, which was already on this Mac and was copied to a temporary folder. It was not installed in the system.&lt;/p&gt;
&lt;p&gt;The code compiled with no warnings. Below is the output of a run with calling code added that checks whether the font can be found by name before and after registration.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Font PostScript names: [&quot;PretendardStd-Bold&quot;]
before register: discoverable=false
registry.registerFont: [&quot;PretendardStd-Bold&quot;]
after register : discoverable=true
CTFontCreateWithName -&amp;gt; PretendardStd-Bold
registry.registerFont again (cached): [&quot;PretendardStd-Bold&quot;]
plain registerFont again: error com.apple.CoreText.CTFontManagerErrorDomain code=105
after unregister: discoverable=false
unregister again: error com.apple.CoreText.CTFontManagerErrorDomain code=201
missing file: fileNotFound(&amp;lt;url&amp;gt;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four things can be read from it.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The font could not be found by name before registration, could be found after it, and could not be found again after unregistration.&lt;/li&gt;
&lt;li&gt;On the second registration, the registry returned the names it had cached. Registering the same URL again without going through the registry gave an error with code 105, the value of CoreText&apos;s &lt;code&gt;kCTFontManagerErrorAlreadyRegistered&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Unregistering twice gave an error with code 201 (&lt;code&gt;kCTFontManagerErrorNotRegistered&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A missing file was caught as &lt;code&gt;fileNotFound&lt;/code&gt; before CoreText was called.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With a font file that is already installed in the system, the same run found the font by name before registration and after unregistration as well. The same font remains at another path. Code that judges registration by name cannot tell that case apart. &lt;code&gt;installDownloadedFont&lt;/code&gt; was not called.&lt;/p&gt;
&lt;h2&gt;Unregistering only removes the font from matching&lt;/h2&gt;
&lt;p&gt;Calling &lt;a href=&quot;https://developer.apple.com/documentation/coretext/ctfontmanagerunregisterfontsforurl%28_%3A_%3A_%3A%29&quot;&gt;CTFontManagerUnregisterFontsForURL&lt;/a&gt; takes the font out of descriptor matching. It should not be read as cleaning up &lt;code&gt;UIFont&lt;/code&gt;, &lt;code&gt;CTFont&lt;/code&gt;, &lt;code&gt;NSAttributedString&lt;/code&gt;, and SwiftUI views that already exist.&lt;/p&gt;
&lt;p&gt;The lifetime is decided by how the font is used. A font needed for the whole run stays registered. For a temporarily downloaded resource, an owner is decided and that owner unregisters it. Unregistering is attempted before the file is deleted, and if it fails, the deletion is postponed. The scope for registering and unregistering has to be the same, so a font registered with &lt;code&gt;.process&lt;/code&gt; is unregistered with &lt;code&gt;.process&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/coretext/ctfontmanagerregistergraphicsfont%28_%3A_%3A%29&quot;&gt;CTFontManagerRegisterGraphicsFont&lt;/a&gt; is the API that registers a &lt;code&gt;CGFont&lt;/code&gt; object. For a font in a file, the Apple documentation points to URL registration. In the documentation as rechecked on October 4, 2026, this API is marked deprecated as of iOS 18.0 and macOS 15.0, with a note to use &lt;code&gt;CTFontManagerCreateFontDescriptorsFromData&lt;/code&gt; or &lt;code&gt;CTFontManagerRegisterFontsForURL&lt;/code&gt;. That designing storage, registration, unregistration, and the cache as one unit around the file URL is easier to handle is my own judgment.&lt;/p&gt;
&lt;p&gt;The lifetime of &lt;code&gt;.process&lt;/code&gt;, the choice between URL registration and &lt;code&gt;CGFont&lt;/code&gt; registration, and the effect of unregistering on objects that already exist need to be checked against the SDK and OS version. Branching per error code, conflict handling, safe file replacement and cache invalidation, registration after a restart, and the fallback policy are only described in this post and not implemented in the examples. What the examples do contain is the per-URL cache and a simple file replacement.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>iOS</category><category>Swift</category><category>CoreText</category><category>Font</category></item><item><title>What to look at before adopting a Swift package registry</title><link>https://jaemyeong.com/en/blog/swift-package-registry/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/swift-package-registry/</guid><description>Receiving Swift dependencies by registry ID changes the path they travel. Request order, configuration, mixed graphs, mirrors, authentication, and signing.</description><pubDate>Wed, 17 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Swift dependencies are usually declared with a Git URL. The same dependency can also be declared with a registry identifier.&lt;/p&gt;
&lt;p&gt;This is a declaration with a Git URL.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(url: &quot;https://github.com/apple/swift-log.git&quot;, from: &quot;1.5.0&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a declaration with a registry ID.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(id: &quot;apple.swift-log&quot;, from: &quot;1.5.0&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The difference is one line, but the path the package travels changes, and CI, reproducibility, internal distribution, security, and Xcode configuration are affected along with it. This post lists what an iOS team should look at when deciding whether to choose this approach. The post does not record applying it to a specific project, and it has no measured resolve times.&lt;/p&gt;
&lt;h2&gt;The cost of fetching through Git&lt;/h2&gt;
&lt;p&gt;With a Git URL, the first build clones the repository and picks a version from the tags. Even when only one release is needed, the Git history comes along. With a large repository or a complex dependency graph, resolving can get slow.&lt;/p&gt;
&lt;p&gt;Reproducibility is at risk too. What a tag points to can change. If the repository is deleted or moved, having &lt;code&gt;Package.resolved&lt;/code&gt; does not guarantee a build.&lt;/p&gt;
&lt;p&gt;Some environments have a restricted network. When &lt;code&gt;github.com&lt;/code&gt; is blocked, clones and CI can fail. In such places the security team usually requires going through an approved Artifactory, a Git mirror, or an artifact store. With a registry, a setup that supplies packages without reaching GitHub becomes possible.&lt;/p&gt;
&lt;h2&gt;The order in which a registry serves a package&lt;/h2&gt;
&lt;p&gt;A registry identifier has the form &lt;code&gt;scope.package-name&lt;/code&gt;. For &lt;code&gt;mona.LinkedList&lt;/code&gt;, the scope is &lt;code&gt;mona&lt;/code&gt; and the name is &lt;code&gt;LinkedList&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(id: &quot;mona.LinkedList&quot;, .upToNextMajor(from: &quot;1.0.0&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;According to &lt;a href=&quot;https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md&quot;&gt;the service specification&lt;/a&gt;, the main requests come in three steps. The list of releases comes from &lt;code&gt;/{scope}/{name}&lt;/code&gt;, then the &lt;code&gt;Package.swift&lt;/code&gt; of that version, then the archive from &lt;code&gt;/{scope}/{name}/{version}.zip&lt;/code&gt;. Checking the checksum and the signature also fetches the metadata of the release (&lt;a href=&quot;https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/PackageRegistryUsage.md&quot;&gt;usage documentation&lt;/a&gt;). Because the archive is served over HTTP, releases can be operated as immutable, and a CDN or an internal store can be used. Source control and distribution are separated. The background is in &lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0292-package-registry-service.md&quot;&gt;SE-0292&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The development repository does not have to move. Development can continue on GitHub, GitLab, GitHub Enterprise Server, or an internal Git server, and the registry only delivers releases.&lt;/p&gt;
&lt;h2&gt;Where the configuration lives&lt;/h2&gt;
&lt;p&gt;The default registry is set with the CLI.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;swift package-registry set https://packages.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The setting is stored at project scope and at user scope. This is the file location at project scope.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.swiftpm/configuration/registries.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the file location at user scope.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/.swiftpm/configuration/registries.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The file holds the default URL and the format version.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;registries&quot;: {
    &quot;[default]&quot;: {
      &quot;url&quot;: &quot;https://packages.example.com&quot;
    }
  },
  &quot;version&quot;: 1
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the help text of Swift 6.4, &lt;code&gt;set&lt;/code&gt; takes one URL and has the &lt;code&gt;--global&lt;/code&gt; and &lt;code&gt;--scope&lt;/code&gt; options. I confirmed this only with &lt;code&gt;--help&lt;/code&gt; on October 4, 2026. I did not connect to a registry server or change any configuration.&lt;/p&gt;
&lt;p&gt;For a package project, declaring by ID in &lt;code&gt;Package.swift&lt;/code&gt; is enough. An iOS app is different. An Xcode app project usually has no &lt;code&gt;Package.swift&lt;/code&gt;, and dependencies are managed through &lt;code&gt;project.pbxproj&lt;/code&gt;, the workspace, and &lt;code&gt;Package.resolved&lt;/code&gt;. Do not assume that a setting made with the CLI applies to Xcode as it is. Check where the setting is actually stored.&lt;/p&gt;
&lt;p&gt;Within a team, the registry URL setting is shared, and access tokens are not committed. Another option is a CLI or GUI tool that finds the configuration location in the workspace or &lt;code&gt;.xcodeproj&lt;/code&gt; and writes &lt;code&gt;mirrors.json&lt;/code&gt;. That is my suggestion, and this post has no example of having built one.&lt;/p&gt;
&lt;h2&gt;When Git and a registry are mixed&lt;/h2&gt;
&lt;p&gt;The case that needs the most care is one package arriving by two paths. The direct dependency is declared by registry ID, and another package pulls in the same thing by Git URL.&lt;/p&gt;
&lt;p&gt;This is the direct dependency.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(id: &quot;apple.swift-log&quot;, from: &quot;1.5.0&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the declaration that comes in as a transitive dependency.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(url: &quot;https://github.com/apple/swift-log.git&quot;, from: &quot;1.5.0&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With different origins, the two can get separate identities. Then modules or products can be duplicated, and symbols can clash.&lt;/p&gt;
&lt;p&gt;What connects the two is &lt;code&gt;/identifiers?url=&lt;/code&gt;. It looks up the registry ID that corresponds to a Git URL. The mapping between URL and ID has to be exact. It is better to keep one origin per package, and I suggest carrying out the move to ID declarations and the upkeep of Git or mirrors together, step by step.&lt;/p&gt;
&lt;h2&gt;A mirror is something else&lt;/h2&gt;
&lt;p&gt;A mirror has a different purpose from a registry. The mirror in &lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0219-package-manager-dependency-mirroring.md&quot;&gt;SE-0219&lt;/a&gt; keeps the &lt;code&gt;.package(url:)&lt;/code&gt; declaration and only changes the address fetched from to another Git server.&lt;/p&gt;
&lt;p&gt;This is an example that maps the original address to an internal Git address. &lt;code&gt;git.example.com&lt;/code&gt; stands for the address of the internal Git server.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;object&quot;: [
    {
      &quot;original&quot;: &quot;https://github.com/apple/swift-log.git&quot;,
      &quot;mirror&quot;: &quot;https://git.example.com/mirrors/apple/swift-log.git&quot;
    }
  ],
  &quot;version&quot;: 1
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A registry is the path of IDs and archives, and a mirror is the path that swaps one Git for another. In a restricted network, I suggest applying an internal mirror first and moving to a registry after that.&lt;/p&gt;
&lt;h2&gt;Authentication and signing&lt;/h2&gt;
&lt;p&gt;A private registry needs authentication, and the &lt;code&gt;login&lt;/code&gt; command can store the credentials (&lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0378-package-registry-auth.md&quot;&gt;SE-0378&lt;/a&gt;).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;swift package-registry login https://packages.example.com --token &quot;$TOKEN&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Authentication is about access rights. Whether a release is genuine is handled by signing. A signature is evidence that the publisher holds the key and that the release has not been altered. The two signature headers in the archive response (&lt;code&gt;X-Swift-Package-Signature-Format&lt;/code&gt; and &lt;code&gt;X-Swift-Package-Signature&lt;/code&gt;) are checked, and the signature and the certificate chain are validated. A verified signature does not mean that the publisher can be trusted. Which CAs to trust, and which certificate may publish which scope, are decided separately by the organization.&lt;/p&gt;
&lt;p&gt;Signing needs an X.509 certificate for code signing and a private key. On macOS an identity in the Keychain can be used, and in CI passing a key file and certificate chain files is also possible (&lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0391-package-registry-publish.md&quot;&gt;SE-0391&lt;/a&gt;). The publish endpoint that the command below uploads the archive to is defined in &lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0321-package-registry-publish.md&quot;&gt;SE-0321&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;swift package-registry publish mycompany.design-system 1.2.0 \
  --url https://packages.example.com \
  --private-key-path ./private-key.pkcs8.der \
  --cert-chain-paths ./leaf-cert.der ./intermediate.der ./root.der
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I confirmed only that &lt;code&gt;--token&lt;/code&gt; of &lt;code&gt;login&lt;/code&gt; and &lt;code&gt;--url&lt;/code&gt;, &lt;code&gt;--private-key-path&lt;/code&gt;, and &lt;code&gt;--cert-chain-paths&lt;/code&gt; of &lt;code&gt;publish&lt;/code&gt; are in the help text of Swift 6.4. The commands were not run against a real server. The environment variable &lt;code&gt;SWIFTPM_REGISTRY_TOKEN&lt;/code&gt;, which the earlier version mentioned for CI, did not appear in the help text. &lt;a href=&quot;https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/PackageRegistryUsage.md&quot;&gt;The usage documentation&lt;/a&gt; on the main branch lists this variable as a way to authenticate (checked on October 4, 2026). I could not confirm whether Swift 6.4 supports it, so check with the toolchain in use before relying on it.&lt;/p&gt;
&lt;p&gt;At the organization level, the scopes and packages each certificate may publish are managed, and when an internal CA is operated, the SwiftPM trust root configuration has to be distributed to the team.&lt;/p&gt;
&lt;h2&gt;Deciding whether to adopt it&lt;/h2&gt;
&lt;p&gt;With few dependencies and free access to GitHub, the gain may be small. The following are reasons to consider it. Resolving is slow. There are many internal packages. Outside access is restricted. A store such as JFrog, Nexus, or Artifactory is already in operation. A private SDK is delivered to customers. There are requirements for checksums, signing, or audit logs.&lt;/p&gt;
&lt;p&gt;I suggest checking in the following order.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;List the dependencies.&lt;/li&gt;
&lt;li&gt;Look at the Git URLs in &lt;code&gt;Package.resolved&lt;/code&gt; and in Xcode.&lt;/li&gt;
&lt;li&gt;Measure how long resolving takes in CI.&lt;/li&gt;
&lt;li&gt;Check whether GitHub, GitHubusercontent, and external binary URLs are allowed.&lt;/li&gt;
&lt;li&gt;Separate what to receive through a registry from what to receive through a mirror.&lt;/li&gt;
&lt;li&gt;Check that no dependency graph brings the same package in by both a Git URL and a registry ID.&lt;/li&gt;
&lt;li&gt;Confirm where the configuration lives in the workspace and &lt;code&gt;.xcodeproj&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Decide how tokens and CI variables are handled.&lt;/li&gt;
&lt;li&gt;Decide whether signing is needed and which CA to trust.&lt;/li&gt;
&lt;li&gt;See how it connects to the internal store.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Package approval, the choice between a registry and a mirror, sharing the Xcode configuration, and the external hosts allowed in CI are matters for the team or a platform group to set as policy.&lt;/p&gt;
&lt;p&gt;The request order, the configuration files, authentication, and signing come from the Swift Evolution proposals and the SwiftPM documentation. The adoption order and the checklist are my own design choices built on that, not results confirmed by applying them. Where the configuration lives in each Xcode version, and how compatible it is, was not checked. Results confirming how a mixed graph actually behaves, or the speed before and after moving to a registry, are not in this post either. The documentation links point to the main branch, so their content can change.&lt;/p&gt;
</content:encoded><category>it-tech</category><category>Swift</category><category>SwiftPM</category><category>Package Registry</category><category>iOS</category></item><item><title>Five changes from WWDC26 for iOS developers</title><link>https://jaemyeong.com/en/blog/wwdc26-ios-developer-changes/</link><guid isPermaLink="true">https://jaemyeong.com/en/blog/wwdc26-ios-developer-changes/</guid><description>What Apple announced at WWDC26 for iOS developers: App Intents, where models run, Xcode 27, the screen, and subscriptions. Facts and my view are kept apart.</description><pubDate>Wed, 17 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;On June 8, 2026, at WWDC26, Apple announced iOS 27, iPadOS 27, macOS 27, watchOS 27, visionOS 27, and tvOS 27 (&lt;a href=&quot;https://www.apple.com/newsroom/2026/06/apple-unveils-next-generation-of-apple-intelligence-siri-ai-and-more/&quot;&gt;Apple Newsroom&lt;/a&gt;). According to the same press release, the developer beta was available from the day of the announcement, and the release comes this fall as a free software update.&lt;/p&gt;
&lt;p&gt;This post sorts what reaches people who build iOS apps into five areas. The announced facts are what I rechecked on October 4, 2026 on six official Apple pages, and what comes later in each section is my own view. Results of implementing or measuring anything on the beta are not in this post.&lt;/p&gt;
&lt;h2&gt;Exposing app content and actions to the system&lt;/h2&gt;
&lt;p&gt;According to &lt;a href=&quot;https://developer.apple.com/wwdc26/guides/ios/&quot;&gt;the WWDC26 iOS guide&lt;/a&gt;, Siri reaches the content and actions of an app in natural language through the App Intents framework. Entity schemas contribute the content of the app to the Spotlight semantic index. With intent schemas, people can ask for actions on that content in natural language, with no specific phrases to define. The new View Annotations API maps views to entities, so that what is on screen can be referred to and acted on in conversation. The App Intents Testing framework validates the integration through real system pathways, without UI automation.&lt;/p&gt;
&lt;p&gt;From here on it is my view. The ways into a feature from Siri and Spotlight, outside the app, are getting wider, so I see App Intents support as something to put into the first design, not an extra convenience. I expect it to be harder to add later. I recommend deciding first which entities and actions to expose. For a sports app the candidates are games, teams, leagues, and schedules, and for a commerce app they are products, orders, the cart, and delivery status. These examples are mine and are not in Apple&apos;s guide.&lt;/p&gt;
&lt;h2&gt;Where the model runs&lt;/h2&gt;
&lt;p&gt;The same iOS guide introduces the Foundation Models framework as a Swift API for the on-device model that Apple Intelligence uses. It says the framework now works with Apple Foundation Models, with cloud models like Claude and Gemini, and with any other provider that conforms to the Language Model protocol. Multimodal prompts pass images alongside text, and Dynamic Profiles swap models, tools, and instructions within a continuous session. The Evaluations framework is a tool for verifying that AI features behave correctly across conditions.&lt;/p&gt;
&lt;p&gt;Core AI is a new framework for running your own models on-device. It is built into the OS and made for Apple Silicon, and models are loaded and run through a Swift API. The guide names ahead-of-time compilation, zero-copy data paths, and stateful execution as its performance elements. For Private Cloud Compute, there is a condition. The guide says that if you are enrolled in the App Store Small Business Program and your app has fewer than 2 million total first-time downloads, you can access the next generation of Apple Foundation Models running on Private Cloud Compute at no cloud API cost.&lt;/p&gt;
&lt;p&gt;As I read it, the two have different roles. Foundation Models connects language and multimodal models to an app, and Core AI runs a model you already have on Apple Silicon. I recommend looking first at handling summarizing, classifying, recommending, and input correction on the device, and at Private Cloud Compute or an outside provider for large inference and generation. For sensitive information, check whether it can be handled on the device before it is sent out. The things to compare are cost, latency, and privacy. Also look at whether testing and evaluation can be automated.&lt;/p&gt;
&lt;h2&gt;Xcode 27 and agents&lt;/h2&gt;
&lt;p&gt;According to &lt;a href=&quot;https://developer.apple.com/news/?id=lvart8mq&quot;&gt;the summary of the Platforms State of the Union&lt;/a&gt;, Xcode 27 is Apple silicon only. Projects load faster, settings sync via iCloud, and the toolbar is customizable. The new Device Hub replaces Simulator and brings virtual and physical devices together in one place. The same article says agents in Xcode can run tests, try things in Playground, run apps in the Simulator, fix issues, and localize apps. Plugins add skills and MCP tools, and other agents connect through the Agent Client Protocol. &lt;a href=&quot;https://developer.apple.com/wwdc26/guides/xcode/&quot;&gt;The WWDC26 Xcode guide&lt;/a&gt; covers Device Hub, improvements to Instruments, and localization with agents.&lt;/p&gt;
&lt;p&gt;The sentence that Device Hub replaces Simulator and the sentence that agents run apps in the Simulator are in the same article. From that article alone I could not tell whether only the management screen changed or the runtime changed as well.&lt;/p&gt;
&lt;p&gt;If you develop on an Intel Mac or run CI builds on one, I think a plan to move is needed. I expect agents to be useful for validating small changes, analyzing test failures, and tidying string catalogs. Even so, I recommend treating generated code as a draft and always reviewing the diff. A team does well to agree first on how far agents are used and on the review rules. Changes that touch architecture, authentication, payments, storage, or sync deserve a stricter look. I think decisions about structure and quality have to stay with the developer.&lt;/p&gt;
&lt;h2&gt;The material and the size of the screen&lt;/h2&gt;
&lt;p&gt;According to &lt;a href=&quot;https://www.apple.com/os/ios/&quot;&gt;the iOS 27 page&lt;/a&gt;, Liquid Glass has more uniform refraction and improved contrast, and app icons are sharper. A slider lets users adjust Liquid Glass from ultraclear to fully tinted. The page also gives performance figures. App launches are up to 30% faster, new photos load in Photos up to 70% faster, and AirDrop transfers are up to 80% faster. These numbers are upper bounds that Apple measured on specific devices under specific conditions, and the footnotes give the test conditions. They do not mean every app gets that much faster.&lt;/p&gt;
&lt;p&gt;The State of the Union summary says iOS apps are now resizable. People can use larger displays on iPad, and in iPhone Mirroring on Mac.&lt;/p&gt;
&lt;p&gt;I see custom navigation bars, tab bars, toolbars, blur backgrounds, translucent overlays, and floating buttons that an app implements itself as things to check again. Confirm that they stay readable with Dynamic Type, dark mode, Reduce Transparency, and increased contrast. A layout that assumes the size of an iPhone can break when the size changes, so I recommend checking touch areas, scrolling, and transitions on a real device.&lt;/p&gt;
&lt;h2&gt;Subscription products and review submission&lt;/h2&gt;
&lt;p&gt;According to &lt;a href=&quot;https://developer.apple.com/wwdc26/guides/app-store/&quot;&gt;the WWDC26 App Store guide&lt;/a&gt;, multiple In-App Purchases, including subscriptions, can be grouped into a single submission in App Store Connect. They can also go together with other review items such as In-App Events, custom product pages, and product page optimization tests.&lt;/p&gt;
&lt;p&gt;Retention Messaging shows subscribers a message about the value of the subscription. Apple describes it as not adding friction to the cancellation flow, and says a special offer can be shown with it. It is listed as coming this fall. Subscription Bundles let people buy several auto-renewable subscriptions as a single subscription, and Suites offer a set of subscriptions that are not sold standalone. The guide says more on how to request these two will come later in the summer. Group Purchases let one subscriber buy multiple seats in a single purchase and invite others. Volume Purchasing makes a subscription available to enterprise and education buyers through Apple School Manager and Apple Business.&lt;/p&gt;
&lt;p&gt;If you run subscriptions, I do not think the StoreKit code is the only thing to look at. The product model, operations in App Store Connect, review, entitlements after purchase, and the cancellation process need to be reviewed together. Pricing and bundling, the response to churn, and sales to organizations are questions to settle together with policy.&lt;/p&gt;
&lt;p&gt;What this post relies on is Apple&apos;s press release and guide pages as they were on October 4, 2026. API names and availability conditions can change in the release version. Availability by region and device, and beta numbers, are not covered here. This post includes no implementation results for the parts written as my view.&lt;/p&gt;
</content:encoded><category>it-news</category><category>WWDC26</category><category>iOS</category><category>App Intents</category><category>Apple Intelligence</category></item></channel></rss>